#!/usr/bin/env python3
"""
Left-edge swipe-to-back gesture daemon for FuriPhone.
- Linux app focused → Alt+Left
- Android app focused → KEYCODE_BACK via andromeda shell
Handles all four display rotations via wl_output transform tracking.
"""
import evdev
from evdev import UInput, ecodes as e
import os, sys, struct, subprocess, tempfile, threading, time, select, shutil
TOUCH_DEV = '/dev/input/event3'
TOUCH_MAX_X = 720
TOUCH_MAX_Y = 1600
EDGE = 60 # px from the "left" edge (axis depends on rotation)
MIN_SWIPE = 120 # minimum travel along the swipe axis
MAX_DRIFT = 200 # maximum travel on the perpendicular axis
MAX_TIME = 0.7 # seconds
STATE_ACTIVATED = 2 # ZWLR_FOREIGN_TOPLEVEL_HANDLE_V1_STATE_ACTIVATED
# wl_output transform constants (Wayland spec)
TRANSFORM_NORMAL = 0 # portrait
TRANSFORM_90 = 1 # 90° CCW
TRANSFORM_180 = 2 # 180°
TRANSFORM_270 = 3 # 270° CCW (= 90° CW)
_focused = [None]
_transform = [TRANSFORM_NORMAL]
_lock = threading.Lock()
_last_fire = 0.0
DEBOUNCE = 0.3
_PROTO_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="wlr_foreign_toplevel_management_unstable_v1">
<interface name="zwlr_foreign_toplevel_manager_v1" version="3">
<event name="toplevel">
<arg name="toplevel" type="new_id" interface="zwlr_foreign_toplevel_handle_v1"/>
</event>
<event name="finished" since="2"/>
<request name="stop"/>
</interface>
<interface name="zwlr_foreign_toplevel_handle_v1" version="3">
<event name="title"><arg name="title" type="string"/></event>
<event name="app_id"><arg name="app_id" type="string"/></event>
<event name="output_enter" since="1"><arg name="output" type="uint"/></event>
<event name="output_leave" since="1"><arg name="output" type="uint"/></event>
<event name="state"><arg name="state" type="array"/></event>
<event name="done"/>
<event name="closed"/>
<event name="parent" since="3"><arg name="parent" type="uint"/></event>
<request name="destroy" type="destructor"/>
</interface>
</protocol>"""
def _focus_tracker():
out_dir = None
xml_path = None
try:
xml_fd, xml_path = tempfile.mkstemp(suffix='.xml', prefix='swipe-back-')
out_dir = tempfile.mkdtemp(prefix='swipe-back-gen-')
with os.fdopen(xml_fd, 'w') as f:
f.write(_PROTO_XML)
r = subprocess.run(
[sys.executable, '-m', 'pywayland.scanner', '-i', xml_path, '-o', out_dir],
capture_output=True, text=True
)
if r.returncode != 0:
print(f'[swipe-back] scanner failed: {r.stderr}', flush=True)
return
sys.path.insert(0, out_dir)
from wlr_foreign_toplevel_management_unstable_v1 import ZwlrForeignToplevelManagerV1
from pywayland.client import Display
from pywayland.protocol.wayland import WlOutput
display = Display()
display.connect()
registry = display.get_registry()
handles = {}
def on_toplevel(mgr, handle):
info = {'app_id': None, 'activated': False}
key = id(handle)
handles[key] = info
def on_app_id(handle, app_id):
info['app_id'] = app_id
if info['activated']:
with _lock:
_focused[0] = app_id
def on_state(handle, raw):
try:
data = bytes(raw) if not isinstance(raw, (bytes, bytearray)) else raw
n = len(data) // 4
states = struct.unpack(f'<{n}I', data) if n else ()
except Exception:
states = ()
activated = STATE_ACTIVATED in states
if activated != info['activated']:
info['activated'] = activated
if activated:
with _lock:
_focused[0] = info['app_id']
def on_closed(handle):
handles.pop(key, None)
handle.dispatcher['app_id'] = on_app_id
handle.dispatcher['state'] = on_state
handle.dispatcher['closed'] = on_closed
def on_global(registry, name, interface, version):
if interface == 'zwlr_foreign_toplevel_manager_v1':
mgr = registry.bind(name, ZwlrForeignToplevelManagerV1, min(version, 3))
mgr.dispatcher['toplevel'] = on_toplevel
elif interface == 'wl_output':
output = registry.bind(name, WlOutput, min(version, 4))
def on_geometry(output, x, y, pw, ph, subpixel, make, model, transform):
with _lock:
_transform[0] = transform
print(f'[swipe-back] display transform → {transform}', flush=True)
output.dispatcher['geometry'] = on_geometry
registry.dispatcher['global'] = on_global
display.roundtrip()
display.roundtrip()
print('[swipe-back] focus tracker active', flush=True)
fd = display.get_fd()
while True:
display.flush()
select.select([fd], [], [], 1.0)
display.roundtrip()
except Exception as ex:
print(f'[swipe-back] focus tracker error: {ex}', flush=True)
with _lock:
_focused[0] = None
finally:
if xml_path and os.path.exists(xml_path):
os.unlink(xml_path)
if out_dir:
shutil.rmtree(out_dir, ignore_errors=True)
def _make_uinput():
return UInput({e.EV_KEY: [e.KEY_LEFTALT, e.KEY_LEFT]},
name='swipe-back-virtual', version=0x3)
def _send_linux_back(ui):
ui.write(e.EV_KEY, e.KEY_LEFTALT, 1)
ui.write(e.EV_KEY, e.KEY_LEFT, 1)
ui.write(e.EV_SYN, e.SYN_REPORT, 0)
ui.write(e.EV_KEY, e.KEY_LEFT, 0)
ui.write(e.EV_KEY, e.KEY_LEFTALT, 0)
ui.write(e.EV_SYN, e.SYN_REPORT, 0)
def _send_android_back():
r = subprocess.run(['sudo', '-n', '/usr/local/bin/android-back'],
capture_output=True, timeout=2)
if r.returncode != 0:
print(f'[swipe-back] android-back failed: {r.stderr.decode(errors="replace")}',
flush=True)
def _is_gesture(s, dt):
"""Return True if the completed touch matches a back swipe for the current transform."""
x0, y0 = s['x0'], s['y0']
x, y = s['x'], s['y']
dx = x - x0
dy = y - y0
with _lock:
t = _transform[0]
if dt > MAX_TIME:
return False
# Raw touch coords are always in the sensor's native space, independent of
# display rotation. On FuriPhone the panel is natively landscape, so:
# portrait → phoc reports transform=1 (90° CCW to rotate landscape→portrait)
# landscape → phoc reports transform=0 (native, no rotation)
#
# Physical left edge in each orientation:
# transform 0 (landscape / native): raw x=0 → swipe right (+dx)
# transform 1 (portrait, 90° CCW): raw y=MAX_Y → swipe up (-dy)
# transform 2 (upside-down): raw x=MAX_X → swipe left (-dx)
# transform 3 (270° CCW / 90° CW): raw y=0 → swipe down (+dy)
if t == TRANSFORM_NORMAL:
return x0 < EDGE and dx >= MIN_SWIPE and abs(dy) <= MAX_DRIFT
elif t == TRANSFORM_90:
return y0 > TOUCH_MAX_Y - EDGE and -dy >= MIN_SWIPE and abs(dx) <= MAX_DRIFT
elif t == TRANSFORM_180:
return x0 > TOUCH_MAX_X - EDGE and -dx >= MIN_SWIPE and abs(dy) <= MAX_DRIFT
elif t == TRANSFORM_270:
return y0 < EDGE and dy >= MIN_SWIPE and abs(dx) <= MAX_DRIFT
return False
def _handle_gesture(ui):
global _last_fire
now = time.monotonic()
if now - _last_fire < DEBOUNCE:
return
_last_fire = now
with _lock:
app_id = _focused[0]
if app_id and app_id.startswith('android.'):
_send_android_back()
else:
_send_linux_back(ui)
def main():
threading.Thread(target=_focus_tracker, daemon=True).start()
dev = evdev.InputDevice(TOUCH_DEV)
ui = _make_uinput()
slots = {}
cur_slot = dev.absinfo(e.ABS_MT_SLOT).value
for ev in dev.read_loop():
if ev.type != e.EV_ABS:
continue
if ev.code == e.ABS_MT_SLOT:
cur_slot = ev.value
elif ev.code == e.ABS_MT_TRACKING_ID:
if ev.value == -1:
s = slots.pop(cur_slot, None)
if s and s['x0'] is not None and s['x'] is not None:
dt = time.monotonic() - s['t0']
if _is_gesture(s, dt):
_handle_gesture(ui)
else:
slots[cur_slot] = {'x0': None, 'y0': None, 'x': None, 'y': None,
't0': time.monotonic()}
elif ev.code == e.ABS_MT_POSITION_X and cur_slot in slots:
s = slots[cur_slot]
if s['x0'] is None:
s['x0'] = ev.value
s['x'] = ev.value
elif ev.code == e.ABS_MT_POSITION_Y and cur_slot in slots:
s = slots[cur_slot]
if s['y0'] is None:
s['y0'] = ev.value
s['y'] = ev.value
if __name__ == '__main__':
main()
Description:
I really missed swipe back on the flx1s and seemed like usability gap — especially when switching between Linux and Android apps
where the expected gesture differs.
This is a proof-of-concept daemon that implements a left-edge swipe-to-back gesture, working correctly for both Linux and Android apps and across all four display orientations.
Behaviour:
Implementation:
The daemon reads raw multitouch events from /dev/input/event3 using python-evdev and runs a Wayland client in a background thread using python-pywayland.
Supporting files:
/usr/local/bin/android-back
#!/bin/bash /usr/bin/andromeda shell input keyevent 4/usr/local/bin/android-back — allows the user daemon to call android-back without a password
/etc/udev/rules.d/60-uinput-input-group.rules — allows the input group to open /dev/uinput:
~/.config/systemd/user/swipe-back.service:
~/.local/bin/swipe-back
Dependencies:
python3-evdev, python3-pywayland