#!/usr/bin/env python3 """Drive the desktop build from outside, for the manual's screenshots. drive.py launch [args...] start the app on the private X server drive.py stop drive.py shot OUT.png drive.py click X Y [button] drive.py move X Y drive.py drag X1 Y1 X2 Y2 [steps] drive.py type TEXT drive.py key KEYSYM... drive.py rec OUT.mp4 / cut start and stop a recording of the window drive.py where the pointer, in window coordinates Everything is in *window* pixels, at scale 1, with the window at the origin of a 1920×1200 Xvfb on `DR_DISPLAY` (`:7`). The app gets its own XDG profile under `DR_HOME` (`/var/tmp/dr-manual`), so nothing here touches the library or settings of whoever is logged in; `DR_BIN` names the binary. Two things that cost an afternoon, kept here so they cost nobody else one: a Slint `TouchArea` wants a *held* press (`mousedown`, a beat, `mouseup`) — xdotool's `click` is sometimes dropped; and under XWayland at scale 2 a pointer warp lands at twice the coordinate asked for, which is why this runs on Xvfb rather than the desktop. """ import os import subprocess import sys import time HOME = os.environ.get('DR_HOME', '/var/tmp/dr-manual') DISPLAY = os.environ.get('DR_DISPLAY', ':7') BIN = os.environ.get('DR_BIN', 'target/release/darkroom-desktop') ENV = dict( os.environ, XDG_CONFIG_HOME=f'{HOME}/xdg/config', XDG_DATA_HOME=f'{HOME}/xdg/data', XDG_STATE_HOME=f'{HOME}/xdg/state', RUST_LOG='info', WINIT_X11_SCALE_FACTOR='1', DISPLAY=DISPLAY, ) ENV.pop('WAYLAND_DISPLAY', None) os.environ['DISPLAY'] = DISPLAY def x(*args, check=True): return subprocess.run( ['xdotool', *map(str, args)], capture_output=True, text=True, check=check ).stdout.strip() def win(): return open(f'{HOME}/app.win').read().strip() def geometry(): out = x('getwindowgeometry', win()) pos = out.split('Position: ')[1].split(' ')[0] px, py = map(int, pos.split(',')) return px, py def launch(args): os.makedirs(HOME, exist_ok=True) log = open(f'{HOME}/app.log', 'w') p = subprocess.Popen([BIN, *args], env=ENV, stdout=log, stderr=subprocess.STDOUT) open(f'{HOME}/app.pid', 'w').write(str(p.pid)) w = '' for _ in range(120): w = x('search', '--pid', p.pid, '--name', 'DarkRoom', check=False).split('\n')[-1] if w: break time.sleep(0.5) time.sleep(1.5) W, H = os.environ.get('WIDTH', '1600'), os.environ.get('HEIGHT', '1100') x('windowsize', '--sync', w, W, H) time.sleep(0.5) x('windowmove', '--sync', w, 0, 0) x('windowfocus', '--sync', w, check=False) open(f'{HOME}/app.win', 'w').write(w) print(f'pid {p.pid} win {w}') def stop(): try: os.kill(int(open(f'{HOME}/app.pid').read()), 15) except (OSError, ValueError) as e: print(e) time.sleep(1) def shot(out): subprocess.run(['import', '-window', win(), out], check=True) def move(px, py): ox, oy = geometry() x('mousemove', '--sync', ox + int(px), oy + int(py)) def click(px, py, button=1): x('windowfocus', '--sync', win(), check=False) move(px, py) time.sleep(0.15) x('mousedown', button) time.sleep(0.12) x('mouseup', button) def drag(x1, y1, x2, y2, steps=20): x('windowfocus', '--sync', win(), check=False) move(x1, y1) time.sleep(0.15) x('mousedown', 1) time.sleep(0.15) for i in range(1, int(steps) + 1): t = i / int(steps) move(int(x1) + (int(x2) - int(x1)) * t, int(y1) + (int(y2) - int(y1)) * t) time.sleep(0.03) time.sleep(0.15) x('mouseup', 1) def drag_path(points, steps=20): """A drag through several points: out of the grid sideways first, then to the row. A diagonal with much vertical in it is taken by the grid's Flickable as a scroll before the DragArea can claim it.""" x('windowfocus', '--sync', win(), check=False) (x1, y1), rest = points[0], points[1:] move(x1, y1) time.sleep(0.15) x('mousedown', 1) time.sleep(0.15) for x2, y2 in rest: for i in range(1, int(steps) + 1): t = i / int(steps) move(int(x1 + (x2 - x1) * t), int(y1 + (y2 - y1) * t)) time.sleep(0.03) x1, y1 = x2, y2 time.sleep(0.4) x('mouseup', 1) def rec_start(out): size = x('getwindowgeometry', win()).split('Geometry: ')[1].strip() ox, oy = geometry() p = subprocess.Popen([ 'ffmpeg', '-hide_banner', '-loglevel', 'error', '-f', 'x11grab', '-framerate', '15', '-video_size', size, '-i', f'{DISPLAY}+{ox},{oy}', '-c:v', 'libx264', '-preset', 'ultrafast', '-qp', '0', '-y', out, ]) open(f'{HOME}/rec.pid', 'w').write(str(p.pid)) time.sleep(0.5) def rec_stop(): pid = int(open(f'{HOME}/rec.pid').read()) os.kill(pid, 2) for _ in range(50): try: os.kill(pid, 0) time.sleep(0.1) except OSError: break def where(): out = x('getmouselocation') mx, my = [int(v.split(':')[1]) for v in out.split()[:2]] ox, oy = geometry() print(mx - ox, my - oy) if __name__ == '__main__': cmd, *a = sys.argv[1:] if cmd == 'launch': launch(a) elif cmd == 'stop': stop() elif cmd == 'shot': shot(a[0]) elif cmd == 'click': click(*a) elif cmd == 'move': move(*a) elif cmd == 'drag': drag(*a) elif cmd == 'type': x('windowfocus', '--sync', win(), check=False) x('type', '--delay', '120', a[0]) elif cmd == 'key': x('windowfocus', '--sync', win(), check=False) x('key', '--delay', '80', *a) elif cmd == 'rec': rec_start(a[0]) elif cmd == 'cut': rec_stop() elif cmd == 'where': where() else: sys.exit(__doc__)