diff --git a/CHANGELOG.md b/CHANGELOG.md index c9786f4..b23c301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ existing flat exports stay untouched and do not mark unrelated clips saved. [Recording guide](docs/recording.md#steam-clips). +- 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/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/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/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/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/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/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_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/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_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/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_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) 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/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(): 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 d10b7e4..957b172 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) @@ -3401,6 +3413,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, @@ -3608,6 +3621,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)