Files
saphidandClaude Opus 5.5 d5e8193ce5 Bundled KDE Connect: tidy the copied packages however a start ends
- Each start keeps its copy (holding a lock on it) until it ends, however
  it ends, then removes it; a restart can still unpack it.
- The server removes a partial copy when copying fails.
- Other copies are removed only when unlocked and over an hour old.
- Tests: a start racing the need-packages retry (one agent, not two), a
  restart that must unpack its copy, a held copy surviving the sweep.
  Both regression tests fail without their fix.

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

470 lines
18 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 others nobody is using.
Another copy goes only if no agent holds its .in-use lock and it's over an
hour old (so not one a server is still copying, before its agent starts).
"""
incoming = BASE / "incoming"
if folder.startswith(str(incoming) + "/"):
shutil.rmtree(folder, ignore_errors=True)
try:
others = list(incoming.iterdir())
except OSError:
return
for other in others:
try:
if time.time() - other.stat().st_mtime < 3600:
continue
with open(other / ".in-use", "a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
shutil.rmtree(other, ignore_errors=True)
except OSError:
pass # in use, or already gone
try:
incoming.rmdir()
except OSError:
pass # another start's copy is still there
def hold_incoming(folder):
"""Mark this start's copy as in use (tidy_incoming leaves it alone); the lock lasts as long as the file."""
if not folder.startswith(str(BASE / "incoming") + "/"):
return None
try:
lock = open(Path(folder) / ".in-use", "a")
fcntl.flock(lock, fcntl.LOCK_SH)
return lock
except OSError:
return None
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):
"""Set up, pair and forward events. This start's copy of the packages stays
until it ends, however it ends: restarting KDE Connect may need to unpack it."""
held = hold_incoming(folder)
try:
return serve(client, name, folder, packages)
finally:
if held:
held.close()
tidy_incoming(folder)
def serve(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
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())