Desktop player on mpv: floating window, system integration

This commit is contained in:
2026-10-27 12:09:01 +03:00
parent 01c1b304ef
commit 371c63c0c3
35 changed files with 5663 additions and 125 deletions

View File

@@ -1,11 +1,11 @@
#!/bin/bash
# SleepyOwl for the desktop without Gradle around the process: ./tools/linux/desktop.sh
# DESKTOP_NOBUILD=1 — do not build first. SLEEPYOWL_SCALE=1.25 — interface scale (until the settings have it, L9).
# DESKTOP_NOBUILD=1 — do not build first. SLEEPYOWL_SCALE=1.25 — interface scale over the setting «Масштаб интерфейса».
# Files go to the XDG places (~/.config/sleepyowl, ~/.cache/sleepyowl, ~/.local/state/sleepyowl/log); the test stand
# (tools/linux/stand.sh) points them at its own directory.
set -e
R=$(cd "$(dirname "$0")/../.." && pwd)
export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1}
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath :desktop-proto:jar :desktop-proto:protoClasspath)
exec "$JAVA_HOME/bin/java" -Dsleepyowl.repo="$R" --add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED --add-opens=java.desktop/sun.awt=ALL-UNNAMED \
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath)
exec "$JAVA_HOME/bin/java" --add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED --add-opens=java.desktop/sun.awt=ALL-UNNAMED \
-cp "$(cat "$R/desktop/build/desktop.classpath")" ru.stenfredd.sleepyowl.desktop.MainKt "$@"

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""A stand-in org.freedesktop.ScreenSaver for the test stand's D-Bus (tools/linux/stand.sh): prints every Inhibit and
UnInhibit, so the app's screensaver inhibition (L9) can be checked without the user's desktop.
stand.sh bus python3 tools/linux/fake_screensaver.py > log &
"""
from gi.repository import Gio, GLib
XML = """<node><interface name="org.freedesktop.ScreenSaver">
<method name="Inhibit"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="u" direction="out"/></method>
<method name="UnInhibit"><arg type="u" direction="in"/></method>
</interface></node>"""
cookies = iter(range(1, 1 << 31))
def call(conn, sender, path, iface, method, args, invocation):
if method == "Inhibit":
cookie = next(cookies)
print(f"Inhibit {args.unpack()} from {sender} -> {cookie}", flush=True)
invocation.return_value(GLib.Variant("(u)", (cookie,)))
else:
print(f"UnInhibit {args.unpack()[0]} from {sender}", flush=True)
invocation.return_value(None)
def acquired(conn, name):
conn.register_object("/org/freedesktop/ScreenSaver", Gio.DBusNodeInfo.new_for_xml(XML).interfaces[0], call, None, None)
# The real service drops an inhibition when its owner leaves the bus; show that moment.
conn.signal_subscribe("org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged", None, None, 0,
lambda c, s, p, i, sig, a: a.unpack()[2] == "" and a.unpack()[0].startswith(":") and print(f"gone {a.unpack()[0]}", flush=True))
print("ready", flush=True)
Gio.bus_own_name(Gio.BusType.SESSION, "org.freedesktop.ScreenSaver", 0, acquired, None, None)
GLib.MainLoop().run()

384
tools/linux/k2.py Executable file
View File

@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""Checkpoint К2 on the test stand (LINUX_PLAN.md §3): the player window and the system integration, end to end.
tools/linux/k2.py runs it; PASS/FAIL per check, screenshots in $STAND/shots/k2-*.png
Everything happens on the stand's display :87 and its own D-Bus (stand.sh): the user's screen, focus and desktop are
not touched; the video is muted, the app runs niced. The player's state is read back from MPRIS (status, position,
volume, title), the windows from X11 (_NET_WM_STATE, geometry, frame), the screensaver from fake_screensaver.py.
The player settings start from scratch (first launch) and are put back afterwards, as is the test account's watch data.
"""
import os, re, shutil, subprocess, sys, time
from Xlib import X, XK, display
from Xlib.ext import xtest
from PIL import Image
R = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
STAND = os.environ.get("STAND") or os.path.expanduser("~/.cache/sleepyowl-test")
STAND_SH = os.path.join(R, "tools/linux/stand.sh")
PREFS = os.path.join(STAND, "config/sleepyowl")
LOG = os.path.join(STAND, "state/sleepyowl/log/sleepyowl.log")
E2, E3, E4 = "00000000000000000000000000000000", "00000000000000000000000000000000", "00000000000000000000000000000000"
results = []
def sh(*args, env=None, check=True):
return subprocess.run(args, env=env, check=check, capture_output=True, text=True).stdout
def check(name, ok, detail=""):
results.append((name, bool(ok)))
print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" — {detail}" if detail else ""), flush=True)
def near(a, b, tol):
return abs(a - b) <= tol
def same_place(p, place):
# ±2 px: the window manager rounds the size to the video's proportions (874×491.6 → 491 or 492).
return all(near(v, w, 2) for v, w in zip((p["x"], p["y"], p["w"], p["h"]), place))
# --- Stand ---
sh(STAND_SH, "up")
d = display.Display(":87")
root = d.screen().root
BUS = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=open(os.path.join(STAND, "run/dbus.addr")).read().strip())
def atom(name):
return d.intern_atom(name)
def prop(w, name, kind=X.AnyPropertyType):
p = w.get_full_property(atom(name), kind)
return p.value if p else None
def windows():
"""The app's windows in mapping order (the library first): name, states, client geometry on the screen, frame."""
out = []
for wid in prop(root, "_NET_CLIENT_LIST") or []:
w = d.create_resource_object("window", wid)
try:
name = prop(w, "_NET_WM_NAME", atom("UTF8_STRING"))
name = name.decode() if isinstance(name, bytes) else (w.get_wm_name() or "")
states = [d.get_atom_name(a).replace("_NET_WM_STATE_", "") for a in (prop(w, "_NET_WM_STATE") or [])]
g = w.get_geometry()
t = w.translate_coords(root, 0, 0)
motif = prop(w, "_MOTIF_WM_HINTS")
out.append(dict(id=wid, name=name, states=states, x=-t.x, y=-t.y, w=g.width, h=g.height,
framed=bool(motif is None or (motif[0] & 2 == 0) or motif[2] != 0)))
except Exception:
pass
return out
def player():
ws = windows()
return ws[1] if len(ws) > 1 else None
def library():
ws = windows()
return ws[0] if ws else None
def press(code, down):
xtest.fake_input(d, X.KeyPress if down else X.KeyRelease, code)
d.sync()
def keycode(name):
return d.keysym_to_keycode(XK.string_to_keysym(name))
def key(chord, raw=None):
*mods, name = chord.split("+")
mc = [keycode({"ctrl": "Control_L", "shift": "Shift_L", "alt": "Alt_L"}[m]) for m in mods]
for c in mc: press(c, True)
c = raw or keycode(name)
press(c, True); time.sleep(0.03); press(c, False)
for c in reversed(mc): press(c, False)
time.sleep(0.15)
def move(x, y):
xtest.fake_input(d, X.MotionNotify, x=int(x), y=int(y)); d.sync(); time.sleep(0.05)
def click(x, y, button=1, times=1):
move(x, y)
for _ in range(times):
xtest.fake_input(d, X.ButtonPress, button); d.sync(); time.sleep(0.03)
xtest.fake_input(d, X.ButtonRelease, button); d.sync(); time.sleep(0.06)
def drag(x1, y1, x2, y2, steps=25):
move(x1, y1)
xtest.fake_input(d, X.ButtonPress, 1); d.sync(); time.sleep(0.1)
for i in range(1, steps + 1):
move(x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
time.sleep(0.15)
xtest.fake_input(d, X.ButtonRelease, 1); d.sync(); time.sleep(0.2)
def shot(name):
path = os.path.join(STAND, "shots", f"k2-{name}.png")
sh("import", "-display", ":87", "-window", "root", path)
return path
def top_line(name, p):
"""The window's top line (the compact layout's title and ×) as grey pixels: compared on a paused frame."""
return list(Image.open(shot(name)).convert("L").crop((p["x"], p["y"], p["x"] + p["w"], p["y"] + 36)).getdata())
def diff(a, b):
return sum(abs(x - y) for x, y in zip(a, b)) / max(1, len(a))
# --- MPRIS and the screensaver ---
MPRIS = ["gdbus", "call", "--session", "--dest", "org.mpris.MediaPlayer2.sleepyowl", "--object-path", "/org/mpris/MediaPlayer2"]
def mpris_raw(prop_name):
return subprocess.run(MPRIS + ["--method", "org.freedesktop.DBus.Properties.Get", "org.mpris.MediaPlayer2.Player", prop_name],
env=BUS, capture_output=True, text=True).stdout
def mpris(prop_name):
out = mpris_raw(prop_name)
m = re.search(r"<(?:int64 )?'?([^'>]*)'?>", out)
if prop_name == "Metadata":
t = re.search(r"xesam:title': <'([^']*)'", out)
return t.group(1) if t else None
if not m:
return None
v = m.group(1)
return float(v) if re.fullmatch(r"-?[\d.]+", v) else v
def mpris_call(method, *args):
subprocess.run(MPRIS + ["--method", f"org.mpris.MediaPlayer2.Player.{method}", *args], env=BUS, capture_output=True)
def mpris_present():
return "org.mpris.MediaPlayer2.sleepyowl" in sh("busctl", "--user", "list", "--no-pager", env=BUS, check=False)
def wait(cond, timeout, step=0.3):
end = time.time() + timeout
while time.time() < end:
v = cond()
if v:
return v
time.sleep(step)
return cond()
def pos():
return (mpris("Position") or 0) / 1e6
ss_log = os.path.join(STAND, "k2-screensaver.txt")
def ss_lines():
return open(ss_log).read().splitlines()
def new_log():
"""This launch's log: the app starts a new file each time (the previous one becomes sleepyowl.1.log)."""
return open(LOG, encoding="utf-8", errors="replace").read()
def run_logs():
"""Both launches of this run."""
return "".join(open(f, encoding="utf-8", errors="replace").read() for f in (LOG.replace(".log", ".1.log"), LOG) if os.path.exists(f))
def start_app(item, start):
env = dict(os.environ, DESKTOP_NOBUILD="1", SLEEPYOWL_MPV_OPTIONS="mute=yes")
subprocess.run(["nice", "-n", "15", STAND_SH, "app", "--play", item, "--start", str(start)], env=env, check=True, capture_output=True)
ok = wait(lambda: mpris_present() and player() and (mpris("Metadata") or ""), 60, 0.5)
time.sleep(2)
return ok
# --- The run ---
backup = os.path.join(STAND, "k2-prefs-backup")
os.makedirs(backup, exist_ok=True)
sh(STAND_SH, "stop")
for f in ("desktop_player.preferences_pb", "desktop.preferences_pb"):
if os.path.exists(os.path.join(PREFS, f)):
shutil.move(os.path.join(PREFS, f), os.path.join(backup, f))
subprocess.run(["python3", os.path.join(R, "tools/linux/watchdata.py"), "snapshot"], check=True, capture_output=True)
fake = subprocess.Popen(["python3", "-u", os.path.join(R, "tools/linux/fake_screensaver.py")], env=BUS,
stdout=open(ss_log, "w"), stderr=subprocess.STDOUT)
sh("setxkbmap", "-display", ":87", "-layout", "us")
time.sleep(1)
try:
print("== first open (§1.1, §1.2)", flush=True)
check("player opens, MPRIS up", start_app(E2, 60))
p = player()
check("mini 640×360 in the bottom right corner, 24 px in", p and near(p["x"], 1256, 2) and near(p["y"], 696, 2) and p["w"] == 640 and p["h"] == 360, p)
check("mini: on top, no frame, focused once", p and "ABOVE" in p["states"] and not p["framed"] and "FOCUSED" in p["states"], p and p["states"])
check("MPRIS: playing, title", mpris("PlaybackStatus") == "Playing" and "Серия 2" in (mpris("Metadata") or ""), mpris("Metadata"))
check("screensaver inhibited", wait(lambda: any(l.startswith("Inhibit") for l in ss_lines()), 5))
print("== keys (§1.3)", flush=True)
key("space"); time.sleep(1)
check("Space pauses", mpris("PlaybackStatus") == "Paused")
check("pause lifts the screensaver inhibition", wait(lambda: any(l.startswith("UnInhibit") for l in ss_lines()), 4))
key("space"); time.sleep(1)
check("Space plays again", mpris("PlaybackStatus") == "Playing")
p0 = pos()
key("Right"); key("Right"); key("Right"); time.sleep(2.5)
p1 = pos()
check("→ ×3: +30 s in one seek", 26 <= p1 - p0 <= 36, f"{p0:.1f} → {p1:.1f}")
key("shift+Left"); time.sleep(2.5)
p2 = pos()
check("Shift+←: −60 s", -64 <= p2 - p1 <= -54, f"{p1:.1f} → {p2:.1f}")
v0 = mpris("Volume")
key("Down"); time.sleep(0.5)
check("↓: volume −5 %", v0 is not None and near(mpris("Volume"), v0 - 0.05, 0.001), f"{v0} → {mpris('Volume')}")
key("Up"); time.sleep(0.5)
key("m"); time.sleep(0.5)
check("M: mute", mpris("Volume") == 0.0)
key("m"); time.sleep(0.5)
check("M again: sound back", near(mpris("Volume") or 0, v0, 0.001))
m_code = keycode("m")
sh("setxkbmap", "-display", ":87", "-layout", "ru")
time.sleep(0.5)
key("m", raw=m_code); time.sleep(0.5)
muted_ru = mpris("Volume") == 0.0
key("m", raw=m_code); time.sleep(0.5)
sh("setxkbmap", "-display", ":87", "-layout", "us")
check("Russian layout: physical M (Ь) mutes and unmutes", muted_ru and near(mpris("Volume") or 0, v0, 0.001))
before = len(re.findall(r"смена аудио|aid", new_log()))
key("a"); time.sleep(1.5); key("a"); time.sleep(1.5)
check("A ×2: audio track switched and back", len(re.findall(r"смена аудио|aid", new_log())) - before >= 2)
print("== autohide (§1.3), on a paused frame", flush=True)
p = player()
key("space"); time.sleep(1.5)
move(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2); time.sleep(0.2); move(p["x"] + p["w"] // 2 + 5, p["y"] + p["h"] // 2 + 5)
time.sleep(0.4)
shown = top_line("osd-shown", p)
time.sleep(3.0)
rested = top_line("osd-rested", p)
check("pointer resting 2 s in mini: controls hidden", diff(shown, rested) > 4, f"diff {diff(shown, rested):.1f}")
move(p["x"] + 100, p["y"] + 100); time.sleep(0.4)
again = top_line("osd-again", p)
check("pointer moves: shown again", diff(shown, again) < 1.5, f"diff {diff(shown, again):.1f}")
move(150, 60); time.sleep(0.5)
left = top_line("osd-left", p)
check("pointer leaves: hidden at once", diff(rested, left) < 1.5 and diff(shown, left) > 4, f"diff {diff(rested, left):.1f} / {diff(shown, left):.1f}")
key("space"); time.sleep(1)
check("playing again", mpris("PlaybackStatus") == "Playing")
print("== drag and resize the mini window (§1.1)", flush=True)
p = player()
cx, cy = p["x"] + p["w"] // 2, p["y"] + p["h"] // 2
drag(cx, cy, cx - 600, cy - 300); time.sleep(1)
q = player()
check("drag by the video moves it", near(q["x"], p["x"] - 600, 12) and near(q["y"], p["y"] - 300, 12), f"{p['x']},{p['y']} → {q['x']},{q['y']}")
check("a drag is not a click: still playing", mpris("PlaybackStatus") == "Playing")
drag(q["x"] + q["w"] - 3, q["y"] + q["h"] // 2, q["x"] + q["w"] + 237, q["y"] + q["h"] // 2); time.sleep(1.2)
r = player()
check("right edge resizes, 16:9 kept", near(r["w"], q["w"] + 240, 20) and near(r["h"], r["w"] * 9 / 16, 3), f"{q['w']}×{q['h']} → {r['w']}×{r['h']}")
mini_place = (r["x"], r["y"], r["w"], r["h"])
print("== modes (§1.1)", flush=True)
key("t"); time.sleep(2.5)
p = player()
check("T: normal window 1280×720 in the middle, framed, not on top", p["w"] == 1280 and p["h"] == 720 and near(p["x"], 320, 2) and near(p["y"], 180, 40)
and p["framed"] and "ABOVE" not in p["states"], p)
key("f"); time.sleep(2.5)
p = player()
check("F: full screen", "FULLSCREEN" in p["states"] and p["w"] == 1920 and p["h"] == 1080, p["states"])
key("Escape"); time.sleep(2.5)
p = player()
check("Esc: back to the normal window", "FULLSCREEN" not in p["states"] and p["w"] == 1280 and p["framed"], p)
shot("window")
key("t"); time.sleep(2.5)
p = player()
check("T: mini again at its own place and size", "ABOVE" in p["states"] and same_place(p, mini_place), f"{mini_place} → {(p['x'], p['y'], p['w'], p['h'])}")
click(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2, times=2); time.sleep(2.5)
p = player()
check("double click in mini: normal window, still playing", p["framed"] and "ABOVE" not in p["states"] and mpris("PlaybackStatus") == "Playing", p["states"])
click(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2, times=2); time.sleep(2.5)
p = player()
check("double click in the window: full screen", "FULLSCREEN" in p["states"])
key("Escape"); time.sleep(2.5)
key("t"); time.sleep(2.5)
p = player()
check("Esc, T: mini", "ABOVE" in p["states"] and not p["framed"])
check("focus stayed with the player through the modes", "FOCUSED" in p["states"])
print("== menu (§1.3)", flush=True)
click(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2, button=3); time.sleep(0.8)
shot("menu")
key("Escape"); time.sleep(0.5)
print("== next episode, focus elsewhere (§1.2)", flush=True)
lib = library()
click(lib["x"] + 200, lib["y"] - 15); time.sleep(1) # the library's title bar
check("library has the focus", "FOCUSED" in library()["states"] and "FOCUSED" not in player()["states"])
pid = player()["id"]
mpris_call("Next")
check("MPRIS Next: S1E3", wait(lambda: "Серия 3" in (mpris("Metadata") or ""), 15), mpris("Metadata"))
time.sleep(2)
p = player()
check("same window, on top, focus not taken", p["id"] == pid and "ABOVE" in p["states"] and "FOCUSED" not in p["states"] and "FOCUSED" in library()["states"], p["states"])
m = re.search(r"mpris:length': <int64 (\d+)>", mpris_raw("Metadata"))
mpris_call("SetPosition", f"/ru/stenfredd/sleepyowl/track/{E3}", str(int(m.group(1)) - 8_000_000) if m else "0")
check("end of S1E3 plays S1E4 by itself", wait(lambda: "Серия 4" in (mpris("Metadata") or ""), 40), mpris("Metadata"))
time.sleep(2)
p = player()
check("…in the same window, focus still elsewhere", p["id"] == pid and "FOCUSED" not in p["states"], p["states"])
mpris_call("Previous")
check("MPRIS Previous: S1E3", wait(lambda: "Серия 3" in (mpris("Metadata") or ""), 15))
time.sleep(3)
p0 = pos(); mpris_call("Seek", "60000000"); time.sleep(2.5)
check("MPRIS Seek +60 s", 55 <= pos() - p0 <= 66, f"{p0:.1f} → {pos():.1f}")
mpris_call("Pause"); time.sleep(1)
check("MPRIS Pause", mpris("PlaybackStatus") == "Paused")
mpris_call("PlayPause"); time.sleep(1)
check("MPRIS PlayPause", mpris("PlaybackStatus") == "Playing")
print("== close and reopen (§1.2 п. 7, §1.1)", flush=True)
p = player()
move(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2); time.sleep(0.2); move(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(0.5)
shot("before-close")
click(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(2)
check("× closes the player, library stays", player() is None and library() is not None)
calls = [l for l in ss_lines() if l.startswith(("Inhibit", "UnInhibit"))]
check("MPRIS gone, screensaver allowed", not mpris_present() and calls and calls[-1].startswith("UnInhibit"), calls[-2:])
sh(STAND_SH, "stop")
check("reopens", start_app(E2, 90))
p = player()
check("…in mini at the last place and size", "ABOVE" in p["states"] and same_place(p, mini_place), f"{mini_place} → {(p['x'], p['y'], p['w'], p['h'])}")
move(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2); time.sleep(0.2); move(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(0.5)
click(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(2)
check("closed again", player() is None)
errors = re.findall(r"^\S+ E .*|.*Exception.*", run_logs(), re.M)
check("no errors in the log", not errors, errors[:3])
finally:
sh(STAND_SH, "stop", check=False)
sh("setxkbmap", "-display", ":87", "-layout", "us", check=False)
fake.terminate()
for f in os.listdir(backup):
shutil.move(os.path.join(backup, f), os.path.join(PREFS, f))
time.sleep(5)
print(subprocess.run(["python3", os.path.join(R, "tools/linux/watchdata.py"), "restore"], capture_output=True, text=True).stdout.strip(), flush=True)
failed = [n for n, ok in results if not ok]
print(f"\n{len(results) - len(failed)}/{len(results)} passed" + (": failed " + "; ".join(failed) if failed else ""))
sys.exit(1 if failed else 0)

View File

@@ -1,14 +1,16 @@
#!/bin/bash
# Test stand of the desktop app (LINUX_PLAN.md §3 «Как проверяем»): a private virtual display :87 with the real
# window manager (Muffin, in its own D-Bus session), so window, mouse and keyboard tests do not touch the user's screen.
# window manager (Muffin), so window, mouse and keyboard tests do not touch the user's screen. Muffin and the app share
# the stand's own D-Bus session bus: the app's MPRIS player and screensaver calls never reach the user's desktop.
#
# stand.sh up start Xvfb :87 (1920×1080) and Muffin, if not running
# stand.sh up start Xvfb :87 (1920×1080), the stand's D-Bus and Muffin, if not running
# stand.sh down stop the app, Muffin and Xvfb
# stand.sh app [args] (re)start the app on :87 in the background; output in $STAND/app.out
# stand.sh stop stop the app
# stand.sh shot NAME screenshot of the display → $STAND/shots/NAME.png
# stand.sh in CMD… input on :87 (tools/linux/xinput.py): click X Y, move X Y, key NAME, type TEXT, …
# stand.sh log the app's log (sleepyowl.log)
# stand.sh bus CMD… run CMD on the stand's D-Bus (busctl --user, gdbus, dbus-monitor…)
#
# The app's files live in $STAND (default ~/.cache/sleepyowl-test): its own config with the test account,
# cache, log and lock — the user's own ~/.config/sleepyowl is never used. Gradle builds before `app` unless
@@ -28,8 +30,11 @@ up() {
echo $! >"$STAND/run/xvfb.pid"
for _ in $(seq 50); do xdpyinfo -display $D >/dev/null 2>&1 && break; sleep 0.1; done
fi
if ! running dbus; then
dbus-daemon --session --fork --print-address=3 --print-pid=4 3>"$STAND/run/dbus.addr" 4>"$STAND/run/dbus.pid"
fi
if ! running muffin; then
DISPLAY=$D setsid dbus-run-session muffin --replace >"$STAND/run/muffin.out" 2>&1 &
DISPLAY=$D DBUS_SESSION_BUS_ADDRESS=$(cat "$STAND/run/dbus.addr") setsid muffin --replace >"$STAND/run/muffin.out" 2>&1 &
echo $! >"$STAND/run/muffin.pid"
sleep 2
fi
@@ -49,14 +54,14 @@ case "$1" in
up) up ;;
down)
stop_app
for p in muffin xvfb; do running $p && pkill -P "$(cat "$STAND/run/$p.pid")" 2>/dev/null; running $p && kill "$(cat "$STAND/run/$p.pid")"; rm -f "$STAND/run/$p.pid"; done
for p in muffin dbus xvfb; do running $p && pkill -P "$(cat "$STAND/run/$p.pid")" 2>/dev/null; running $p && kill "$(cat "$STAND/run/$p.pid")"; rm -f "$STAND/run/$p.pid"; done
echo "stand down" ;;
app)
shift
up >/dev/null
stop_app
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath :desktop-proto:jar :desktop-proto:protoClasspath)
DISPLAY=$D XDG_CONFIG_HOME="$STAND/config" XDG_CACHE_HOME="$STAND/cache" XDG_STATE_HOME="$STAND/state" \
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath)
DISPLAY=$D DBUS_SESSION_BUS_ADDRESS=$(cat "$STAND/run/dbus.addr") XDG_CONFIG_HOME="$STAND/config" XDG_CACHE_HOME="$STAND/cache" XDG_STATE_HOME="$STAND/state" \
SLEEPYOWL_RUNTIME_DIR="$STAND/run" DESKTOP_NOBUILD=1 "$R/tools/linux/desktop.sh" "$@" >"$STAND/app.out" 2>&1 &
echo $! >"$STAND/run/app.pid"
echo "app started, pid $(cat "$STAND/run/app.pid")" ;;
@@ -66,5 +71,6 @@ case "$1" in
echo "$STAND/shots/$2.png" ;;
in) shift; DISPLAY=$D python3 "$R/tools/linux/xinput.py" "$@" ;;
log) cat "$STAND/state/sleepyowl/log/sleepyowl.log" ;;
*) sed -n '2,17p' "$0"; exit 1 ;;
bus) shift; DBUS_SESSION_BUS_ADDRESS=$(cat "$STAND/run/dbus.addr") "$@" ;;
*) sed -n '2,19p' "$0"; exit 1 ;;
esac

