Files
saphid--frame-control/ui/frame_input_agent.py
T
saphidandClaude Opus 5.5 6765fbcb20 Bundled KDE Connect: close the races the second review found
- The need-packages retry only runs if no start() has taken over, so two
  agents can't end up running for one session.
- Each copy goes to its own incoming folder; the agent removes its own once
  connected (and any a cancelled start left over an hour ago), so a stopped
  start can't delete files another is copying or still needs.
- A missing package folder means need-packages, not an error.
- Tests keep fake agents running, so they check ready and which agent owns
  the session, plus a bounded retry and the tidy rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:28:10 +10:00

433 lines
17 KiB
Python

"""Keyboard and pointer for the Steam Frame. Frame Control's server runs this ON the Frame.
It speaks KDE Connect's LAN protocol (version 7, as in KDE Connect 24.02) to the
Frame's own kdeconnectd, as a phone would, and forwards remote-input events read
from stdin: one JSON object (or list of them) per line, each a KDE Connect
"mousepad" request body such as {"dx": 4, "dy": -2} or {"key": "hello"}.
KDE Connect does the typing and clicking.
KDE Connect isn't installed on the Frame. Frame Control ships Valve's build of it
for the Frame and the few libraries the Frame lacks (frame/kdeconnect); the server
copies them over the SSH connection and this unpacks them into
~/.local/share/frame-control/kdeconnect: no root, no internet, and SteamOS
updates leave it alone.
argv: client id, client name, the folder holding the packages, and a JSON list
of [file, sha256] naming them (see frame/kdeconnect/packages.json).
Status goes to stdout, one JSON object per line:
{"state": "installing" | "starting" | "pairing" | "ready" | "error" | "need-packages", ...}.
Standard library only: this runs on the Frame's own Python.
"""
import fcntl
import hashlib
import json
import os
import selectors
import shutil
import signal
import socket
import ssl
import subprocess
import sys
import time
from pathlib import Path
BASE = Path.home() / ".local/share/frame-control/kdeconnect"
ROOT = BASE / "root"
BRIDGE = BASE / "bridge"
STAMP = ".frame-control-packages" # in ROOT: which packages it was unpacked from
PORT = int(os.environ.get("FRAME_INPUT_PORT", "1716"))
UID = os.getuid()
MOUSEPAD = "kdeconnect.mousepad.request"
def say(state, **more):
print(json.dumps({"state": state, **more}), flush=True)
def packet(kind, body):
return (json.dumps({"id": int(time.time() * 1000), "type": kind, "body": body}) + "\n").encode()
# ---- KDE Connect on the Frame ------------------------------------------------
SYSTEM_DAEMON = Path("/usr/lib/kdeconnectd")
def stamp(packages):
"""What ROOT/STAMP holds once these packages are unpacked (the server checks it too)."""
return "".join(f"{sha} {name}\n" for name, sha in packages)
def installed(packages):
try:
return (ROOT / STAMP).read_text() == stamp(packages)
except OSError:
return False
def sha256(path):
digest = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
digest.update(block)
return digest.hexdigest()
def install(folder, packages):
"""Unpack the packages the server copied to `folder` into ROOT, checking each one first."""
if not packages:
raise RuntimeError("This copy of Frame Control doesn't include KDE Connect")
say("installing", message="Unpacking KDE Connect on the Frame")
stage = BASE / "root.new"
shutil.rmtree(stage, ignore_errors=True)
stage.mkdir(parents=True)
for name, sha in packages:
path = Path(folder) / name
if not path.is_file():
raise RuntimeError(f"{name} didn't reach the Frame")
if sha256(path) != sha:
raise RuntimeError(f"{name} arrived damaged (its SHA-256 doesn't match)")
if subprocess.run(["tar", "--zstd", "-xf", str(path), "-C", str(stage)], capture_output=True).returncode:
subprocess.run(["bsdtar", "-xf", str(path), "-C", str(stage)], check=True, capture_output=True)
(stage / STAMP).write_text(stamp(packages))
stop_daemon() # an older copy may still be running from ROOT
shutil.rmtree(ROOT, ignore_errors=True)
stage.rename(ROOT)
def app_display():
"""The X display that apps (not Steam's own VR menus) are on.
gamescope runs two Xwayland servers: on 2026-09-28 :0 held Steam's VR bar and
menus and ignored XTest pointer motion, while :1 held apps such as Chromium and
took it. Inferred to hold in general.
"""
return ":1" if Path("/tmp/.X11-unix/X1").exists() else ":0"
def daemon_env(daemon):
env = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{UID}/bus",
XDG_RUNTIME_DIR=f"/run/user/{UID}", DISPLAY=app_display(), QT_QPA_PLATFORM="xcb")
if str(daemon).startswith(str(ROOT)):
env.update(LD_LIBRARY_PATH=str(ROOT / "usr/lib"), QT_PLUGIN_PATH=str(ROOT / "usr/lib/qt6/plugins"),
QML_IMPORT_PATH=str(ROOT / "usr/lib/qt6/qml"),
XDG_DATA_DIRS=f"{ROOT / 'usr/share'}:/usr/share")
return env
def listening():
try:
socket.create_connection(("127.0.0.1", PORT), 1).close()
return True
except OSError:
return False
def our_daemons():
"""Process ids of the kdeconnectd that Frame Control installed (never a system one)."""
pids = []
for proc in Path("/proc").iterdir():
if proc.name.isdigit():
try:
if os.readlink(proc / "exe").startswith(str(ROOT) + "/"):
pids.append(int(proc.name))
except OSError:
pass
return pids
def stop_daemon():
"""Stop our kdeconnectd and wait until it's gone (so its port is closed too)."""
for sig, wait in ((signal.SIGTERM, 30), (signal.SIGKILL, 30)): # tenths of a second
for pid in our_daemons():
try:
os.kill(pid, sig)
except ProcessLookupError:
pass
for _ in range(wait):
if not our_daemons() and not listening():
return
time.sleep(0.1)
class NeedPackages(Exception):
"""This build of KDE Connect isn't unpacked and the server didn't send it (it thought it was there)."""
def ensure_daemon(folder, packages):
"""Start KDE Connect: the Frame's own if it ever has one, else ours, unpacked first if needed.
A copy from another Frame Control version that another device is using right
now is left running and used as it is (they speak the same protocol); it's
replaced the next time nobody is using it.
"""
system = SYSTEM_DAEMON.exists()
if not system and not installed(packages) and not (listening() and our_daemons()):
if not folder or not Path(folder).is_dir():
raise NeedPackages()
install(folder, packages)
if listening():
return
BASE.mkdir(parents=True, exist_ok=True)
daemon = SYSTEM_DAEMON if system else ROOT / "usr/lib/kdeconnectd"
say("starting", message="Starting KDE Connect on the Frame")
log = open(BASE / "kdeconnectd.log", "ab")
# Its own session, so it outlives this connection and serves the next one.
subprocess.Popen([str(daemon)], env=daemon_env(daemon), cwd=str(Path.home()), stdin=subprocess.DEVNULL,
stdout=log, stderr=log, start_new_session=True)
for _ in range(40):
if listening():
return
time.sleep(0.25)
raise RuntimeError(f"KDE Connect didn't start; see {BASE / 'kdeconnectd.log'} on the Frame")
def qdbus(device, method):
"""Call a method on KDE Connect's D-Bus object for our device; its output, or None."""
env = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{UID}/bus")
try:
r = subprocess.run(["qdbus6", "org.kde.kdeconnect", f"/modules/kdeconnect/devices/{device}",
f"org.kde.kdeconnect.device.{method}"], capture_output=True, text=True, env=env, timeout=5)
except (OSError, subprocess.TimeoutExpired):
return None
return r.stdout.strip() if r.returncode == 0 else None
# ---- our identity --------------------------------------------------------------
def identity(client):
"""A device id and certificate for this client, made once and kept (pairing is tied to them).
Each computer or phone gets its own: KDE Connect keeps one connection per device,
so a shared identity would make them knock each other off.
"""
folder = BRIDGE / client
folder.mkdir(parents=True, exist_ok=True)
id_file, cert, key = folder / "id", folder / "cert.pem", folder / "key.pem"
if not (id_file.exists() and cert.exists() and key.exists()):
device = "framecontrol_" + os.urandom(12).hex() # KDE Connect wants 32-38 of [A-Za-z0-9_]
subprocess.run(["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
"-nodes", "-days", "3650", "-subj", f"/O=KDE/OU=Kde connect/CN={device}",
"-keyout", str(key), "-out", str(cert)], check=True, capture_output=True)
os.chmod(key, 0o600)
id_file.write_text(device)
return id_file.read_text().strip(), cert, key
# ---- the link ------------------------------------------------------------------
class Link:
"""One TLS connection to kdeconnectd, as a paired device that sends remote input."""
def __init__(self, device, cert, key, port=PORT, name="Frame Control"):
self.device, self.buf, self.keyboard = device, b"", None
raw = socket.create_connection(("127.0.0.1", port), 5)
raw.sendall(packet("kdeconnect.identity", {
"deviceId": device, "deviceName": name, "deviceType": "phone", "protocolVersion": 7,
"incomingCapabilities": [], "outgoingCapabilities": [MOUSEPAD], "tcpPort": port}))
# KDE Connect's rule: whoever opened the TCP connection is the TLS server.
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(str(cert), str(key))
ctx.verify_mode = ssl.CERT_NONE # both sides are on this machine
self.sock = ctx.wrap_socket(raw, server_side=True)
self.sock.setblocking(False)
def send(self, body):
# Bounded: if KDE Connect stops reading, fail (and be restarted) rather than hang.
self.sock.settimeout(5)
try:
self.sock.sendall(packet(MOUSEPAD, body))
finally:
self.sock.setblocking(False)
def pair(self, paired, accept, timeout=15):
"""Ask to pair and accept it on KDE Connect's side (we control both ends).
Only asks when not already paired: a pair request to a device that is
already paired makes KDE Connect unpair it.
"""
if paired():
return
self.sock.settimeout(5)
self.sock.sendall(packet("kdeconnect.pair", {"pair": True}))
self.sock.setblocking(False)
end = time.time() + timeout
while time.time() < end:
accept()
self.read(0.5)
if paired():
return
raise RuntimeError("KDE Connect didn't accept the pairing")
def read(self, wait=0.0):
"""Packets waiting from kdeconnectd; None once it has closed the connection."""
if wait:
sel = selectors.DefaultSelector()
sel.register(self.sock, selectors.EVENT_READ)
sel.select(wait)
sel.close()
try:
while True:
chunk = self.sock.recv(65536)
if not chunk:
return None
self.buf += chunk
except (ssl.SSLWantReadError, BlockingIOError):
pass
out = []
while b"\n" in self.buf:
line, self.buf = self.buf.split(b"\n", 1)
if line.strip():
p = json.loads(line)
if p.get("type") == "kdeconnect.mousepad.keyboardstate":
self.keyboard = bool(p.get("body", {}).get("state"))
out.append(p)
return out
def events(line):
"""The event bodies in one stdin line (an object or a list of objects)."""
try:
value = json.loads(line)
except ValueError:
return []
return [e for e in (value if isinstance(value, list) else [value]) if isinstance(e, dict) and e]
def connect(device, cert, key, name):
link = Link(device, cert, key, name=name)
link.pair(lambda: qdbus(device, "isPaired") == "true", lambda: qdbus(device, "acceptPairing"))
link.read(0.5) # its hello, including whether it can type
return link
def client_args():
"""argv: a folder-safe id for the computer or phone, the name KDE Connect shows for it,
the folder holding the packages, and their [file, sha256] list."""
client = sys.argv[1] if len(sys.argv) > 1 else "default"
client = "".join(c for c in client if c.isalnum() or c in "-_")[:64] or "default"
name = (sys.argv[2] if len(sys.argv) > 2 else "")[:60].strip()
folder = os.path.expanduser(sys.argv[3]) if len(sys.argv) > 3 else ""
try:
packages = [(str(f), str(h)) for f, h in json.loads(sys.argv[4])] if len(sys.argv) > 4 else []
except (ValueError, TypeError):
packages = []
return client, f"Frame Control ({name})" if name else "Frame Control", folder, packages
def main():
client, name, folder, packages = client_args()
# A dropped ssh (the Frame slept, the app quit) hangs up on us: exit through the
# clean-up below rather than dying on the spot.
for sig in (signal.SIGHUP, signal.SIGTERM):
signal.signal(sig, lambda *_: sys.exit(0))
BASE.mkdir(parents=True, exist_ok=True)
# Every agent holds this lock shared while it runs. The last one out gets it
# exclusively and stops KDE Connect, so it runs, and shows up on the network,
# only while something is using the keyboard and trackpad.
clients = open(BASE / "clients.lock", "w")
fcntl.flock(clients, fcntl.LOCK_SH)
try:
return run(client, name, folder, packages)
finally:
# Finish the clean-up even if a second hang-up or TERM arrives meanwhile.
for sig in (signal.SIGHUP, signal.SIGTERM):
signal.signal(sig, signal.SIG_IGN)
fcntl.flock(clients, fcntl.LOCK_UN)
try:
fcntl.flock(clients, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
pass # another device is still using it
else:
with daemon_lock():
stop_daemon()
def tidy_incoming(folder):
"""Remove this start's copy of the packages, and any a cancelled start left over an hour ago."""
incoming = BASE / "incoming"
if folder.startswith(str(incoming) + "/"):
shutil.rmtree(folder, ignore_errors=True)
try:
for old in incoming.iterdir():
if time.time() - old.stat().st_mtime > 3600:
shutil.rmtree(old, ignore_errors=True)
incoming.rmdir()
except OSError:
pass # none, or another start's copy is still there
class daemon_lock:
"""Installing, starting and restarting KDE Connect happen one agent at a time."""
def __enter__(self):
self.file = open(BASE / "daemon.lock", "w")
fcntl.flock(self.file, fcntl.LOCK_EX)
def __exit__(self, *_):
self.file.close()
def run(client, name, folder, packages):
try:
with daemon_lock():
ensure_daemon(folder, packages)
device, cert, key = identity(client)
say("pairing")
seen = our_daemons()
try:
link = connect(device, cert, key, name)
except (OSError, RuntimeError):
if not seen:
raise
# Ours, but not answering (KDE Connect 24.02 can hang, for one after
# unpairing a device that's offline): start it afresh, once. If another
# agent already replaced it, just use the new one.
say("starting", message="Restarting KDE Connect on the Frame")
with daemon_lock():
if set(our_daemons()) & set(seen):
stop_daemon()
ensure_daemon(folder, packages)
link = connect(device, cert, key, name)
except NeedPackages:
say("need-packages") # the server copies them and starts again
return 1
except (OSError, RuntimeError, subprocess.SubprocessError) as e:
say("error", message=str(e))
return 1
tidy_incoming(folder) # unpacked or not needed: the copy has done its job
say("ready", keyboard=link.keyboard is not False)
stdin, pending = sys.stdin.fileno(), b""
sel = selectors.DefaultSelector()
sel.register(stdin, selectors.EVENT_READ)
sel.register(link.sock, selectors.EVENT_READ)
while True:
for key_, _ in sel.select(30):
if key_.fileobj == stdin:
chunk = os.read(stdin, 65536) # raw reads: a buffered readline could strand lines select can't see
if not chunk: # the server went away
return 0
*lines, pending = (pending + chunk).split(b"\n")
try:
for line in lines:
for body in events(line):
link.send(body)
except OSError as e:
say("error", message=f"Lost KDE Connect: {e}")
return 1
else:
packets = link.read()
if packets is None:
say("error", message="KDE Connect closed the connection")
return 1
if any(p.get("type") == "kdeconnect.pair" and not p.get("body", {}).get("pair") for p in packets):
say("error", message="KDE Connect unpaired Frame Control")
return 1
if __name__ == "__main__":
sys.exit(main())