is the SHA-256 of the archive, so a new
@@ -17,7 +19,9 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
- "frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js"]
+ "frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
+ "frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
+ "frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
def files():
@@ -47,6 +51,9 @@ def build():
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(__doc__)
+ sys.path.insert(0, str(ROOT / "frame" / "kdeconnect"))
+ import fetch
+ fetch.fetch()
data = build()
Path(sys.argv[1]).write_bytes(data)
print(hashlib.sha256(data).hexdigest()[:16])
diff --git a/tests/test_input.py b/tests/test_input.py
index bc2afbc..bf38bba 100644
--- a/tests/test_input.py
+++ b/tests/test_input.py
@@ -71,6 +71,8 @@ class InputEvents(unittest.TestCase):
del os.environ["FRAME_CLIENT"]
self.assertTrue(cmd.startswith("python3 -u -c '"))
self.assertIn(" test-client-1 ", cmd)
+ cmd = self.server.InputAgent(packages=[("a.pkg.tar.zst", "ab")]).command("~/in/x")
+ self.assertTrue(cmd.endswith(""" '~/in/x' '[["a.pkg.tar.zst","ab"]]'"""), cmd)
def test_batch_limits(self):
with self.assertRaises(self.server.Failure):
@@ -79,6 +81,156 @@ class InputEvents(unittest.TestCase):
self.server.remote_input({"events": [{"dx": 1}] * (self.server.INPUT_BATCH_LIMIT + 1)})
+class Bundled(unittest.TestCase):
+ """KDE Connect ships with Frame Control: the manifest, the notice, the copy to the Frame."""
+
+ @classmethod
+ def setUpClass(cls):
+ import server
+ cls.server = server
+ cls.manifest = json.loads((ROOT / "frame/kdeconnect/packages.json").read_text())
+
+ def test_manifest_pins_every_package(self):
+ packages = self.manifest["packages"]
+ self.assertEqual({p["name"] for p in packages},
+ {"kdeconnect", "kcontacts", "kpeople", "libfakekey", "modemmanager-qt", "pulseaudio-qt"})
+ for p in packages:
+ self.assertRegex(p["sha256"], r"^[0-9a-f]{64}$")
+ self.assertTrue(p["file"].startswith(f"{p['name']}-{p['version']}-") and p["file"].endswith("-aarch64.pkg.tar.zst"), p)
+ self.assertTrue(p["source"].startswith(self.manifest["release"]), p)
+ self.assertRegex(p["source_sha256"], r"^[0-9a-f]{64}$")
+ self.assertTrue(self.manifest["release"].startswith("https://github.com/") and self.manifest["release"].endswith("/"))
+ self.assertNotIn("DO_NOT_SHARE", json.dumps(self.manifest))
+
+ def test_notice_names_each_version_and_its_licence_texts_ship(self):
+ notice = (ROOT / "frame/kdeconnect/NOTICE.md").read_text()
+ for p in self.manifest["packages"]:
+ self.assertIn(f"{p['name']} {p['version']}", notice)
+ self.assertIn(p["source"], notice)
+ self.assertIn(p["upstream"], notice)
+ for spdx in p["licenses"]:
+ self.assertTrue((ROOT / "frame/kdeconnect/LICENSES" / p["name"] / f"{spdx}.txt").is_file(), spdx)
+ self.assertIn("frame/kdeconnect/NOTICE.md", (ROOT / "THIRD_PARTY_NOTICES.md").read_text())
+
+ def test_about_dialog_has_the_licences(self):
+ titles = [n["title"] for n in self.server.licenses()]
+ self.assertIn("Frame Control (MIT)", titles)
+ self.assertIn("KDE Connect for the Frame", titles)
+ self.assertIn("kdeconnect: GPL-2.0-only", titles)
+
+ def test_both_apps_bundle_the_packages(self):
+ pkg = json.loads((ROOT / "app/package.json").read_text())["build"]["extraResources"]
+ kde = next(r for r in pkg if r["from"] == "../frame/kdeconnect")
+ self.assertIn("packages/*.pkg.tar.zst", kde["filter"])
+ self.assertIn("LICENSES/**/*", kde["filter"])
+ bundle = (ROOT / "ios/scripts/make_frame_bundle.py").read_text()
+ for pattern in ("frame/kdeconnect/packages/*.pkg.tar.zst", "frame/kdeconnect/LICENSES/*/*", "THIRD_PARTY_NOTICES.md"):
+ self.assertIn(pattern, bundle)
+
+ def fake_bundle(self, damaged=False):
+ folder = Path(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, folder)
+ (folder / "packages").mkdir()
+ data = {"a-1-1-aarch64.pkg.tar.zst": b"first", "b-2-1-aarch64.pkg.tar.zst": b"second"}
+ packages = []
+ for name, body in data.items():
+ (folder / "packages" / name).write_bytes(b"x" + body if damaged else body)
+ packages.append((name, __import__("hashlib").sha256(body).hexdigest()))
+ return folder, packages
+
+ def deliver(self, frame_has, damaged=False):
+ folder, packages = self.fake_bundle(damaged)
+ calls = []
+
+ def ssh(remote, stdin=None, timeout=30, text=True):
+ calls.append((remote, stdin))
+ return "yes\n" if remote.startswith("{ test -x") and frame_has else ""
+ old = self.server.ssh, self.server.KDECONNECT, self.server.LOCAL
+ self.server.ssh, self.server.KDECONNECT, self.server.LOCAL = ssh, folder, False
+ try:
+ agent = self.server.InputAgent(packages=packages)
+ return agent.deliver(lambda m: calls.append(("report", m))), calls, packages
+ finally:
+ self.server.ssh, self.server.KDECONNECT, self.server.LOCAL = old
+
+ def test_copies_nothing_when_the_frame_has_them(self):
+ folder, calls, packages = self.deliver(frame_has=True)
+ self.assertEqual(folder, "")
+ self.assertEqual(len(calls), 1)
+ self.assertEqual(calls[0][1], "".join(f"{sha} {name}\n" for name, sha in packages))
+
+ def test_copies_each_package_over_ssh(self):
+ folder, calls, packages = self.deliver(frame_has=False)
+ self.assertTrue(folder.startswith("~/.local/share/frame-control/kdeconnect/incoming/"))
+ copies = [c for c in calls if c[0] != "report" and "cat >" in c[0]]
+ self.assertEqual([c[1] for c in copies], [b"first", b"second"])
+ self.assertIn("a-1-1-aarch64.pkg.tar.zst.part", copies[0][0])
+
+ def test_refuses_a_damaged_bundle(self):
+ with self.assertRaises(self.server.Failure):
+ self.deliver(frame_has=False, damaged=True)
+
+
+@unittest.skipIf(sys.platform == "win32", "the agent runs on the Frame (Linux)")
+class AgentInstall(unittest.TestCase):
+ """The agent unpacks what the server copied, after checking each SHA-256."""
+
+ @classmethod
+ def setUpClass(cls):
+ import frame_input_agent
+ cls.agent = frame_input_agent
+
+ def setUp(self):
+ self.dir = Path(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.dir)
+ saved = self.agent.BASE, self.agent.ROOT, self.agent.stop_daemon, self.agent.say
+ self.addCleanup(lambda: setattr_all(self.agent, saved))
+ self.agent.BASE, self.agent.ROOT = self.dir / "base", self.dir / "base/root"
+ self.agent.stop_daemon, self.agent.say = lambda: None, lambda *a, **k: None
+ self.agent.BASE.mkdir()
+
+ def package(self):
+ src = self.dir / "src"
+ (src / "usr/lib").mkdir(parents=True)
+ (src / "usr/lib/kdeconnectd").write_text("#!/bin/sh\n")
+ out = self.dir / "incoming/kdeconnect-24.02.2-1-aarch64.pkg.tar.zst"
+ out.parent.mkdir()
+ if subprocess.run(["tar", "--zstd", "-cf", str(out), "-C", str(src), "usr"], capture_output=True).returncode:
+ self.skipTest("this tar can't write zstd")
+ return out, [(out.name, self.agent.sha256(out))]
+
+ def test_unpacks_and_stamps(self):
+ path, packages = self.package()
+ self.assertFalse(self.agent.installed(packages))
+ self.agent.install(path.parent, packages)
+ self.assertTrue((self.agent.ROOT / "usr/lib/kdeconnectd").is_file())
+ self.assertTrue(self.agent.installed(packages))
+ self.assertFalse(self.agent.installed([(path.name, "0" * 64)]))
+
+ def test_refuses_a_damaged_package(self):
+ path, packages = self.package()
+ with open(path, "ab") as f:
+ f.write(b"!")
+ with self.assertRaisesRegex(RuntimeError, "damaged"):
+ self.agent.install(path.parent, packages)
+ self.assertFalse(self.agent.ROOT.exists())
+
+ def test_missing_package_and_empty_manifest(self):
+ with self.assertRaisesRegex(RuntimeError, "didn't reach"):
+ self.agent.install(self.dir, [("nope.pkg.tar.zst", "0" * 64)])
+ with self.assertRaisesRegex(RuntimeError, "doesn't include"):
+ self.agent.install(self.dir, [])
+
+ def test_server_and_agent_agree_on_the_stamp(self):
+ import server
+ packages = [("a.pkg.tar.zst", "1" * 64), ("b.pkg.tar.zst", "2" * 64)]
+ self.assertEqual(server.kdeconnect_stamp(packages), self.agent.stamp(packages))
+
+
+def setattr_all(module, saved):
+ module.BASE, module.ROOT, module.stop_daemon, module.say = saved
+
+
class FakeKdeConnect:
"""Just enough of kdeconnectd: pairs when asked and records remote-input packets."""
@@ -171,9 +323,11 @@ class AgentProtocol(unittest.TestCase):
old = sys.argv
try:
sys.argv = ["-c", "../../etc x", "Alex's Mac"]
- self.assertEqual(self.agent.client_args(), ("etcx", "Frame Control (Alex's Mac)"))
+ self.assertEqual(self.agent.client_args(), ("etcx", "Frame Control (Alex's Mac)", "", []))
sys.argv = ["-c"]
- self.assertEqual(self.agent.client_args(), ("default", "Frame Control"))
+ self.assertEqual(self.agent.client_args(), ("default", "Frame Control", "", []))
+ sys.argv = ["-c", "mac", "Mac", "~/x", '[["a.pkg.tar.zst", "ab"]]']
+ self.assertEqual(self.agent.client_args()[2:], (os.path.expanduser("~/x"), [("a.pkg.tar.zst", "ab")]))
finally:
sys.argv = old
diff --git a/ui/frame_input_agent.py b/ui/frame_input_agent.py
index 6a38861..776bc0f 100644
--- a/ui/frame_input_agent.py
+++ b/ui/frame_input_agent.py
@@ -6,10 +6,14 @@ 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, but Valve's package repository for it
-has a build. The first run fetches that and the few libraries the Frame lacks
-into ~/.local/share/frame-control/kdeconnect: no root, and SteamOS updates
-leave it alone.
+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", ...}.
@@ -17,6 +21,7 @@ Status goes to stdout, one JSON object per line:
Standard library only: this runs on the Frame's own Python.
"""
import fcntl
+import hashlib
import json
import os
import selectors
@@ -32,9 +37,7 @@ from pathlib import Path
BASE = Path.home() / ".local/share/frame-control/kdeconnect"
ROOT = BASE / "root"
BRIDGE = BASE / "bridge"
-# kdeconnect plus the dependencies the Frame's image doesn't have (checked 2026-09-28,
-# SteamOS 0.4.1); `pacman -Sp` adds any others still missing.
-PACKAGES = ["kdeconnect", "kpeople", "libfakekey", "modemmanager-qt", "pulseaudio-qt"]
+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"
@@ -50,32 +53,49 @@ def packet(kind, body):
# ---- KDE Connect on the Frame ------------------------------------------------
-def daemon_path():
- for path in (Path("/usr/lib/kdeconnectd"), ROOT / "usr/lib/kdeconnectd"):
- if path.exists():
- return path
- return None
+SYSTEM_DAEMON = Path("/usr/lib/kdeconnectd")
-def install():
- say("installing", message="Fetching KDE Connect from the Frame's package repository")
- found = subprocess.run(["pacman", "-Sp", *PACKAGES], capture_output=True, text=True, timeout=120)
- urls = [u for u in found.stdout.split() if u.startswith("https://")]
- if found.returncode or not urls:
- raise RuntimeError("Couldn't find KDE Connect in the Frame's package repository: "
- + (found.stderr.strip() or "no packages listed"))
- download, stage = BASE / "download", BASE / "root.new"
- for d in (download, stage):
- shutil.rmtree(d, ignore_errors=True)
- d.mkdir(parents=True)
- for url in urls:
- name = download / url.rsplit("/", 1)[1]
- subprocess.run(["curl", "-fsSL", "--retry", "2", "-o", str(name), url], check=True, timeout=600)
- if subprocess.run(["tar", "--zstd", "-xf", str(name), "-C", str(stage)], capture_output=True).returncode:
- subprocess.run(["bsdtar", "-xf", str(name), "-C", str(stage)], check=True, capture_output=True)
+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)
- shutil.rmtree(download, ignore_errors=True)
def app_display():
@@ -133,14 +153,15 @@ def stop_daemon():
time.sleep(0.1)
-def ensure_daemon():
+def ensure_daemon(folder, packages):
+ """Start KDE Connect: the Frame's own if it ever has one, else ours, unpacked first if needed."""
+ system = SYSTEM_DAEMON.exists()
+ if not system and not installed(packages):
+ install(folder, packages)
if listening():
return
BASE.mkdir(parents=True, exist_ok=True)
- daemon = daemon_path()
- if not daemon:
- install()
- daemon = daemon_path()
+ 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.
@@ -273,15 +294,21 @@ def connect(device, cert, key, name):
def client_args():
- """argv: a folder-safe id for the computer or phone, and the name KDE Connect shows for it."""
+ """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()
- return client, f"Frame Control ({name})" if name else "Frame Control"
+ 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 = client_args()
+ 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):
@@ -293,7 +320,7 @@ def main():
clients = open(BASE / "clients.lock", "w")
fcntl.flock(clients, fcntl.LOCK_SH)
try:
- return run(client, name)
+ 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):
@@ -319,10 +346,16 @@ class daemon_lock:
self.file.close()
-def run(client, name):
+def run(client, name, folder, packages):
try:
with daemon_lock():
- ensure_daemon()
+ ensure_daemon(folder, packages)
+ if folder.startswith(str(BASE / "incoming") + "/"):
+ shutil.rmtree(folder, ignore_errors=True) # unpacked; the copy isn't needed again
+ try:
+ (BASE / "incoming").rmdir()
+ except OSError:
+ pass # another device's copy is still there
device, cert, key = identity(client)
say("pairing")
seen = our_daemons()
@@ -338,7 +371,7 @@ def run(client, name):
with daemon_lock():
if set(our_daemons()) & set(seen):
stop_daemon()
- ensure_daemon()
+ ensure_daemon(folder, packages)
link = connect(device, cert, key, name)
except (OSError, RuntimeError, subprocess.SubprocessError) as e:
say("error", message=str(e))
diff --git a/ui/index.html b/ui/index.html
index 1d38ba6..48c4017 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -254,10 +254,13 @@
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
.and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; }
- #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
+ #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
- #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
- #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
+ #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
+ #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
+ .about-text { max-height: 55vh; overflow: auto; }
+ .about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
+ .about-text summary { cursor: pointer; margin: 8px 0; }
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
@@ -514,8 +517,9 @@
Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
- don't take it. It works through KDE Connect on the Frame: the first time, Frame Control fetches Valve's build of it
- (an 8 MB download, 82 MB unpacked) into your home folder there and pairs with it. Nothing to install on this device.
+ don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
+ for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
+ else to install. Licences
@@ -683,6 +687,7 @@
Sleep, Restart and Shut down open a terminal window for the Developer Mode password.
Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.
Change headset…
+ About and licences
@@ -713,6 +718,14 @@
Close
+
+ About and licences
+ Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
+ licence texts follow, with where to get the source.
+ Loading…
+ Close
+
+