View File

@@ -4,6 +4,7 @@
xinput.py click X Y [BUTTON] move there and click (1 left, 3 right, 8 back)
xinput.py dclick X Y double click
xinput.py move X Y
xinput.py drag X1 Y1 X2 Y2 press the left button at X1 Y1, move in steps to X2 Y2, release
xinput.py key NAME [NAME…] keys by keysym name: Escape Return Tab BackSpace Left …; "alt+Left" for a chord
xinput.py type TEXT types Latin text, digits and punctuation
xinput.py wait SECONDS
@@ -52,6 +53,16 @@ def click(x, y, button=1, times=1):
xtest.fake_input(d, X.ButtonRelease, int(button)); d.sync(); time.sleep(0.06)
def drag(x1, y1, x2, y2, steps=20):
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
move(x1, y1)
xtest.fake_input(d, X.ButtonPress, 1); d.sync(); time.sleep(0.1)
for i in range(1, steps + 1):
xtest.fake_input(d, X.MotionNotify, x=x1 + (x2 - x1) * i // steps, y=y1 + (y2 - y1) * i // steps); d.sync(); time.sleep(0.03)
time.sleep(0.1)
xtest.fake_input(d, X.ButtonRelease, 1); d.sync(); time.sleep(0.1)
SHIFTED = {c: True for c in '~!@#$%^&*()_+{}|:"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ'}
NAMES = {" ": "space", ".": "period", ":": "colon", "/": "slash", "-": "minus", "_": "underscore", "@": "at"}
@@ -72,6 +83,7 @@ def run(cmd):
if op == "click": click(args[0], args[1], args[2] if len(args) > 2 else 1)
elif op == "dclick": click(args[0], args[1], times=2)
elif op == "move": move(*args)
elif op == "drag": drag(*args)
elif op == "key": [key(a) for a in args]
elif op == "type": type_text(" ".join(args))
elif op == "wait": time.sleep(float(args[0]))