mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-28 12:01:54 +01:00
Merge remote-tracking branch 'origin/main' into fix/steam-clip-export-collisions
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
a87b746dc1
19 files changed
+872
-27
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -100,7 +100,21 @@ copy one by hand if you need it.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), 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/<appid>` 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.
|
||||
|
||||
|
||||
+56
-2
@@ -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 <name>` 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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('<script src="main.js"></script>', 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()
|
||||
@@ -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()
|
||||
@@ -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('<p>Incompatible panel</p>')
|
||||
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)
|
||||
|
||||
+60
-17
@@ -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)
|
||||
|
||||
+7
-1
@@ -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 = {}
|
||||
|
||||
@@ -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 1024<len(data)<=50*1024*1024 or hashlib.sha256(data).hexdigest()!=digest:
|
||||
raise RuntimeError('Plugin archive digest mismatch on Frame')
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
files=z.infolist()
|
||||
if not files or len(files)>3000 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
|
||||
@@ -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():
|
||||
|
||||
+34
-1
@@ -27,6 +27,10 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
|
||||
<p id="actionStatus" role="status" aria-live="polite"></p>
|
||||
<h2>Running games</h2><div class="row"><button id="gamesRefresh" class="secondary">Refresh games</button></div>
|
||||
<div id="games" class="stack" style="margin-top:12px">Loading…</div>
|
||||
<h2>Decky plugins</h2><p>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.</p>
|
||||
<div class="row"><button id="deckyRefresh" class="secondary">Refresh plugins</button><button id="deckyStoreLoad" class="secondary">Open official store</button></div>
|
||||
<p id="deckyState" role="status"></p><div id="deckyInstalled" class="stack"></div>
|
||||
<div id="deckyStorePanel" hidden><label>Find a plugin <input id="deckySearch" type="search" placeholder="Search Decky store"></label><div id="deckyStoreList" class="stack"></div></div>
|
||||
<h2>Extensions</h2><p>Local additions run only when opened in the isolated panel below. They can read status but cannot change headset settings through the extension API.</p>
|
||||
<div class="row"><button id="extensionsRefresh" class="secondary">Refresh extensions</button><span id="extensionsFolder"><small></small></span></div>
|
||||
<div id="extensions" class="stack" style="margin-top:12px">Loading…</div>
|
||||
@@ -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);
|
||||
</script></body></html>
|
||||
+59
-2
@@ -1453,9 +1453,15 @@
|
||||
<p class="sub">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.</p>
|
||||
<div id="deckyState" class="sub">Checking…</div>
|
||||
<div id="deckyPlugins" class="hint"></div>
|
||||
<div id="deckyManagedList" class="stack"></div>
|
||||
<label class="popt"><input type="checkbox" id="deckyAck"> I understand that the VR menu and individual plugins may not work. Install only if I want to test Decky.</label>
|
||||
<div class="row" style="margin-top:10px"><button class="small action" id="deckyInstall" disabled>Install</button><button class="small" id="deckyUpdate" disabled>Update</button><button class="small danger" id="deckyRemove" disabled>Remove managed Decky</button></div>
|
||||
<div class="hint">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.</div>
|
||||
<h3>Official plugin store</h3>
|
||||
<p class="sub">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.</p>
|
||||
<label>Find a plugin <input id="deckyStoreSearch" type="search" placeholder="Search store"></label>
|
||||
<div class="row"><button class="small" id="deckyStoreRefresh">Load store</button></div>
|
||||
<div id="deckyStoreState" class="hint"></div><div id="deckyStoreList" class="stack"></div>
|
||||
</section>
|
||||
<section class="panel" id="headsetPanel">
|
||||
<div class="shelf-head"><h2>Frame Control in VR</h2><span class="spacer"></span><button class="small" id="headsetRefresh">Refresh</button></div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user