From 7a8e9195460c67e91fa6dc18bae1012e215c39ce Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 25 Oct 2026 06:13:55 +0000 Subject: [PATCH 1/5] Validate extension bridge API versions before loading panels (#209) Co-authored-by: SAPHID --- docs/extensions.md | 8 ++ .../extensions/headset-glance/manifest.json | 1 + tests/test_extensions.py | 107 ++++++++++++++++++ tests/test_server.py | 18 +++ ui/frame_extensions.py | 9 +- 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 tests/test_extensions.py diff --git a/docs/extensions.md b/docs/extensions.md index 978f6d8..13d3379 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -34,11 +34,19 @@ The manifest ID must match the folder name: "id": "your-id", "name": "Your feature", "version": "1.0.0", + "apiVersion": 1, "description": "What the panel does.", "entry": "index.html" } ``` +`apiVersion` selects the Frame Control bridge contract, independently of your +extension's own `version`. This app supports integer `1`; a missing field means +`1` for existing extensions. Unsupported versions appear as errors on Refresh, +and their assets are not served. Declare it in new extensions so a future API +change can fail clearly instead of running incompatible code. The runtime +`capabilities` reply below reports the bridge version too. + Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS, JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder. Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel diff --git a/examples/extensions/headset-glance/manifest.json b/examples/extensions/headset-glance/manifest.json index 97576c2..66ade56 100644 --- a/examples/extensions/headset-glance/manifest.json +++ b/examples/extensions/headset-glance/manifest.json @@ -2,6 +2,7 @@ "id": "headset-glance", "name": "Headset Glance", "version": "1.0.0", + "apiVersion": 1, "description": "Shows a small read-only summary of the connected Steam Frame.", "entry": "index.html" } diff --git a/tests/test_extensions.py b/tests/test_extensions.py new file mode 100644 index 0000000..9f1091c --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,107 @@ +"""Extension compatibility and filesystem boundaries, without running plugin code.""" +import sandbox # noqa: F401 +import json +import shutil +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import frame_extensions + + +class ExtensionFiles(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'extensions' + self.root.mkdir() + self.patch = patch.object(frame_extensions, 'root', return_value=self.root) + self.patch.start() + self.addCleanup(self.patch.stop) + + def extension(self, name='test-panel', **fields): + folder = self.root / name + folder.mkdir(exist_ok=True) + manifest = {'id': name, 'name': 'Test panel', 'version': '1.0.0', + 'description': 'A read-only panel', 'entry': 'index.html', **fields} + (folder / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8') + (folder / 'index.html').write_text('', encoding='utf-8') + (folder / 'main.js').write_text('/* extension code */', encoding='utf-8') + return folder + + def test_existing_and_explicit_v1_manifests_are_compatible(self): + self.extension('legacy') + self.extension('explicit', apiVersion=1) + result = frame_extensions.list_extensions() + self.assertEqual(result['errors'], []) + self.assertEqual([(x['id'], x['apiVersion']) for x in result['extensions']], + [('explicit', 1), ('legacy', 1)]) + self.assertEqual(frame_extensions.asset('explicit', 'main.js'), + (b'/* extension code */', 'text/javascript; charset=utf-8')) + + def test_unsupported_or_malformed_api_cannot_be_loaded_directly(self): + for version in (2, 0, -1, '1', None, True, 1.0, [], {}): + with self.subTest(apiVersion=version): + self.extension(apiVersion=version) + result = frame_extensions.list_extensions() + self.assertEqual(result['extensions'], []) + self.assertIn('unsupported apiVersion', result['errors'][0]['error']) + with self.assertRaisesRegex(ValueError, 'unsupported apiVersion'): + frame_extensions.asset('test-panel', 'index.html') + + def test_one_incompatible_extension_does_not_hide_compatible_panels(self): + self.extension('future', apiVersion=2) + self.extension('working', apiVersion=1) + result = frame_extensions.list_extensions() + self.assertEqual([x['id'] for x in result['extensions']], ['working']) + self.assertEqual(result['errors'][0]['folder'], 'future') + + def test_sample_is_discoverable_and_its_assets_are_served(self): + source = Path(__file__).resolve().parents[1] / 'examples/extensions/headset-glance' + shutil.copytree(source, self.root / source.name) + result = frame_extensions.list_extensions() + self.assertEqual(result['errors'], []) + self.assertEqual(result['extensions'][0]['id'], source.name) + for asset in ('index.html', 'main.js', 'style.css'): + self.assertEqual(frame_extensions.asset(source.name, asset)[0], + (source / asset).read_bytes()) + + def test_private_nested_and_traversal_paths_are_rejected(self): + self.extension() + for name in ('manifest.json', '../outside.js', 'sub/main.js', '.hidden.js'): + with self.subTest(name=name), self.assertRaises(FileNotFoundError): + frame_extensions.asset('test-panel', name) + with self.assertRaises(FileNotFoundError): + frame_extensions.asset('../test-panel', 'main.js') + + def test_oversized_assets_are_not_read(self): + folder = self.extension() + with (folder / 'large.js').open('wb') as output: + output.truncate(5 * 1024 * 1024 + 1) + with self.assertRaises(FileNotFoundError): + frame_extensions.asset('test-panel', 'large.js') + + def test_symlinks_cannot_expose_files_outside_a_panel(self): + folder = self.extension() + outside = Path(self.tmp.name) / 'outside.js' + outside.write_text('private file', encoding='utf-8') + try: + (folder / 'escape.js').symlink_to(outside) + (self.root / 'alias').symlink_to(folder, target_is_directory=True) + except OSError: + self.skipTest('symlinks unavailable for this user') + with self.assertRaises(FileNotFoundError): + frame_extensions.asset('test-panel', 'escape.js') + with self.assertRaises(ValueError): + frame_extensions.asset('alias', 'main.js') + (folder / 'manifest.json').unlink() + (folder / 'manifest.json').symlink_to(outside) + with self.assertRaises(ValueError): + frame_extensions.asset('test-panel', 'main.js') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index 8a9b5b9..97696be 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -126,6 +126,24 @@ class ServerGuards(unittest.TestCase): self.assertEqual(code, 400) self.assertIn('experimental', body['error']) + def test_incompatible_extension_is_reported_and_assets_are_unavailable(self): + folder = Path(os.environ['FRAME_CONTROL_DATA_DIR']) / 'extensions' / 'future-panel' + folder.mkdir(parents=True, exist_ok=True) + (folder / 'manifest.json').write_text(json.dumps({'id': 'future-panel', 'name': 'Future', + 'version': '1.0', 'description': 'Requires a newer API', 'apiVersion': 2})) + (folder / 'index.html').write_text('

Incompatible panel

') + try: + self.assertEqual(self.request('GET', '/api/extensions')[0], 403) + code, _, body = self.request('GET', '/api/extensions', headers={'X-Frame-UI': '1'}) + self.assertEqual(code, 200) + data = json.loads(body) + self.assertNotIn('future-panel', [x['id'] for x in data['extensions']]) + self.assertTrue(any(x['folder'] == 'future-panel' and 'apiVersion' in x['error'] + for x in data['errors'])) + self.assertEqual(self.request('GET', '/extension/future-panel/index.html')[0], 404) + finally: + shutil.rmtree(folder) + def test_headset_page_and_install_acknowledgement(self): code, _, body = self.request('GET', '/headset') self.assertEqual(code, 200) diff --git a/ui/frame_extensions.py b/ui/frame_extensions.py index 5cfe39c..6bd531c 100644 --- a/ui/frame_extensions.py +++ b/ui/frame_extensions.py @@ -7,6 +7,7 @@ import frame_host ID = re.compile(r"[a-z][a-z0-9-]{0,39}\Z") +API_VERSION = 1 ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z") TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json", @@ -27,6 +28,11 @@ def _manifest(folder): data = json.loads(manifest.read_text(encoding="utf-8")) if not isinstance(data, dict) or data.get("id") != folder.name: raise ValueError("manifest id must match its folder") + # Older manifests predate this field and use bridge v1. Check again when + # serving assets, so an incompatible panel cannot bypass discovery. + api_version = data.get("apiVersion", 1) + if type(api_version) is not int or api_version != API_VERSION: + raise ValueError(f"unsupported apiVersion; this app supports {API_VERSION}") for key, limit in (("name", 80), ("version", 40), ("description", 240)): value = data.get(key) if not isinstance(value, str) or not value.strip() or len(value) > limit: @@ -34,7 +40,8 @@ def _manifest(folder): entry = data.get("entry", "index.html") if entry != "index.html" or (folder / entry).is_symlink() or not (folder / entry).is_file(): raise ValueError("entry must be a regular index.html") - return {key: data[key] for key in ("id", "name", "version", "description")} + return {**{key: data[key] for key in ("id", "name", "version", "description")}, + "apiVersion": api_version} def list_extensions(): From 234b0a18144f56b3d7e4c40ebde2b94dca27d165 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 25 Oct 2026 06:15:42 +0000 Subject: [PATCH 2/5] Add managed Decky store and Frame plugin compatibility audit (#208) * Add managed Decky store plugins and Frame compatibility audit * Reject malformed flags and linked plugin folders --------- Co-authored-by: SAPHID --- README.md | 2 +- docs/decky-frame.md | 58 +++++++++- docs/frame-plugin-author.md | 34 ++++++ docs/headset-app.md | 8 ++ tests/test_frame_decky_store.py | 128 +++++++++++++++++++++ ui/frame_decky.py | 8 +- ui/frame_decky_store.py | 197 ++++++++++++++++++++++++++++++++ ui/headset.html | 35 +++++- ui/index.html | 61 +++++++++- ui/server.py | 15 +++ 10 files changed, 539 insertions(+), 7 deletions(-) create mode 100644 docs/frame-plugin-author.md create mode 100644 tests/test_frame_decky_store.py create mode 100644 ui/frame_decky_store.py diff --git a/README.md b/README.md index f33d0c0..40a5dc8 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ Frame's software fits together, all checked against a real headset and labelled | [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build | | [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input | | [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers | -| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API | +| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame plugin author guide](docs/frame-plugin-author.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API | | [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes | | [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing | | [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset | diff --git a/docs/decky-frame.md b/docs/decky-frame.md index dc18fde..ec540b8 100644 --- a/docs/decky-frame.md +++ b/docs/decky-frame.md @@ -7,6 +7,57 @@ verified. Frame Control therefore labels its user-service manager experimental. ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its VR user interface has not been seen or used. +## Official store plugin audit, 2026-10-25 + +On a real Frame with Frame OS 0.5.6 build `20261022.6153909`, SteamVR 2.18.2 +reported `activity_level` 3 (standby). The battery was 99% before and after. +The test downloaded the official store's current archives, checked each SHA-256 +against store metadata, and inspected their manifests and binaries. For seven +non-root candidates, the removable user-service loader started each plugin in +turn. It stopped between plugins. Logs showed `Loaded ` for all seven. +The service and test folders were removed after the run; the owned shared lock +was released. **A load message is not proof that a plugin's page, settings, or +action works in VR.** No plugin was tried by a wearer. + +| Official store plugin (tested version) | Result | Evidence and limit | +| --- | --- | --- | +| ProtonDB Badges 1.3.4 | Partial | ARM64 backend loaded. Earlier 1.1.0 also registered in the shared Gamepad UI; badges in the VR shell were not seen. | +| CSS Loader 2.1.3 | Partial | Backend loaded; one `SyntaxWarning`. Theme controls and CSS application in VR were not checked. | +| SteamGridDB 1.8.1 | Partial | Backend loaded. Artwork selection and its UI were not checked. | +| Animation Changer 1.3.2 | Partial | Backend loaded. Boot animation replacement was not attempted. | +| Storage Cleaner 1.4.0 | Partial | Backend loaded. No files were cleaned; no UI check. | +| HLTB for Deck 2.0.12 | Partial | Passive plugin loaded. Game page content in VR was not checked. | +| Bluetooth 3.1.1 | Partial | Backend logged `Bluetooth plugin loaded`. Pairing and VR controls were not checked. | +| PowerTools 2.0.3 | Not applicable | Manifest asks for root; archive includes x86_64 Ryzen binaries and targets Steam Deck hardware. The Frame user service blocks it. | +| Audio Loader 1.6.1 | Not applicable | Manifest asks for `_root`; the Frame user service blocks it. | +| Wine Cellar 0.2.1 | Broken on ARM64 | Archive includes an x86_64 backend binary. The store installer blocks it before execution. | +| MagicPods 2.0.21 | Broken on ARM64 | Archive includes an x86_64 `magicpodscore` binary. The store installer blocks it before execution. | +| Decky Recorder | Not in current official store | No matching plugin appeared in the official store list on this date; no archive was installed. Frame Control's own headset panel has a separate recorder. | + +The table reflects the tested versions only. Other releases can change their +files or behavior. The official store's digest checks the archive in transit; +it does not certify plugin code. Plugins run with the headset user's access. +For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md). + +The new store transaction was also exercised on the same real Frame: +**managed loader install → ProtonDB Badges 1.3.4 store install → status → +store update → store remove → managed loader remove**. Status reported the +managed store ID and version while the plugin was installed. The service +remained active after each store change. The battery was 99% before and after. +The test removed its plugin folder, loader, and owned lock. This validates the +store file and service path over SSH, not a visible VR plugin page. + +Frame Control's desktop app and opt-in headset panel now show official store +entries. Install, update, and remove need an explicit confirmation. They only +change Frame Control owned plugin folders under +`~/.local/share/frame-control/decky/plugins`. The installer checks the store +hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls +back an update if the service restart fails. The current VR shell does not +provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel +shows installed plugins and store actions, but plugin pages, settings, routes, +and Decky toasts are **not** available there yet. The panel itself still needs +a worn-headset visual and controller check. + ## What was checked on the real headset The headset was SteamOS `0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`, @@ -86,8 +137,8 @@ its published SHA-256 digest, and checks the ELF architecture. It installs a officially supported. Plugins that require root, assume `~/homebrew`, or rely on Steam Deck hardware may fail even if the loader runs. Frame Control lists plugins already present in its managed folder and the upstream `~/homebrew` -folder; it does not install plugins while their Frame UI and compatibility -are unverified. +folder. The opt-in store manager installs compatible archives only into its +own folder. It cannot make Steam Deck QAM plugin pages visible in Frame VR. The latest tested release was [Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0), @@ -103,6 +154,9 @@ notice, check the acknowledgement box, and choose **Install**. **Update** downloads and verifies the latest ARM64 release again. **Remove managed Decky** disables and removes only Frame Control's user service and binary. It preserves plugin files and settings so unrelated user data is not deleted. +Open **Official plugin store** to review a plugin, then confirm each install, +update, or remove. Root and x86-only plugins are blocked. The manager only +removes plugin folders that it installed; it leaves other Decky folders alone. Restart Steam or the headset to clear the frontend already injected into its current web helper process. No action wipes the headset. diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md new file mode 100644 index 0000000..26f61d7 --- /dev/null +++ b/docs/frame-plugin-author.md @@ -0,0 +1,34 @@ +# Write a plugin for Frame + +There are two paths. Choose the one that matches your feature. + +## Frame Control extension + +Use a Frame Control extension for a panel, status view, or guide that does not +need to change the headset. Put `extension.json` and `index.html` in one folder +under `~/.local/share/frame-control/extensions`. The opt-in headset panel lists +the folder and opens it in a sandboxed iframe. The version 1 bridge has only +`capabilities` and read-only `status`. It cannot call Steam, change settings, +or run privileged code. See [the extension API](extensions.md) and its example. + +## Decky plugin + +Use a Decky plugin only if you need Decky's backend or Gamepad UI integration. +Build an official-format ZIP with one top-level folder, `plugin.json`, and a +frontend bundle in `dist/index.js`. A Python `main.py` backend is optional. +Test on ARM64. If you ship an ELF helper, build it for AArch64 (ELF machine +183). Avoid root, `sudo`, Steam Deck TDP controls, fixed x86 binaries, and +paths under `/etc`. Frame Control's managed loader is a normal user service; +it blocks root plugins and x86 archives. Keep data under your plugin folder or +the user's home directory, and provide a removal path. + +The current Frame SteamVR shell does not expose Decky's Steam Deck Quick Access +Menu. A backend `Loaded` log only proves backend startup. Test any frontend +page, setting, notification, route, and controller input while wearing a Frame +before describing it as working in VR. Until a QAM bridge is verified, a Frame +Control extension is the practical route for a visible read-only VR panel. + +To test safely, use a separate Frame Control managed Decky install, verify the +ZIP digest from the official store, install one plugin at a time, and inspect +`journalctl --user -u frame-control-decky.service`. Remove only your own +plugin folder when you finish. See the current [compatibility table](decky-frame.md). diff --git a/docs/headset-app.md b/docs/headset-app.md index 6473067..4fde137 100644 --- a/docs/headset-app.md +++ b/docs/headset-app.md @@ -28,6 +28,14 @@ It offers the version 1 read-only `capabilities` and `status` bridge from [the extension guide](extensions.md). Installing an extension still means copying its folder to the Frame; the panel does not download third-party code. +The panel also lists Decky plugins and can install, update, or remove compatible +plugins from the official store when the opt-in managed loader is running. +Each change asks for confirmation. The Frame VR shell does not show Decky's +Steam Deck Quick Access Menu, so Decky plugin pages, settings, routes, and +toasts are not available in this panel yet. The [compatibility table](decky-frame.md) +separates backend loading from VR use. Authors can use the +[Frame plugin and extension guide](frame-plugin-author.md). + The per-run UI key is passed in the URL fragment, removed from the address bar after load, and sent in the `X-Frame-UI` header. The key is absent from the HTTP request line and server log. The usual host/origin and anti-framing checks still apply. diff --git a/tests/test_frame_decky_store.py b/tests/test_frame_decky_store.py new file mode 100644 index 0000000..128a2b5 --- /dev/null +++ b/tests/test_frame_decky_store.py @@ -0,0 +1,128 @@ +"""Official Decky store transport and owned plugin transaction.""" +import sandbox # noqa: F401 +import hashlib +import io +import json +import os +from pathlib import Path +import secrets +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui')) +import frame_decky_store + + +def archive(flags=(), extra=None): + stream = io.BytesIO() + with zipfile.ZipFile(stream, 'w') as bundle: + bundle.writestr('Example/plugin.json', json.dumps({'name': 'Example', 'flags': list(flags)})) + bundle.writestr('Example/dist/index.js', 'export default {};' + ' ' * 1200) + if extra: + bundle.writestr(*extra) + data = stream.getvalue() + return data, hashlib.sha256(data).hexdigest() + + +class ArchiveTests(unittest.TestCase): + def test_catalog_selects_valid_visible_latest_versions(self): + records = [{'id': 42, 'name': 'Example', 'visible': True, 'versions': + [{'name': '1.0', 'hash': 'a' * 64}], 'tags': []}, + {'id': 43, 'name': 'Hidden', 'visible': False, 'versions': + [{'name': '1.0', 'hash': 'b' * 64}]}, + {'id': 44, 'name': 'Unsafe', 'visible': True, 'versions': + [{'name': '1.0; touch /tmp/bad', 'hash': 'c' * 64}]}] + with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())): + self.assertEqual([p['id'] for p in frame_decky_store.catalog()], [42]) + + def test_accepts_hashed_non_root_archive(self): + data, digest = archive() + self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example') + with self.assertRaisesRegex(ValueError, 'SHA-256'): + frame_decky_store.archive_info(data, '0' * 64, 'Example') + + def test_rejects_root_x86_and_escape(self): + for data in (archive(flags=['root'])[0], + archive(extra=('Example/bin/helper', b'\x7fELF' + b'\x00' * 14 + (62).to_bytes(2, 'little')))[0], + archive(extra=('../outside', b'oops'))[0]): + with self.subTest(data=hashlib.sha256(data).hexdigest()[:8]): + with self.assertRaises(ValueError): + frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example') + + +@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux') +class StoreTransaction(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + home = Path(self.tmp.name) + unit = home / '.config/systemd/user/frame-control-decky.service' + unit.parent.mkdir(parents=True) + unit.write_text('[Service]\n') + fake = home / 'systemctl' + fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 0; fi\nexit 0\n') + fake.chmod(0o700) + self.env = {**os.environ, 'HOME': self.tmp.name, + 'PATH': self.tmp.name + os.pathsep + os.environ['PATH']} + self.folder = home / '.local/share/frame-control/decky/plugins/Example' + self.folder.parent.mkdir(parents=True) + + def apply(self, action, data=None): + token = secrets.token_hex(12) + source = Path('/tmp/frame-control-decky-plugin-' + token) + if data is not None: + source.write_bytes(data) + digest = hashlib.sha256(data or b'').hexdigest() + try: + return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0'], + input=frame_decky_store.APPLY, text=True, capture_output=True, + env=self.env, timeout=15) + finally: + source.unlink(missing_ok=True) + + def test_install_update_remove_only_owned_folder(self): + first, _ = archive() + result = self.apply('install', first) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads((self.folder / '.frame-control-store.json').read_text())['storeId'], 42) + second, _ = archive(extra=('Example/data.txt', 'new')) + result = self.apply('update', second) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.folder / 'data.txt').read_text(), 'new') + result = self.apply('remove') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.folder.exists()) + self.folder.mkdir() + (self.folder / 'plugin.json').write_text('{}') + result = self.apply('remove') + self.assertNotEqual(result.returncode, 0) + self.assertTrue(self.folder.exists()) + external = Path(self.tmp.name) / 'other-plugin' + external.mkdir() + (external / '.frame-control-store.json').write_text('{"storeId":42}') + for item in self.folder.iterdir(): item.unlink() + self.folder.rmdir() + self.folder.symlink_to(external, target_is_directory=True) + result = self.apply('remove') + self.assertNotEqual(result.returncode, 0) + self.assertTrue(external.exists()) + + +class StoreHost(unittest.TestCase): + def test_owned_plugin_can_be_removed_without_store_network(self): + state = {'managed': True, 'running': True, 'plugins': + [{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]} + def ssh(command, **kwargs): + return '{}' + with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \ + patch.object(frame_decky_store, 'catalog') as network: + frame_decky_store.change(ssh, 'remove', 42) + network.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/ui/frame_decky.py b/ui/frame_decky.py index 015185a..3ff2bb6 100644 --- a/ui/frame_decky.py +++ b/ui/frame_decky.py @@ -31,8 +31,14 @@ def plugins(folder): if info.is_symlink() or not info.is_file() or info.stat().st_size > 16384: continue try: data = json.loads(info.read_text()) + marker = child / '.frame-control-store.json' + try: + owned = json.loads(marker.read_text()) if marker.is_file() and not marker.is_symlink() else {} + except (OSError, ValueError): owned = {} found.append({'folder': child.name, 'name': str(data.get('name', child.name))[:80], - 'author': str(data.get('author', ''))[:80]}) + 'author': str(data.get('author', ''))[:80], + 'storeId': owned.get('storeId') if isinstance(owned.get('storeId'), int) else None, + 'version': str(owned.get('version', ''))[:40]}) except (ValueError, OSError, UnicodeError): pass return found os_release = {} diff --git a/ui/frame_decky_store.py b/ui/frame_decky_store.py new file mode 100644 index 0000000..5025948 --- /dev/null +++ b/ui/frame_decky_store.py @@ -0,0 +1,197 @@ +"""Verified official-store archives for the opt-in Frame Decky user service. + +Only Frame Control owned plugin folders can be updated or removed. The store +digest validates transport, not plugin trust; store plugins run as the user. +""" +import hashlib +import io +import json +import re +import secrets +import urllib.request +import zipfile + +import frame_decky + + +STORE_URL = 'https://plugins.deckbrew.xyz/plugins' +CDN = 'https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/' +MAX_STORE = 4 * 1024 * 1024 +MAX_ZIP = 50 * 1024 * 1024 +MAX_UNPACKED = 150 * 1024 * 1024 +SAFE_FOLDER = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\Z') +SHA = re.compile(r'[0-9a-f]{64}\Z') +VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z') + + +def catalog(): + request = urllib.request.Request(STORE_URL, headers={'User-Agent': 'Frame-Control'}) + with urllib.request.urlopen(request, timeout=15) as response: + raw = response.read(MAX_STORE + 1) + if len(raw) > MAX_STORE: + raise ValueError('Decky store response is too large') + records = json.loads(raw) + if not isinstance(records, list): + raise ValueError('Invalid Decky store response') + result = [] + for item in records: + if not isinstance(item, dict) or item.get('visible') is False: + continue + versions = item.get('versions') + if type(item.get('id')) is not int or item['id'] <= 0 or not isinstance(item.get('name'), str) or not isinstance(versions, list): + continue + latest = next((v for v in versions if isinstance(v, dict) and isinstance(v.get('name'), str) + and VERSION.fullmatch(v['name']) and isinstance(v.get('hash'), str) and SHA.fullmatch(v['hash'])), None) + if not latest: + continue + tags = item.get('tags') if isinstance(item.get('tags'), list) else [] + result.append({'id': item['id'], 'name': item['name'][:100], + 'description': str(item.get('description') or '')[:300], + 'version': latest['name'][:40], 'sha256': latest['hash'], + 'root': 'root' in tags}) + return result + + +def archive_info(data, expected_hash, expected_name): + if not 1024 < len(data) <= MAX_ZIP or hashlib.sha256(data).hexdigest() != expected_hash: + raise ValueError('Plugin archive size or SHA-256 did not match the store') + with zipfile.ZipFile(io.BytesIO(data)) as archive: + files = archive.infolist() + if not files or len(files) > 3000 or sum(f.file_size for f in files) > MAX_UNPACKED: + raise ValueError('Plugin archive exceeds the file or size limit') + names = [f.filename for f in files] + manifests = [n for n in names if n.count('/') == 1 and n.endswith('/plugin.json')] + if len(manifests) != 1: + raise ValueError('Plugin archive needs one top-level manifest') + folder = manifests[0].split('/')[0] + if not SAFE_FOLDER.fullmatch(folder): + raise ValueError('Invalid plugin folder') + for entry in files: + name = entry.filename + mode = entry.external_attr >> 16 + if ('\\' in name or name.startswith('/') or '..' in name.split('/') or + name.split('/')[0] != folder or entry.file_size > 30 * 1024 * 1024 or + mode & 0o170000 == 0o120000): + raise ValueError('Unsafe plugin archive member') + if entry.is_dir(): + continue + with archive.open(entry) as source: + header = source.read(20) + if header[:4] == b'\x7fELF' and int.from_bytes(header[18:20], 'little') != 183: + raise ValueError('Plugin contains a non-ARM64 Linux binary: ' + name) + if header[:2] == b'MZ': + raise ValueError('Plugin contains a Windows binary: ' + name) + manifest = json.loads(archive.read(manifests[0])) + if manifest.get('name') != expected_name: + raise ValueError('Plugin name differs from the official store') + flags = manifest.get('flags', []) + if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags): + raise ValueError('Invalid plugin flags') + if 'root' in flags or '_root' in flags: + raise ValueError('This plugin needs root and is not supported by the Frame user service') + return folder + + +APPLY = r''' +import hashlib,json,pathlib,shutil,subprocess,sys,zipfile +action,token,plugin_id,digest,folder,version = sys.argv[1:] +base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins' +unit=pathlib.Path.home()/'.config/systemd/user/frame-control-decky.service' +target=base/folder +archive=pathlib.Path('/tmp/frame-control-decky-plugin-'+token) +stage=base/('.frame-control-stage-'+token) +backup=base/('.frame-control-old-'+token) +marker='.frame-control-store.json' +def owned(): + if target.is_symlink() or not target.is_dir(): return False + try: return json.loads((target/marker).read_text()).get('storeId') == int(plugin_id) + except (OSError,ValueError): return False +def service(*args): + p=subprocess.run(['systemctl','--user',*args],capture_output=True,text=True,timeout=25) + if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'Decky service failed') +if not unit.is_file(): raise RuntimeError('Install Frame Control managed Decky first') +if action not in ('install','update','remove'): raise RuntimeError('Invalid plugin action') +if action=='install' and (target.exists() or target.is_symlink()): raise RuntimeError('Plugin folder already exists') +if action in ('update','remove') and not owned(): raise RuntimeError('Only a Frame Control store plugin can be changed') +running=subprocess.run(['systemctl','--user','is-active','--quiet','frame-control-decky.service']).returncode==0 +try: + if action!='remove': + data=archive.read_bytes() + if not 10243000 or sum(f.file_size for f in files)>150*1024*1024: + raise RuntimeError('Plugin archive too large') + if any(f.filename.split('/')[0]!=folder or f.filename.startswith('/') or + '..' in f.filename.split('/') or '\\' in f.filename or + f.file_size>30*1024*1024 or f.external_attr>>16&0o170000==0o120000 for f in files): + raise RuntimeError('Unsafe plugin archive') + stage.mkdir(mode=0o700) + z.extractall(stage) + unpacked=stage/folder + if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder') + (unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n') + if running: service('stop','frame-control-decky.service') + try: + if action!='install': target.rename(backup) + if action!='remove': (stage/folder).rename(target) + if running: service('start','frame-control-decky.service') + except Exception: + if target.exists(): shutil.rmtree(target) + if backup.exists(): backup.rename(target) + if running: subprocess.run(['systemctl','--user','start','frame-control-decky.service'],timeout=25) + raise + if backup.exists(): shutil.rmtree(backup) + past={'install':'Installed','update':'Updated','remove':'Removed'}[action] + print(json.dumps({'message':f'{past} {folder}. Plugin UI in VR is not yet verified.'})) +finally: + archive.unlink(missing_ok=True) + if stage.exists(): shutil.rmtree(stage) +''' + + +def change(ssh, action, plugin_id): + if action not in ('install', 'update', 'remove') or type(plugin_id) is not int or plugin_id <= 0: + raise ValueError('Choose a valid store plugin action') + state = frame_decky.status(ssh) + if not state['managed'] or not state['running']: + raise ValueError('Start Frame Control managed Decky before changing plugins') + installed = [p for p in state['plugins'] if p.get('storeId') == plugin_id] + if action == 'remove': + if len(installed) != 1: + raise ValueError('Only an installed Frame Control store plugin can be removed') + folder = installed[0]['folder'] + plugin = {'sha256': '0' * 64, 'version': 'removed'} + else: + plugin = next((item for item in catalog() if item['id'] == plugin_id), None) + if not plugin: + raise ValueError('Plugin is not in the official Decky store') + if plugin['root']: + raise ValueError('Root plugins are not supported by the Frame user service') + existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']] + if action == 'install' and existing_name: + raise ValueError('A plugin with this name is already installed') + if action == 'update' and len(installed) != 1: + raise ValueError('Only an installed Frame Control store plugin can be updated') + data = None + if action != 'remove': + url = CDN + plugin['sha256'] + '.zip' + with urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Frame-Control'}), timeout=35) as response: + data = response.read(MAX_ZIP + 1) + folder = archive_info(data, plugin['sha256'], plugin['name']) + if action == 'update' and installed[0]['folder'] != folder: + raise ValueError('The store changed its plugin folder; remove the old version before installing') + if not SAFE_FOLDER.fullmatch(folder): + raise ValueError('Unsafe installed plugin folder') + token = secrets.token_hex(12) + remote = '/tmp/frame-control-decky-plugin-' + token + try: + if data is not None: + ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120) + return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]}', + stdin=APPLY, timeout=90)) + finally: + if data is not None: + try: ssh('rm -f ' + remote, timeout=10) + except Exception: pass diff --git a/ui/headset.html b/ui/headset.html index 92a59d4..251d679 100644 --- a/ui/headset.html +++ b/ui/headset.html @@ -27,6 +27,10 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid

Running games

Loading…
+

Decky plugins

Manage official store plugins here. Their backend can run on Frame, but Decky's Steam Deck Quick Access Menu is absent from this VR shell. Plugin pages, settings, routes, and toasts are not yet available in this panel.

+
+

+

Extensions

Local additions run only when opened in the isolated panel below. They can read status but cannot change headset settings through the extension API.

Loading…
@@ -61,6 +65,34 @@ async function refreshGames(){const list=byId('games');try{const data=await api( catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}}; row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.'; }catch(error){list.textContent=error.message;}} +let deckyState=null, deckyStore=[]; +async function refreshDecky(){try{deckyState=await api('/api/decky'); + byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':'Decky is not running. Install it from Frame Control on your computer.'; + const list=byId('deckyInstalled');list.replaceChildren(); + for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block'; + const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name); + if(plugin.storeId){const button=document.createElement('button');button.className='danger';button.textContent='Remove'; + button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);} + list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.'; + showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}} +async function loadDeckyStore(){try{const data=await api('/api/decky/store');deckyStore=data.plugins; + byId('deckyStorePanel').hidden=false;showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}} +function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren(); + const q=byId('deckySearch').value.trim().toLowerCase(); + for(const plugin of deckyStore.filter(p=>!q||`${p.name} ${p.description}`.toLowerCase().includes(q)).slice(0,30)){ + const row=document.createElement('div');row.className='block game'; + const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version} · ${plugin.description}`; + const button=document.createElement('button');button.textContent='Install'; + const installed=(deckyState?.plugins||[]).find(p=>p.storeId===plugin.id); + if(installed)button.textContent=installed.version===plugin.version?'Reinstall':'Update'; + button.disabled=!deckyState?.running||plugin.root; + if(plugin.root)label.textContent+=' · Needs root; not supported on Frame'; + button.onclick=()=>deckyChange(installed?'update':'install',plugin);row.append(label,button);list.append(row);}} +async function deckyChange(action,plugin){if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return; + byId('deckyState').textContent=`${action} ${plugin.name}…`; + try{const job=await api('/api/decky/plugin',{action,id:plugin.id,acknowledged:true}); + byId('deckyState').textContent=job.message||'Request queued. Refresh after it finishes.'; + setTimeout(refreshDecky,5000);}catch(error){byId('deckyState').textContent=error.message;}} async function refreshRecord(){try{const data=await api('/api/record'); byId('record').textContent=['recording','starting'].includes(data.state)?'Stop recording':'Start recording'; byId('record').disabled=recordBusy||['stopping','saving'].includes(data.state); @@ -84,6 +116,7 @@ async function refreshExtensions(){const list=byId('extensions');try{const data= }catch(error){list.textContent=error.message;}} byId('refresh').onclick=refresh;byId('gameMode').onclick=()=>act('game-mode');byId('screenshot').onclick=()=>act('screenshot'); byId('gamesRefresh').onclick=refreshGames;byId('record').onclick=toggleRecord;byId('extensionsRefresh').onclick=refreshExtensions; +byId('deckyRefresh').onclick=refreshDecky;byId('deckyStoreLoad').onclick=loadDeckyStore;byId('deckySearch').oninput=showDeckyStore; byId('extensionClose').onclick=()=>{activeExtension=null;byId('extensionFrame').removeAttribute('src');byId('extensionPanel').hidden=true;}; window.addEventListener('message',event=>{if(!activeExtension||event.source!==byId('extensionFrame').contentWindow)return; const request=event.data;if(!request||request.type!=='frame-control:request'||!Number.isSafeInteger(request.id))return; @@ -92,6 +125,6 @@ window.addEventListener('message',event=>{if(!activeExtension||event.source!==by os:{version:latestStatus?.os?.version||null,build:latestStatus?.os?.build||null}, battery:{percent:latestStatus?.battery?.percent??null},steamvr:!!latestStatus?.services?.steamvr}; else return;event.source.postMessage({type:'frame-control:response',id:request.id,value},'*');}); -refresh();refreshGames();refreshRecord();refreshExtensions(); +refresh();refreshGames();refreshRecord();refreshExtensions();refreshDecky(); setInterval(()=>{if(!document.hidden){refresh();refreshRecord();}},15000); diff --git a/ui/index.html b/ui/index.html index da45cc7..b481df0 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1453,9 +1453,15 @@

Experimental. Decky's ARM64 backend can start on Frame OS, but its plugin menu has not been verified in the VR shell. Some Decky plugins need root and will not work with this user service.

Checking…
+
Install and update download Decky's official ARM64 release and verify its published SHA-256. Frame Control uses a removable user service and does not change the root filesystem. Remove keeps plugin files.
+

Official plugin store

+

Plugins run code as your Frame user. Store installs are opt-in and only work with the managed loader. Root plugins and x86-only archives are blocked. A loaded backend does not prove its VR page works.

+ +
+

Frame Control in VR

@@ -2175,14 +2181,62 @@ $('extensionClose').onclick = () => { $('extensionPanel').hidden = true; }; let deckyInfo = null; +let deckyCatalog = []; async function loadDecky() { try { const d = deckyInfo = await api('/api/decky'); $('deckyState').textContent = `${d.frameOS || 'Unknown'} ${d.variant || ''} · ${d.arch} · CEF ${d.cef ? 'ready' : 'unavailable'} · ${d.managed ? `managed Decky ${d.version || ''} ${d.running ? 'running' : 'stopped'}` : d.upstreamInstall ? 'upstream Decky installation found' : 'Decky not installed'}`; const plugins = [...d.plugins.map(x => `${x.name} (managed)`), ...d.upstreamPlugins.map(x => `${x.name} (upstream)`)]; $('deckyPlugins').textContent = plugins.length ? `Installed plugins: ${plugins.join(', ')}` : 'No Decky plugins found.'; + const managedList=$('deckyManagedList');managedList.replaceChildren(); + for(const plugin of d.plugins||[]){if(!plugin.storeId)continue; + const row=document.createElement('div');row.className='hint'; + const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''}`; + const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove plugin'; + remove.disabled=!d.running||!$('deckyAck').checked; + remove.onclick=()=>changeDeckyPlugin('remove',{id:plugin.storeId,name:plugin.name}); + row.append(label,remove);managedList.append(row);} deckyButtons(); - } catch (error) { deckyInfo = null; $('deckyState').textContent = error.message; deckyButtons(); } + showDeckyStore(); + } catch (error) { deckyInfo = null; $('deckyState').textContent = error.message; deckyButtons(); showDeckyStore(); } +} +async function loadDeckyStore() { + $('deckyStoreState').textContent = 'Loading the official store…'; + try { deckyCatalog = (await api('/api/decky/store')).plugins; + $('deckyStoreState').textContent = `${deckyCatalog.length} plugins in the official store. Select a plugin to install it.`; + showDeckyStore(); + } catch(error) { $('deckyStoreState').textContent = error.message; } +} +function showDeckyStore() { + const list = $('deckyStoreList'); list.replaceChildren(); + const query = $('deckyStoreSearch').value.trim().toLowerCase(); + const installed = [...(deckyInfo?.plugins||[]), ...(deckyInfo?.upstreamPlugins||[])]; + for (const plugin of deckyCatalog.filter(p => !query || `${p.name} ${p.description}`.toLowerCase().includes(query)).slice(0, 40)) { + const row = document.createElement('div'); row.className = 'hint'; + const title = document.createElement('strong'); title.textContent = `${plugin.name} · ${plugin.version}`; + const description = document.createElement('span'); description.textContent = ` ${plugin.description}`; + const entry = installed.find(p => p.storeId === plugin.id); + const unmanaged = installed.some(p => p.name === plugin.name && p.storeId !== plugin.id); + const reason = plugin.root ? 'Needs root; not supported by the Frame user service.' : unmanaged ? 'Existing plugin is not managed by Frame Control.' : ''; + row.append(title, description); + if (reason) { const note=document.createElement('small'); note.textContent=` ${reason}`; row.append(note); } + else { + const button=document.createElement('button'); button.className='small'; + button.textContent=entry ? (entry.version===plugin.version?'Reinstall':'Update') : 'Install'; + button.disabled=!deckyInfo?.managed || !deckyInfo?.running || !$('deckyAck').checked; + button.onclick=()=>changeDeckyPlugin(entry?'update':'install',plugin); + row.append(button); + if(entry){const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove'; + remove.disabled=!deckyInfo?.managed || !deckyInfo?.running || !$('deckyAck').checked; + remove.onclick=()=>changeDeckyPlugin('remove',plugin);row.append(remove);} + } + list.append(row); + } +} +async function changeDeckyPlugin(action, plugin) { + if (!$('deckyAck').checked || !confirm(`${action} ${plugin.name}? This official store plugin runs code as your Frame user.`)) return; + try { await runJob(`${action} ${plugin.name}`, 'decky-plugin', () => api('/api/decky/plugin', {action,id:plugin.id,acknowledged:true})); + await loadDecky(); showDeckyStore(); } catch(error) { $('deckyStoreState').textContent=error.message; } } function deckyButtons() { const ready = !!deckyInfo && deckyInfo.arch === 'aarch64' && deckyInfo.variant === 'vr' && deckyInfo.cef && !deckyInfo.upstreamInstall; @@ -2191,8 +2245,11 @@ function deckyButtons() { $('deckyUpdate').disabled = !ack || !ready || !deckyInfo.managed; $('deckyRemove').disabled = !ack || !deckyInfo?.managed; } -$('deckyAck').onchange = deckyButtons; +$('deckyAck').onchange = () => { deckyButtons(); showDeckyStore(); + for(const button of $('deckyManagedList').querySelectorAll('button'))button.disabled=!$('deckyAck').checked||!deckyInfo?.running; }; $('deckyRefresh').onclick = loadDecky; +$('deckyStoreRefresh').onclick = loadDeckyStore; +$('deckyStoreSearch').oninput = showDeckyStore; for (const action of ['Install', 'Update', 'Remove']) { $(`decky${action}`).onclick = async () => { if (!$('deckyAck').checked || !deckyInfo) return; diff --git a/ui/server.py b/ui/server.py index 09f2f83..fda2764 100755 --- a/ui/server.py +++ b/ui/server.py @@ -80,6 +80,7 @@ import frame_wired_doctor # noqa: E402 import frame_compat_db # noqa: E402 import frame_extensions # noqa: E402 import frame_decky # noqa: E402 +import frame_decky_store # noqa: E402 import frame_headset # noqa: E402 frame_host.trust_bundled_cas() @@ -561,6 +562,17 @@ def decky(body): return start_job("Decky Loader " + action, lambda: frame_decky.change(ssh, action)) +def decky_plugin(body): + action = body.get('action') + plugin_id = body.get('id') + if action not in ('install', 'update', 'remove') or type(plugin_id) is not int: + raise Failure('Choose an official store plugin and action', 400) + if body.get('acknowledged') is not True: + raise Failure('Confirm that store plugins run code as your Frame user', 400) + return start_job('Decky plugin ' + action, + lambda: frame_decky_store.change(ssh, action, plugin_id)) + + def headset_action(body): if LOCAL: raise Failure("Manage the headset panel from Frame Control on your computer", 400) @@ -3397,6 +3409,7 @@ POST = { "/api/power": power_action, "/api/android/backup": android_backup, "/api/backup": backup_action, "/api/decky": decky, + "/api/decky/plugin": decky_plugin, "/api/headset": headset_action, "/api/headset/quick": headset_quick, "/api/vr": vr, @@ -3604,6 +3617,8 @@ class Handler(BaseHTTPRequestHandler): self.send_json(frame_extensions.list_extensions()) elif path == "/api/decky": self.send_json(frame_decky.status(ssh)) + elif path == "/api/decky/store": + self.send_json({'plugins': frame_decky_store.catalog()}) elif path == "/api/headset": if LOCAL: raise Failure("Manage this panel from Frame Control on your computer", 400) From da34d940096746fcfdc0f0a2ca0134bf1823ca06 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 25 Oct 2026 06:19:38 +0000 Subject: [PATCH 3/5] Skip backup paths with symlinked ancestors (#215) Co-authored-by: SAPHID --- CHANGELOG.md | 6 +++ docs/backup-and-recovery.md | 25 ++++++++- tests/test_backup.py | 102 ++++++++++++++++++++++++++++++++++++ ui/frame_backup_remote.py | 77 +++++++++++++++++++++------ 4 files changed, 191 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e71ed..e63b57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Skip backup saves and settings reached through a symbolic link anywhere below + the home folder, and recheck paths before packing each file. Local synthetic + tests cover redirected Proton prefixes and links changed after discovery; + concurrent filesystem changes can still race the checks. + [Backup layout and limits](docs/backup-and-recovery.md). + - Import a base APK with supported native-library or asset splits from a local Android backup or XAPK/APKS/APKM bundle. Check package and version before install; refuse resource, code and feature splits and device-variant sets. diff --git a/docs/backup-and-recovery.md b/docs/backup-and-recovery.md index 1191909..51bc000 100644 --- a/docs/backup-and-recovery.md +++ b/docs/backup-and-recovery.md @@ -100,7 +100,21 @@ copy one by hand if you need it. - Steam Cloud's own folders (`userdata//`), shader caches and screenshots (**Tools → Capture & Live** saves those to Pictures). - Android apps themselves, their OBB files and shared storage (`/sdcard`). -- Symbolic links (never followed), and single files over 2 GiB. +- Symbolic links, including any ancestor below the home folder of a selected + save or settings path, and single files over 2 GiB. Discovery checks each + component, and packing checks it again before opening each file. + +Steam backups use the normal `~/.local/share/Steam` directory tree. A separate +`~/.steam/steam` alias does not affect this route. A library, Proton prefix or +Wine save folder redirected by a symlink below the home folder is skipped, +even if its target is inside home; it is not resolved and backed up elsewhere. +The home folder itself is trusted and may use the host's normal path aliases. + +These are path checks, not a filesystem snapshot: a concurrent rename or link +replacement between a check and a read can still race it. Close games and +avoid moving or relinking these folders during backup. This fix rejects links +already present when checked, including ones introduced after discovery; it +does not provide atomic protection against concurrent filesystem changes. Backups aren't encrypted. Game folders can still hold a launcher's login, and Android app data can hold an app's, so keep backups private. @@ -192,11 +206,18 @@ If the button can't help, or the headset isn't reachable: `tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`) for real against a folder laid out like the Frame's home, with only SSH faked: which paths are picked, that sign-in files and keys never are (also inside game -folders), links never followed, the manifest and its checksums, restore checks +folders), links in save/settings roots and their ancestors refused, the manifest +and its checksums, restore checks after a reset (game not installed, no Proton folders yet, not signed in), that a restore replaces only its own files and keeps the old ones, unsafe archives (`..`, absolute paths, links, other games' folders) writing nothing, backup names that try to leave the backups folder, and the exact Game Mode command. +Synthetic symlink fixtures cover every component of a Proton save root below +home, including `compatdata/` and `steamuser/AppData`, plus directory +and file links introduced between discovery and packing. Ordinary Proton and +non-Steam saves, Linux saves, controller layouts, reference settings and home +aliases still pack with their original bytes. These are local fixture checks; +this path fix has not been tested on a headset. `tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through the server, deletes it and restores it. Neither involves a real headset. diff --git a/tests/test_backup.py b/tests/test_backup.py index 131a840..230d5ed 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -113,6 +113,108 @@ class Remote(unittest.TestCase): os.symlink(str(outside), str(base / 'linked-file')) self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f]) + def packed(self, part): + out = io.BytesIO() + remote.pack(part, out) + out.seek(0) + with tarfile.open(fileobj=out, mode='r:gz') as archive: + return {m.name: archive.extractfile(m).read() for m in archive} + + @unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows') + def test_backup_rejects_every_ancestor_of_a_save_root(self): + save = USER % 1234 + '/AppData/Roaming/Game/fixture.dat' + # Each component below HOME, including the selected root itself. + components = (USER % 1234 + '/AppData/Roaming').split('/') + for end in range(1, len(components) + 1): + ancestor = '/'.join(components[:end]) + with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / 'home' + manifest(home, 1234, 'Synthetic game') + path = write(home, save, b'synthetic fixture') + linked = home / ancestor + outside = Path(tmp) / 'outside/.ssh' + outside.parent.mkdir() + linked.rename(outside) + linked.symlink_to(outside, target_is_directory=True) + # Only synthetic bytes: the lexical archive path hides .ssh. + self.assertEqual(path.read_bytes(), b'synthetic fixture') + with patch.object(remote, 'HOME', home): + self.assertEqual(self.files('saves:1234'), []) + self.assertNotIn('saves:1234', [p['id'] for p in remote.plan()['parts']]) + self.assertEqual(self.packed('saves:1234'), {}) + + @unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows') + def test_pack_rechecks_links_changed_after_discovery(self): + save = USER % 1234 + '/Documents/Game/fixture.dat' + for ancestor in (remote.COMPAT + '/1234', USER % 1234 + '/Documents', save): + with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / 'home' + write(home, save, b'original save') + linked = home / ancestor + outside = Path(tmp) / 'outside/.ssh' + outside.parent.mkdir() + part_files = remote.part_files + + def discover_then_link(part): + result = part_files(part) + self.assertEqual(result[0], [(save, len(b'original save'))]) + linked.rename(outside) + linked.symlink_to(outside, target_is_directory=outside.is_dir()) + (home / save).write_bytes(b'synthetic redirected bytes') + return result + + with patch.object(remote, 'HOME', home), \ + patch.object(remote, 'part_files', side_effect=discover_then_link): + self.assertEqual(self.packed('saves:1234'), {}) + + def test_legitimate_steam_layout_and_reference_files_still_pack(self): + frame_home(self.home) + manifest(self.home, 2800000002, 'Synthetic non-Steam game') + save = USER % 2800000002 + '/Saved Games/fixture.dat' + write(self.home, save, b'non-Steam save') + controller = STEAM + '/steamapps/common/Steam Controller Configs/555/config/layout.vdf' + write(self.home, controller, b'controller layout') + for part in ('saves:1234', 'saves:2800000002', 'saves:linux', + 'settings:steam', 'settings:system'): + with self.subTest(part=part): + expected = {rel: (self.home / rel).read_bytes() for rel in self.files(part)} + self.assertTrue(expected) + self.assertEqual(self.packed(part), expected) + self.assertEqual(self.packed('saves:2800000002'), {save: b'non-Steam save'}) + self.assertIn(controller, self.packed('settings:steam')) + + @unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows') + def test_settings_discovery_and_manifest_reads_reject_links(self): + frame_home(self.home) + for ancestor, part in (('.config', 'settings:system'), + (STEAM + '/userdata', 'settings:steam')): + with self.subTest(ancestor=ancestor): + linked = self.home / ancestor + moved = linked.with_name(linked.name + '-original') + linked.rename(moved) + linked.symlink_to(moved, target_is_directory=True) + self.assertEqual(self.files(part), []) + self.assertEqual(self.packed(part), {}) + if part == 'settings:steam': + self.assertEqual(remote.accounts(), []) + appmanifest = self.home / STEAM / 'steamapps/appmanifest_1234.acf' + moved = appmanifest.with_suffix('.original') + appmanifest.rename(moved) + appmanifest.symlink_to(moved) + with patch.object(Path, 'read_text', side_effect=AssertionError('must not read a link')): + self.assertIsNone(remote.game_name('1234')) + + @unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows') + def test_home_alias_and_unused_steam_alias_preserve_normal_backups(self): + frame_home(self.home) + alias = self.home / 'home-alias' + alias.symlink_to(self.home, target_is_directory=True) + (self.home / '.steam/steam').symlink_to(self.home / STEAM, target_is_directory=True) + expected = self.packed('saves:1234') + with patch.object(remote, 'HOME', alias): + self.assertEqual(self.packed('saves:1234'), expected) + self.assertEqual(remote.game_name('1234'), 'Test Game') + def test_big_files_are_listed_as_skipped(self): frame_home(self.home) with patch.object(remote, 'MAX_FILE', 3): diff --git a/ui/frame_backup_remote.py b/ui/frame_backup_remote.py index df6c8f4..06ea07c 100644 --- a/ui/frame_backup_remote.py +++ b/ui/frame_backup_remote.py @@ -19,6 +19,7 @@ import os from pathlib import Path, PurePosixPath import re import shutil +import stat import subprocess import sys import tarfile @@ -88,28 +89,57 @@ def _under(rel, root): return rel == root or rel.startswith(root + '/') +def _backup_stat(rel): + """lstat a backup path only if every component below trusted HOME is link-free. + + Rechecked at discovery and before reads; not atomic against concurrent renames. + HOME itself may use the host's normal home/temp-directory aliases. + """ + if not safe_rel(rel): + return None + path = HOME + try: + for component in rel.split('/'): + path = path / component + st = path.lstat() + if stat.S_ISLNK(st.st_mode): + return None + return st + except OSError: + return None + + +def _backup_dir(rel): + st = _backup_stat(rel) + return st is not None and stat.S_ISDIR(st.st_mode) + + def _walk(rel_root, skip=()): """Regular files under HOME/rel_root, never following links: [(rel, size)], [skipped].""" files, skipped = [], [] base = HOME / rel_root - if base.is_symlink() or not base.is_dir(): - if base.is_file() and not base.is_symlink(): - return [(rel_root, base.stat().st_size)], skipped + st = _backup_stat(rel_root) + if st is None or is_secret(rel_root): + return files, skipped + if stat.S_ISREG(st.st_mode): + if st.st_size > MAX_FILE: + skipped.append({'path': rel_root, 'reason': 'over 2 GiB'}) + return files, skipped + return [(rel_root, st.st_size)], skipped + if not stat.S_ISDIR(st.st_mode): return files, skipped for top, dirs, names in os.walk(str(base)): rel_top = PurePosixPath(rel_root, *Path(top).relative_to(base).parts).as_posix() - dirs[:] = sorted(d for d in dirs if not os.path.islink(os.path.join(top, d)) and + if not _backup_dir(rel_top): + dirs[:] = [] + continue + dirs[:] = sorted(d for d in dirs if _backup_dir(rel_top + '/' + d) and not any(_under(rel_top + '/' + d, s) for s in skip) and not is_secret(rel_top + '/' + d + '/x')) for name in sorted(names): - path, rel = os.path.join(top, name), rel_top + '/' + name - if os.path.islink(path) or is_secret(rel): - continue - try: - st = os.lstat(path) - except OSError: - continue - if not os.path.isfile(path): + rel = rel_top + '/' + name + st = _backup_stat(rel) + if st is None or not stat.S_ISREG(st.st_mode) or is_secret(rel): continue if st.st_size > MAX_FILE: skipped.append({'path': rel, 'reason': 'over 2 GiB'}) @@ -124,26 +154,34 @@ def _manifest_field(text, key): def game_name(appid): + rel = '%s/steamapps/appmanifest_%s.acf' % (STEAM, appid) + st = _backup_stat(rel) + if st is None or not stat.S_ISREG(st.st_mode): + return None try: - text = (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).read_text(errors='replace') + text = (HOME / rel).read_text(errors='replace') except OSError: return None return _manifest_field(text, 'name') def accounts(): - root = HOME / STEAM / 'userdata' + rel = STEAM + '/userdata' + if not _backup_dir(rel): + return [] try: - return sorted(p.name for p in root.iterdir() if p.name.isdigit() and p.is_dir() - and not p.is_symlink() and p.name != '0') + return sorted(p.name for p in (HOME / rel).iterdir() if p.name.isdigit() and + p.name != '0' and _backup_dir(rel + '/' + p.name)) except OSError: return [] def _prefixes(): + if not _backup_dir(COMPAT): + return [] try: return sorted((p.name for p in (HOME / COMPAT).iterdir() - if p.name.isdigit() and (p / PREFIX_USER).is_dir()), key=int) + if p.name.isdigit() and _backup_dir(COMPAT + '/' + p.name + '/' + PREFIX_USER)), key=int) except OSError: return [] @@ -244,9 +282,14 @@ def pack(part, output): with tarfile.open(fileobj=output, mode='w|gz') as archive: for rel, _ in files: path = HOME / rel + st = _backup_stat(rel) + if st is None or not stat.S_ISREG(st.st_mode) or is_secret(rel): + continue try: with open(str(path), 'rb') as src: st = os.fstat(src.fileno()) + if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_FILE: + continue member = tarfile.TarInfo(rel) member.size, member.mtime, member.mode = st.st_size, int(st.st_mtime), st.st_mode & 0o755 archive.addfile(member, src) From 4e07409627e14c6418a6dd9d6d5d5d5bc1c1306d Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 25 Oct 2026 06:29:47 +0000 Subject: [PATCH 4/5] Document Steam Frame plugin research and verification limits (#210) * Record Steam Frame plugin research and fresh read-only evidence * Update Decky research for official store audit --------- Co-authored-by: SAPHID --- docs/decky-frame.md | 3 + docs/evidence/plugins-2026-10-25.json | 43 ++++++++ docs/plugins-research.md | 141 ++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 docs/evidence/plugins-2026-10-25.json create mode 100644 docs/plugins-research.md diff --git a/docs/decky-frame.md b/docs/decky-frame.md index ec540b8..ba4392f 100644 --- a/docs/decky-frame.md +++ b/docs/decky-frame.md @@ -1,5 +1,8 @@ # Decky Loader on Steam Frame +For the modding-path survey, licence review and update limits, see +[plugin and modding research](plugins-research.md). + **Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS 0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in JavaScript. A usable plugin menu in the Frame's VR shell has **not** been diff --git a/docs/evidence/plugins-2026-10-25.json b/docs/evidence/plugins-2026-10-25.json new file mode 100644 index 0000000..50e3529 --- /dev/null +++ b/docs/evidence/plugins-2026-10-25.json @@ -0,0 +1,43 @@ +{ + "checkedAt": "2026-10-25T06:03:45.387452+00:00", + "method": "Read-only Python probe over the existing frame SSH alias; no device changes", + "baselineCommit": "d79efb4730becc781f9a641cc36cb27f6481667c", + "deckyStatus": { + "arch": "aarch64", + "frameOS": "0.5.6", + "variant": "vr", + "build": "20261022.6153909", + "cef": true, + "managed": false, + "running": false, + "version": null, + "upstreamInstall": false, + "plugins": [], + "upstreamPlugins": [] + }, + "platform": { + "userServiceManager": true, + "rootMountOptions": "rw,relatime,ssd,discard=async,space_cache=v2,subvolid=5,subvol=/", + "homeMountOptions": "rw,relatime", + "homebrewExists": false, + "cefMarkerExists": false, + "cefTargetTitles": [ + "SteamVR - valve.steam.gamepadui.frame.menu.53930005 (53930005)", + "SteamVR - valve.steam.gamepadui.bar (53930000)", + "SteamVR", + "SteamVR - Notifications", + "SteamVR - valve.steam.gamepadui.floatingfooter (53930004)", + "SteamVR - valve.steam.gamepadui.volumelevel (53930003)", + "SteamVR - valve.steam.gamepadui.tooltip.53930002 (53930002)", + "SteamVR - valve.steam.gamepadui.barpopup.53930001 (53930001)", + "SteamVR - Keyboard", + "data:text/html,<body><%2Fbody>", + "SharedJSContext" + ] + }, + "limits": [ + "No Decky install/update/uninstall repeated in this retry", + "No OS update or reboot performed", + "No wearer-visible menu or controller input verified" + ] +} diff --git a/docs/plugins-research.md b/docs/plugins-research.md new file mode 100644 index 0000000..ff02742 --- /dev/null +++ b/docs/plugins-research.md @@ -0,0 +1,141 @@ +# Plugin and modding research + +Checked **2026-10-25**. Decision: keep the experimental, reversible Decky manager +and ship Frame Control's own sandboxed panel API as the supported way to add +local features. Both implementations already landed in the replacement job +while the original job was unavailable. This report consolidates their evidence +and the original research questions; it does not claim another loader was built. + +## Can Decky run on Frame? + +**Its ARM64 backend and Steam shared-context injection work on the tested build. +A usable Decky menu inside VR remains unverified.** Do not equate an active +service or a JavaScript plugin registration with a working headset interface. + +| Requirement | Evidence | Consequence | +| --- | --- | --- | +| ARM64 Linux binary | Upstream [3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0) has `PluginLoader-arm64`; Frame reports `aarch64` | ARM is not itself a blocker. Individual plugins may still ship x86 binaries or assume Deck hardware. | +| Steam Gamepad UI and CEF | Loopback `127.0.0.1:8080/json/list` exposes `SharedJSContext` plus distinct SteamVR pages | The shared context exists; the VR shell is a separate integration target. | +| CEF enable marker | `~/.steam/steam/.cef-enable-remote-debugging` is absent, but CEF already answers | No marker or Steam launch flag change is needed on this build. | +| User service manager | `systemctl --user show-environment` succeeds | A per-user service works without installing an upstream root service. The manager is degraded by a stale failed Decky unit, although its unit file is gone. | +| Filesystem | `/` currently mounts `rw` on Btrfs, `/home` mounts `rw` | This is an observation of this device, not a stock-image guarantee. Do not disable SteamOS read-only protection or install packages in `/usr`. | +| Homebrew folder | `~/homebrew` is absent | It is an installer convention, not a preinstalled SteamOS plugin API. Frame Control uses a separate managed directory. | +| Visible menu | Earlier tests registered Decky's tab only in shared JavaScript; SteamVR had no Decky overlay; headset was in standby | QAM visibility, controller input and a real plugin's visible result remain unverified. | + +The fresh [read-only probe](evidence/plugins-2026-10-25.json) records SteamOS +`0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`. It confirms CEF, architecture, +mounts, user-service availability, and absence of a running managed or upstream +Decky installation. This retry did not install anything or restart Steam. + +The replacement job's initial [test record](decky-frame.md) reports: + +- Decky 3.3.0 started as the normal `steamos` user and injected into + `SharedJSContext`. +- Install → status → update → status → remove → status succeeded on the Frame. + Removal left the unit, binary and port 1337 absent, while preserving user data. +- ProtonDB Badges 1.1.0 loaded as a passive plugin and appeared in Decky's + shared-context registry. Its badge or menu was not seen inside VR. +- Injected frontend code can outlive backend removal until Steam's web helper + restarts. An earlier helper-restart test cleared the globals; this retry did + not repeat it. + +These are prior documented results, separate from the fresh read-only probe. +The coordinator then ran a separate, locked +[official-store compatibility audit](decky-frame.md#official-store-plugin-audit-2026-10-25) +on the same Frame OS build. Seven plugins logged backend loads. A managed +ProtonDB Badges 1.3.4 store install, update and remove also succeeded. The +headset stayed unworn, so these results still do not establish VR UI use. + +## Installer and update choice + +The upstream [installer at e42a3c8](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh) +escalates through sudo, creates `~/homebrew/plugins` and `services`, writes the +CEF marker and a system unit under `/etc/systemd/system`, and starts Decky as +root. Its architecture switch supports `aarch64|arm64`. Running this installer +is unnecessary for a compatibility check and grants more access than a panel +extension needs. + +Frame Control's [manager](../ui/frame_decky.py) instead downloads the official +ARM64 asset, checks GitHub's published SHA-256 and the ELF machine, and writes +only under `~/.local/share/frame-control/decky` and `~/.config/systemd/user`. +It refuses an upstream system-service installation, requires active CEF before +install/update, and restores the previous binary, receipt and unit if applying +an update fails. Existing tests exercise that rollback with a failed service +start. These checks do not prove every possible OS or Steam update is safe. + +The binary is downloaded on demand, not bundled. Plugin discovery lists the +managed directory and upstream `~/homebrew/plugins`. The later +[store manager](../ui/frame_decky_store.py) exposes official entries in the PC +app and opt-in headset panel. It verifies archive digests, blocks root and +non-ARM64 binaries, and changes only its own plugin folders. That is file and +backend support, not a working VR plugin page. Decky plugins can execute +backend code and are not isolated by Frame Control's HTML extension sandbox. + +Home-directory state is expected to survive SteamOS image updates, based on +SteamOS's layout; **an actual OS update and reboot persistence test has not been +performed**. Steam frontend changes, CEF availability, libc requirements and +plugin hardware assumptions can still break Decky. On failure, remove the user +service using [the documented removal command](decky-frame.md#opt-in-and-remove); +its removal path does not require a working CEF target. Test the sequence again +on each supported OS build before expanding support claims. + +## Other paths + +| Path | What it enables | Current limits and choice | +| --- | --- | --- | +| Decky plugins | Steam frontend hooks, optional Python backends | Seven official-store plugins logged backend loads on ARM64. Store install/update/remove worked for ProtonDB Badges. VR pages and controller input remain unverified. Keep experimental. | +| [CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader) / [DeckThemes](https://docs.deckthemes.com/) | Stylesheets applied to Steam UI | CSS Loader 2.1.3 backend loaded on Frame. No theme was applied or visually tested. Deck themes do not establish compatibility with Frame's separate VR shell. | +| User home / SteamOS homebrew | User services, user Flatpaks, files and helpers | Use owned folders and explicit uninstall. A folder named `homebrew` supplies no discovery or security boundary by itself. No rootfs changes needed for the chosen path. | +| Android / Lepton | Sideloaded apps, game-specific content and per-app settings | Frame Control already manages APKs and ADB. Lepton runs Android containers, not Steam's CEF UI; an Android mod does not add a Steam dashboard tab. No new Android mod was tested here. See [APKs](apks.md), [VR APKs](vr-apks.md), and [game mods](mods.md). | +| Frame Control extensions | Third-party HTML/CSS/JS panels with a small read-only bridge | Shipped in [#194](https://github.com/saphid/frame-control/pull/194), with a manifest, local folder, sandbox, example and [author guide](extensions.md). No Python imports, arbitrary SSH commands or write actions are exposed. | +| Frame Control's headset panel | Runs the local tools and extensions as a SteamVR panel | Shipped in [#198](https://github.com/saphid/frame-control/pull/198), expanded in [#201](https://github.com/saphid/frame-control/pull/201). Page/API execution and uninstall were tested; wearer-visible pixels and controller interaction remain unverified. See [test record](headset-app.md). | + +The extension iframe has `sandbox="allow-scripts"`, an opaque origin, restrictive +CSP and a `postMessage` bridge accepting only `capabilities` and a reduced +`status` snapshot from the active frame. It is useful for local dashboards and +small tools today. It cannot replace the full Decky API. Third-party code still +needs review: an iframe may navigate itself externally, and local installation +does not establish publisher trust. + +Extensions are loaded only when opened and are removed by closing their panel +and deleting their own directory. They modify no SteamOS files. The +[compatibility follow-up](https://github.com/saphid/frame-control/pull/209) adds +manifest `apiVersion` validation during discovery and asset serving, with +existing manifests defaulting to version 1. It rejects incompatible panels +even when requested through a direct asset URL. + +## Licences: separate download versus bundling + +Decky's [licence](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/LICENSE) +is GPL version 2. Frame Control is MIT-licensed. The integration contains its +own service-management code and downloads the upstream executable as a separate +program. It does not incorporate Decky source or ship its binary in Frame +Control installers. Keep the upstream licence/source links visible to users +who opt in; installing a separate GPL program does not by itself relicense +Frame Control's independent code. + +If a future release bundles or mirrors Decky binaries, the distributor must +preserve copyright/licence notices and satisfy GPLv2's corresponding-source +requirements for the exact binary, including modifications and build scripts +as applicable. A link to an evolving default branch is not a substitute for +meeting those distribution obligations. Mere aggregation and deriving or +linking a combined work are different questions; reassess the boundary if +Decky code is incorporated. Each plugin, theme and bundled dependency has its +own licence and assets to check independently. The existing +[third-party notices](../THIRD_PARTY_NOTICES.md) describe software already +distributed with Frame Control. + +## Next checks + +1. On a worn Frame, establish an accessible Decky menu or implement an adapter + for the VR shell. Verify a plugin's visible result and controller navigation + before describing store-installed plugins as usable inside VR. +2. Verify the Frame Control panel and example extension visually, including + opening, refreshing and closing with the controllers. +3. Test a normal Steam restart, reboot and SteamOS update with a disposable + managed install; confirm removal still works with CEF unavailable. Retain + the experimental label until those checks pass. + +The release coordinator owns those ongoing headset-integration checks. This +retry leaves the headset untouched and hands over the research and compatibility +follow-up instead of running concurrent loader experiments. From d5fa8feb3153b130d9d22d209c7854eb0443999f Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 25 Oct 2026 06:31:45 +0000 Subject: [PATCH 5/5] Fix orphan smoke cleanup and portable smoke wrapper (#216) * Fix orphan smoke cleanup and portable smoke wrapper * Resolve smoke wrapper fixture working directory on macOS --------- Co-authored-by: SAPHID --- CHANGELOG.md | 6 ++ docs/testing.md | 25 +++++- scripts/frame-smoke.sh | 7 +- tests/smoke/frame_smoke.py | 18 ++-- tests/test_smoke_cleanup.py | 170 ++++++++++++++++++++++++++++++++++-- 5 files changed, 202 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e63b57d..deacb09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Fix smoke cleanup skipping orphan metadata and Steam shortcuts after a test + title directory was already removed. Sweep and verify all six exact reserved + smoke IDs; preserve similarly named titles. Make `scripts/frame-smoke.sh` + work with POSIX sh and Python 3 without zsh, including from another working + directory. Local regressions cover both fixes; no new headset run. + - Skip backup saves and settings reached through a symbolic link anywhere below the home folder, and recheck paths before packing each file. Local synthetic tests cover redirected Proton prefixes and links changed after discovery; diff --git a/docs/testing.md b/docs/testing.md index ca54bdb..434b919 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -188,6 +188,11 @@ scripts/frame-smoke.sh # needs `ssh frame` to work without a password scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset ``` +The wrapper uses POSIX `sh` and Python 3 on macOS or Linux; zsh is not required. +It resolves the checkout path independently of the current working directory. +`/path/to/checkout/scripts/frame-smoke.sh --help` is safe to run offline and +does not contact the headset. + It checks `properties.json` and the status, then installs, launches and removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py` (an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and @@ -208,6 +213,14 @@ isn't reachable. Smoke process cleanup runs before and after title removal and during the initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`, `fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents. +Each leftover sweep checks all six IDs, even if no title directory or install +record remains. It removes their exact `-argv.json`, `-env.json`, +`-settings.json` and `-framecontrol.json` files, syncs Steam's shortcuts, then +syncs again to verify that none of those IDs is still registered. Missing +devkit utilities are copied before syncing and removed at the end if they +were absent before the run. Metadata or shortcuts that remain, or a reported +sync failure, fail cleanup. Similarly named titles and their metadata are +outside the deletion boundary. On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and an exact title directory in `/proc` cwd/exe or an individual absolute command argument; similarly named folders and shell command substrings do not count. @@ -231,12 +244,16 @@ The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its remove step stopped five test-owned wrapper/Proton PIDs before removing the title; the following process sweep found none. The final cleanup found no test titles or processes, the test desktop was closed, and the battery -remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run -on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran -the same entry point instead. +remained at 99%. That historical run used the Python entry point directly; +the current POSIX wrapper has local offline coverage on a PATH without zsh. +The orphan-metadata and shortcut follow-up has not been rerun on a headset. The local regression suite runs real Linux processes in temporary fake title directories (including deleted folders and a TERM-resistant wrapper); it checks -exit, escalation and survival of unrelated processes without SSH or a headset: +exit, escalation and survival of unrelated processes. Local filesystem commands +with mocked SSH/Steam also cover orphan metadata, shortcut-only leftovers, +sync failures and exact ID boundaries. Wrapper tests check offline help, +argument forwarding and exit status from another working directory. None of +these local tests contacts a headset: ```sh python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v diff --git a/scripts/frame-smoke.sh b/scripts/frame-smoke.sh index 8d8872a..0151366 100755 --- a/scripts/frame-smoke.sh +++ b/scripts/frame-smoke.sh @@ -1,8 +1,9 @@ -#!/usr/bin/env zsh +#!/bin/sh # Mac or Linux: the headset smoke test. Installs, launches and removes tiny # test titles on the Frame (the `frame` alias) and records the results with # its BUILD_ID under tests/smoke/results/. See docs/testing.md. # # Usage: scripts/frame-smoke.sh [--pair] (--pair needs you in the headset to approve) -set -euo pipefail -exec python3 "${0:A:h:h}/tests/smoke/frame_smoke.py" "$@" +set -eu +smoke_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd -P) +exec python3 "$smoke_root/tests/smoke/frame_smoke.py" "$@" diff --git a/tests/smoke/frame_smoke.py b/tests/smoke/frame_smoke.py index dea89f5..a11b0f9 100644 --- a/tests/smoke/frame_smoke.py +++ b/tests/smoke/frame_smoke.py @@ -203,23 +203,23 @@ def cleanup(smoke): never removed. Raises if any owned process, file or shortcut stays. """ problems = [] - try: - ids = {t['id'] for t in frame_titles.list_titles() if t['id'] in stop_processes.IDS} - except frame_android.FrameError as e: - ids = set() - problems.append(f'could not list titles: {e}') - ids |= smoke.installed + # Directory listings and this run's install tracker cannot reveal orphan + # metadata or shortcuts. Always sweep the exact, reserved IDs; never let + # arbitrary entries in smoke.installed expand the deletion boundary. + ids = stop_processes.IDS # Scan every reserved ID, even when a prior run removed its folder and # shortcut but left Proton running. Stop before removing any more files. stopped = stop_titles(stop_processes.IDS) gone = set() for gid in sorted(ids): try: + metadata = ' '.join(f'~/devkit-game/{gid}-{k}.json' + for k in ('argv', 'env', 'settings', 'framecontrol')) if ssh(f'test -d ~/devkit-game/{gid} && echo yes || true').strip() == 'yes': frame_titles.remove(gid) # Also when remove() stopped part-way, or only the json files are left. - ssh(f"rm -f {' '.join(f'~/devkit-game/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))}") - if ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip(): + ssh(f'rm -f {metadata}') + if ssh(f'ls -d ~/devkit-game/{gid} {metadata} 2>/dev/null || true').strip(): problems.append(f'{gid} is still on the Frame') else: gone.add(gid) @@ -230,6 +230,8 @@ def cleanup(smoke): # It logs a failed sync and exits 0, so read its log; a second run lists # what Steam still has registered, which must not include ours. try: + # A shortcut-only leftover (or a first run) may have no utilities. + frame_titles.ensure_utils() command = frame_titles.devkit_script('delete') + ' 2>&1' first = ssh(command, timeout=120) second = ssh(command, timeout=120) diff --git a/tests/test_smoke_cleanup.py b/tests/test_smoke_cleanup.py index 5233a56..038e953 100644 --- a/tests/test_smoke_cleanup.py +++ b/tests/test_smoke_cleanup.py @@ -119,15 +119,6 @@ class Safety(unittest.TestCase): with self.assertRaisesRegex(RuntimeError, 'survived cleanup'): stop_processes.stop(['fc_smoke_exe'], grace=0) - def test_cleanup_scans_even_when_title_already_removed(self): - with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[]), \ - mock.patch.object(frame_smoke, 'ssh') as ssh, \ - mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': [42]}) as stop: - result = frame_smoke.cleanup(frame_smoke.Smoke()) - self.assertEqual(stop.call_args_list, [mock.call(stop_processes.IDS)] * 2) - self.assertEqual(result['process_cleanup'][0]['stopped_pids'], [42]) - ssh.assert_not_called() - def test_stop_failure_prevents_file_removal(self): with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[{'id': 'fc_smoke_exe'}]), \ mock.patch.object(frame_smoke, 'ssh') as ssh, \ @@ -151,3 +142,164 @@ class Safety(unittest.TestCase): self.assertEqual(frame_smoke.main(), 1) cycle.assert_not_called() self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1) + + +@unittest.skipUnless(os.name == 'posix', 'local POSIX shell required') +class OrphanCleanup(unittest.TestCase): + """Run the real cleanup shell commands in a throwaway home; mock Steam/SSH.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.home = Path(self.tmp.name) + self.games = self.home / 'devkit-game' + self.games.mkdir() + self.shortcuts = set() + self.sync_outputs = None + self.events = [] + self.smoke = frame_smoke.Smoke() + for target, name, kwargs in ( + (frame_smoke, 'ssh', {'side_effect': self.local_ssh}), + (frame_smoke, 'stop_titles', {'side_effect': self.stop}), + (frame_smoke.frame_titles, 'list_titles', {'return_value': []}), + (frame_smoke.frame_titles, 'remove', {'side_effect': self.remove}), + (frame_smoke.frame_titles, 'ensure_utils', {}), + (frame_smoke.frame_titles, 'devkit_script', {'return_value': 'smoke-sync'})): + patcher = mock.patch.object(target, name, **kwargs) + setattr(self, name + '_mock', patcher.start()) + self.addCleanup(patcher.stop) + + def stop(self, ids): + self.assertEqual(ids, stop_processes.IDS) + self.events.append('stop') + return {'stopped_pids': [42] if len(self.events) == 1 else []} + + def remove(self, gid): + self.assertIn(gid, stop_processes.IDS) + self.events.append('remove') + shutil.rmtree(self.games / gid) + + def local_ssh(self, command, timeout=60): + self.events.append(command) + self.assertEqual(self.events[0], 'stop', 'processes must stop before file cleanup') + if command == 'smoke-sync 2>&1': + if self.sync_outputs is not None: + return self.sync_outputs.pop(0) + # Valve logs the registered IDs before reconciling with disk. + out = '\n'.join(f'Found Devkit Game registered with Steam Client: {gid!r}' + for gid in sorted(self.shortcuts)) + self.shortcuts = {gid for gid in self.shortcuts if (self.games / gid).is_dir()} + return out + return subprocess.run(command, shell=True, check=True, capture_output=True, + text=True, timeout=timeout, + env={**os.environ, 'HOME': str(self.home)}).stdout + + def metadata(self, gid): + paths = [self.games / f'{gid}-{kind}.json' + for kind in ('argv', 'env', 'settings', 'framecontrol')] + for path in paths: + path.write_text('{}') + return paths + + def test_orphan_metadata_for_every_reserved_id_is_removed_and_shortcuts_rechecked(self): + paths = [path for gid in stop_processes.IDS for path in self.metadata(gid)] + self.shortcuts = set(stop_processes.IDS) + result = frame_smoke.cleanup(self.smoke) + self.assertTrue(all(not path.exists() for path in paths)) + self.assertFalse(self.shortcuts) + self.assertEqual(result['removed'], sorted(stop_processes.IDS)) + self.assertEqual(result['process_cleanup'], [{'stopped_pids': [42]}, {'stopped_pids': []}]) + self.assertEqual(self.events[-1], 'stop') + self.assertEqual(self.events.count('smoke-sync 2>&1'), 2) + self.ensure_utils_mock.assert_called_once_with() + self.remove_mock.assert_not_called() + + def test_shortcut_only_leftover_is_synced_without_title_or_install_record(self): + self.shortcuts = {'fc_smoke_exe'} + frame_smoke.cleanup(self.smoke) + self.assertFalse(self.shortcuts) + self.assertEqual(self.events.count('smoke-sync 2>&1'), 2) + + def test_unrelated_titles_and_metadata_survive_even_if_in_install_tracker(self): + others = ('portal2', 'fc_smoke_custom', 'fc_smoke_exe-other', 'fc_smoke_exe_other') + paths = [] + for gid in ('fc_smoke_exe', *others): + (self.games / gid).mkdir() + paths.extend(self.metadata(gid) if gid in others else []) + self.shortcuts = {'fc_smoke_exe', *others} + self.smoke.installed = {'fc_smoke_exe', *others, 'fc_smoke_exe/../portal2'} + frame_smoke.cleanup(self.smoke) + self.assertFalse((self.games / 'fc_smoke_exe').exists()) + self.assertTrue(all((self.games / gid).is_dir() for gid in others)) + self.assertTrue(all(path.exists() for path in paths)) + self.assertEqual(self.shortcuts, set(others)) + self.assertNotIn('fc_smoke_exe', self.smoke.installed) + self.remove_mock.assert_called_once_with('fc_smoke_exe') + + def test_still_registered_current_or_legacy_shortcut_fails_cleanup(self): + for gid in sorted(stop_processes.IDS): + with self.subTest(gid=gid): + self.sync_outputs = ['', f'Found Devkit Game registered with Steam Client: {gid!r}'] + self.smoke.installed = {gid} + with self.assertRaisesRegex(AssertionError, f'{gid} is still registered with Steam'): + frame_smoke.cleanup(self.smoke) + self.assertIn(gid, self.smoke.installed) + + def test_sync_failure_in_either_pass_fails_cleanup(self): + for failed_pass in (0, 1): + with self.subTest(failed_pass=failed_pass): + self.sync_outputs = ['', ''] + self.sync_outputs[failed_pass] = 'Steam client sync of devkit games failed: offline' + self.smoke.installed = {'fc_smoke_exe'} + with self.assertRaisesRegex(AssertionError, 'sync of devkit games failed'): + frame_smoke.cleanup(self.smoke) + self.assertIn('fc_smoke_exe', self.smoke.installed) + + def test_metadata_remaining_after_delete_fails_cleanup(self): + self.metadata('fc_smoke_exe') + real_ssh = self.local_ssh + with mock.patch.object(frame_smoke, 'ssh', side_effect=lambda command, **kwargs: + '' if command.startswith('rm -f ') else real_ssh(command, **kwargs)): + with self.assertRaisesRegex(AssertionError, 'fc_smoke_exe is still on the Frame'): + frame_smoke.cleanup(self.smoke) + + def test_missing_utils_cannot_report_success_without_shortcut_verification(self): + self.ensure_utils_mock.side_effect = frame_smoke.frame_android.FrameError('copy failed') + with self.assertRaisesRegex(AssertionError, "syncing Steam's shortcuts: copy failed"): + frame_smoke.cleanup(self.smoke) + self.assertEqual(self.events[-1], 'stop') + + +@unittest.skipUnless(os.name == 'posix', 'POSIX wrapper') +class PortableWrapper(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='smoke wrapper ') + self.addCleanup(self.tmp.cleanup) + # macOS aliases /var to /private/var; relative commands need the physical cwd. + self.folder = Path(self.tmp.name).resolve() + self.bin = self.folder / 'bin' + self.bin.mkdir() + # No zsh or SSH on PATH: --help must exit before any headset access. + (self.bin / 'dirname').symlink_to(shutil.which('dirname')) + self.env = {**os.environ, 'PATH': str(self.bin), 'CDPATH': str(self.folder)} + self.wrapper = SMOKE.parent.parent / 'scripts' / 'frame-smoke.sh' + + def test_help_runs_directly_and_with_posix_sh_from_another_cwd_without_zsh(self): + (self.bin / 'python3').symlink_to(sys.executable) + for launcher in ([], ['/bin/sh']): + with self.subTest(launcher=launcher): + result = subprocess.run([*launcher, str(self.wrapper), '--help'], cwd=self.folder, + env=self.env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('--pair', result.stdout) + self.assertIn('Headset smoke test', result.stdout) + + def test_relative_invocation_preserves_arguments_and_python_exit_status(self): + python = self.bin / 'python3' + python.write_text('#!/bin/sh\nprintf "<%s>\\n" "$@"\nexit 7\n') + python.chmod(0o755) + result = subprocess.run([os.path.relpath(self.wrapper, self.folder), '--pair', 'two words', ''], + cwd=self.folder, env=self.env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 7, result.stderr) + self.assertEqual(result.stdout.splitlines(), + [f'<{SMOKE / "frame_smoke.py"}>', '<--pair>', '', '<>'])