mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 02:01:52 +01:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d6bd463ce | ||
|
|
981173a1f2 | ||
|
|
c6e54041e0 | ||
|
|
91f60c26e3 | ||
|
|
d77e2bd056 | ||
|
|
2a6736a02c | ||
|
|
c8ef81ad93 | ||
|
|
e529d60554 | ||
|
|
e9273beaa1 | ||
|
|
63820d1909 | ||
|
|
1aaa3c8c06 | ||
|
|
be18ed1a8b | ||
|
|
3f4aaf539c | ||
|
|
3acc2d91e6 | ||
|
|
0867debebd | ||
|
|
e400cb1dcf | ||
|
|
1701e622dc |
No files matched your search
@@ -4,7 +4,7 @@ Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.31 candidate source `1255ef2`, Frame OS 0.5.6 build `20261022.6153909`: [sanitized results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.36 tag `1701e62`, Frame OS 0.5.6 build `20261022.6153909`: [release smoke evidence](evidence/v0436-release-smoke-2026-10-25.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
|
||||
+16
-5
@@ -44,9 +44,9 @@ For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-aut
|
||||
|
||||
### Which of these plugins can open a Framey panel?
|
||||
|
||||
The separate Framey panel accepts a Frame Control store install when its
|
||||
`plugin.json` declares API v1 or v2 and it has a modern Decky frontend bundle
|
||||
in `dist/index.js`. A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
The separate Framey panel accepts a Frame Control store install with a supported
|
||||
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
|
||||
A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
|
||||
catalogue. All 11 had a frontend bundle, but their manifests differed:
|
||||
|
||||
@@ -54,16 +54,27 @@ catalogue. All 11 had a frontend bundle, but their manifests differed:
|
||||
| --- | --- | --- |
|
||||
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
|
||||
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | Framey refuses these frontends. Audio Loader and PowerTools also request root. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
|
||||
|
||||
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
|
||||
has each store ID, exact version, SHA-256, manifest field, and bundle check.
|
||||
The archive audit itself is static PC inspection. Later local host and fake
|
||||
Frame tests cover the Storage Cleaner and HLTB cases noted above; none shows
|
||||
Frame tests cover the Storage Cleaner, HLTB, CSS Loader and SteamGridDB cases noted above; none shows
|
||||
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
|
||||
remain the only device evidence for these versions. A new store version needs a
|
||||
new check because its manifest and binaries can change.
|
||||
|
||||
Pinned CSS Loader 2.1.3 and SteamGridDB 1.8.1 archives now pass the fake Frame
|
||||
managed install/remove flow. CSS Loader's `plugin.json` has no `api_version` and
|
||||
its older IIFE bundle passes the Framey packaging check added in
|
||||
[#261](https://github.com/saphid/frame-control/pull/261). The fixture does not
|
||||
render its theme controls. SteamGridDB declares API v1 and its bundle passes Framey's packaging
|
||||
check, but it looks up Steam library modules while loading and patches game
|
||||
context menus. Those modules and menus do not exist in Framey's isolated
|
||||
iframe, so its artwork picker cannot be called a usable VR panel. Framey does
|
||||
not apply its Steam library changes. These checks did not run CSS themes or
|
||||
modify game artwork on the real Frame.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Legacy Decky frontend bridge
|
||||
|
||||
The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures)
|
||||
has no `api_version` field and returns its initializer from a Decky IIFE.
|
||||
Framey now wraps that expression in an ES module inside the existing
|
||||
opaque-origin iframe. It passes a legacy `serverAPI` to the initializer.
|
||||
|
||||
Local Chromium ran the unchanged official frontend, including React effects,
|
||||
with all backend calls rejected and network access blocked. It emitted
|
||||
`framey:ready` without a JavaScript startup error during the observation window.
|
||||
This proves initialization, not playback, MPRIS access, or a useful populated UI.
|
||||
|
||||
The fake Frame installed, packed, checked the frontend bytes and removed this
|
||||
exact archive. Its synthetic Sample Panel also read a legacy keyword argument,
|
||||
wrote a setting, and read that setting through the modern API. This exercised
|
||||
the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was
|
||||
checked against Decky's `handle_plugin_method_call_legacy`: it calls with a
|
||||
keyword object and returns `{success, result}`. Invalid argument shapes and
|
||||
unmanaged plugin IDs are rejected.
|
||||
|
||||
Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'`
|
||||
(21 passed), `node tests/page/framey_actions.mjs`, and
|
||||
`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or
|
||||
headset state was changed by these checks. Real Frame backend behavior,+controller input, playback and worn VR use remain unverified.
|
||||
@@ -0,0 +1,24 @@
|
||||
# v0.4.36 release smoke on a real Frame
|
||||
|
||||
On 25 October 2026 at 22:59 UTC, the release branch ran
|
||||
`scripts/frame-smoke.sh` from a physical Linux host against a real Steam Frame.
|
||||
The script and its Python implementation were byte-identical to the
|
||||
`v0.4.36` tag `1701e622dc1f9888560412a058fe816b19ca7e79`.
|
||||
The headset reported Frame OS 0.5.6, build `20261022.6153909`.
|
||||
|
||||
The test acquired `/tmp/frame-test.lock` with a unique owner marker before
|
||||
reading device status or changing test titles. Its first read showed SteamVR
|
||||
activity level 3 (standby), proximity raw 6.3125, and battery 99%. The test
|
||||
ran only while that lock was owned. It released only its own marker and lock
|
||||
after the final cleanup.
|
||||
|
||||
All **13 of 13** smoke steps passed: cleanup of reserved leftovers, devkit
|
||||
service read, status read, install/launch/remove for reserved ARM64, x86_64
|
||||
and Windows test titles, and final cleanup. The test did not pair a key. The
|
||||
test output was saved locally under `/tmp/frame-v0436-smoke-results/`; this
|
||||
summary omits the headset identifier and private device status fields.
|
||||
|
||||
The headset was in standby. This test does not show wearer-visible game or
|
||||
Framey pixels, controller input, live Decky settings, or plugin actions.
|
||||
See [release v0.4.36](https://github.com/saphid/frame-control/releases/tag/v0.4.36)
|
||||
for the tagged installer and checksum checks.
|
||||
@@ -57,7 +57,9 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| Decky API or UI feature | Framey behavior | Verified in |
|
||||
| --- | --- | --- |
|
||||
| `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test |
|
||||
| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; older API v1 bundles still show an incompatibility message | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
|
||||
| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; recognized legacy IIFE bundles also use the legacy bridge | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
|
||||
| Legacy Decky IIFE frontend, with no API manifest field | Wraps the returned initializer as an ES module inside the same opaque iframe; passes `serverAPI` and preserves the legacy title | MusicControl 1.1.6 exact official archive, local Chromium startup with backend methods blocked; fake Frame installation/pack/removal |
|
||||
| Legacy `serverAPI.callPluginMethod(method, kwargs)` | Uses Decky's `loader/call_legacy_plugin_method` RPC, preserving keyword arguments and `{success, result}` replies | Fake Frame HTTP/CDP read/write with a synthetic backend; no official plugin backend action or worn VR result |
|
||||
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
|
||||
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
|
||||
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
|
||||
@@ -66,7 +68,7 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification |
|
||||
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack` | Local routes inside this plugin panel only | Host unit test |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
|
||||
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
@@ -75,6 +77,9 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
Keep routes local to the panel and give every operation a visible result. A
|
||||
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
|
||||
Frame Control store; only paths registered by that same plugin can navigate.
|
||||
Optional route segments must be at the end. Framey accepts local path segments
|
||||
only; query strings, external URLs, Steam library routes and duplicate parameter
|
||||
names are rejected. Removing an active route returns to the plugin panel.
|
||||
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
|
||||
An API v1 manifest does not guarantee a working Framey panel. Test the exact
|
||||
store archive and do not assume SteamClient app details, library patches, or
|
||||
|
||||
@@ -24,7 +24,10 @@ and swaps one managed plugin
|
||||
folder with rollback on failure. It rejects non-ARM64 ELF and Windows binaries.
|
||||
The Decky user service restarts after a file change. Framey's panel host accepts
|
||||
API v2 bundles and API v1 bundles using Decky's modern frontend initializer,
|
||||
with `dist/index.js`; other installed plugins remain manageable but have no
|
||||
with `dist/index.js`. Legacy `(function (...) {...})(DFL, SP_REACT)` bundles are
|
||||
also accepted: their returned initializer receives the legacy `serverAPI`, and
|
||||
keyword backend calls use Decky's legacy RPC. The wrapper runs as a blob ES
|
||||
module inside the same sandbox; it does not require `eval`. Other installed plugins remain manageable but have no
|
||||
Framey panel. API v1 admission is a compatibility attempt, not a guarantee
|
||||
that the plugin's controls work in VR. The store manager does not currently
|
||||
screen a plugin's runtime network downloads.
|
||||
|
||||
@@ -13,6 +13,14 @@ browser cache. A missing, damaged, or old non-boolean value reads as the
|
||||
caller's default;
|
||||
the next save atomically replaces the sample's settings file.
|
||||
|
||||
The **About this setting** button opens a local detail page at
|
||||
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
|
||||
`Navigation.Navigate`, `useParams`, `NavigateBack`, and removal of the route in
|
||||
`onDismount`. The `:topic?` segment is optional. Framey keeps this navigation
|
||||
inside the plugin panel; it does not open a Steam library page. Put optional
|
||||
segments at the end of the path and do not use query strings. This detail page
|
||||
has not been checked with headset controllers.
|
||||
|
||||
## Build
|
||||
|
||||
From this directory, run:
|
||||
@@ -35,7 +43,7 @@ plugin directory does not enable its VR panel.
|
||||
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
|
||||
repository root. It checks backend persistence and evaluates the built bundle
|
||||
against Framey's actual host script, including the quick action's scoped
|
||||
backend call. The fake Frame store flow is exercised by
|
||||
backend call and local route registration/navigation. The fake Frame store flow is exercised by
|
||||
the repository's e2e suite. No worn-headset visibility or controller-input test
|
||||
has passed for this sample yet.
|
||||
|
||||
|
||||
+26
-17
@@ -16,6 +16,7 @@ if (api._version != API_VERSION) {
|
||||
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);
|
||||
}
|
||||
const callable = api.callable;
|
||||
const routerHook = api.routerHook;
|
||||
const toaster = api.toaster;
|
||||
const definePlugin = (fn) => {
|
||||
return (...args) => {
|
||||
@@ -59,24 +60,32 @@ function Content() {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
|
||||
}
|
||||
var index = definePlugin(() => Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
settings: SP_JSX.jsx(Content, {}),
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
function Details() {
|
||||
const { topic } = DFL.useParams();
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Sample detail page", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsxs("p", { children: ["Topic: ", topic || 'overview'] }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { children: "The toggle is stored by this plugin's Decky backend in the user's settings directory." }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.NavigateBack(), children: "Back to setting" }) })] });
|
||||
}
|
||||
var index = definePlugin(() => {
|
||||
routerHook.addRoute('/framey-sample/details/:topic?', Details);
|
||||
return Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
settings: SP_JSX.jsx(Content, {}),
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
export { index as default };
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the main content\n // usable there too, while showing authors the extra VR settings view.\n settings: <Content />,\n frameyQuickActions: [{\n label: 'Read saved setting',\n onClick: async () => {\n const enabled = await getSetting('enabled', false);\n return `Saved setting is ${enabled ? 'on' : 'off'}`;\n },\n }],\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WLine truncated
|
||||
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return <PanelSection title=\"Sample detail page\">\n <PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>\n <PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => {\n routerHook.addRoute('/framey-sample/details/:topic?', Details);\n return Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the mainLine truncated
|
||||
@@ -1,5 +1,5 @@
|
||||
import {callable, definePlugin, toaster} from '@decky/api';
|
||||
import {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';
|
||||
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
|
||||
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
|
||||
@@ -45,14 +45,27 @@ function Content() {
|
||||
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>
|
||||
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => Object.assign({
|
||||
function Details() {
|
||||
const {topic} = useParams<{topic?: string}>();
|
||||
return <PanelSection title="Sample detail page">
|
||||
<PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>
|
||||
<PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute('/framey-sample/details/:topic?', Details);
|
||||
return Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: <span aria-hidden="true">⚙</span>,
|
||||
content: <Content />,
|
||||
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
@@ -64,4 +77,5 @@ export default definePlugin(() => Object.assign({
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,9 @@ import threading
|
||||
import zipfile
|
||||
|
||||
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh
|
||||
from framey_official import PLUGINS as OFFICIAL_PLUGINS, archive as official_archive
|
||||
from framey_official import LEGACY_PLUGINS, PLUGINS as PANEL_PLUGINS, archive as official_archive
|
||||
|
||||
OFFICIAL_PLUGINS = PANEL_PLUGINS + LEGACY_PLUGINS
|
||||
|
||||
require() # Skip the module outside the disposable fake Frame, like its peers.
|
||||
|
||||
@@ -122,6 +124,15 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}],
|
||||
'legacy':True})['result'], {'success':True,'result':'light'})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}],
|
||||
'legacy':True})['result'], {'success':True,'result':True})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved')
|
||||
|
||||
StoreFixture.release = 1
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':999,'acknowledged':True}))
|
||||
@@ -148,7 +159,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertIn('version changed', stale.get('error', ''))
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
|
||||
|
||||
def test_pinned_official_api_v1_and_v2_plugins_install_and_remove(self):
|
||||
def test_pinned_official_plugins_install_and_remove(self):
|
||||
self.backend()
|
||||
catalog = {plugin['id']:plugin for plugin in ok('GET', '/api/decky/store')['plugins']}
|
||||
for plugin in OFFICIAL_PLUGINS:
|
||||
@@ -165,7 +176,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
|
||||
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
|
||||
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['api_version'], plugin['api_version'])
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':plugin['id'],'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
|
||||
@@ -28,10 +28,20 @@ function build(steam) {
|
||||
// Synthetic Decky API for Framey's CDP bridge test. This does not model the
|
||||
// real loader or prove frontend visibility on a headset.
|
||||
const deckyBackend = {async call(route, plugin, method, ...args) {
|
||||
if (route !== 'loader/call_plugin_method' || plugin !== 'Sample Panel')
|
||||
if (!['loader/call_plugin_method','loader/call_legacy_plugin_method'].includes(route) || plugin !== 'Sample Panel')
|
||||
throw Error('unexpected Decky plugin route');
|
||||
const settings = (steam.decky_settings ||= {});
|
||||
const owned = (settings[plugin] ||= {});
|
||||
if (route === 'loader/call_legacy_plugin_method') {
|
||||
const kwargs = args[0];
|
||||
if (method === 'get_setting')
|
||||
return {success:true, result:Object.hasOwn(owned, kwargs.key) ? owned[kwargs.key] : kwargs.default};
|
||||
if (method === 'set_setting') {
|
||||
owned[kwargs.key] = kwargs.value;
|
||||
return {success:true, result:true};
|
||||
}
|
||||
return {success:false, result:'method unavailable in fake Decky backend'};
|
||||
}
|
||||
if (method === 'get_setting')
|
||||
return Object.hasOwn(owned, args[0]) ? owned[args[0]] : args[1];
|
||||
if (method === 'set_setting') {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+2
@@ -7,6 +7,7 @@ from a local fixture so CI does not depend on the store being online.
|
||||
|
||||
| File | Official plugin | Version | Store SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
|
||||
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
|
||||
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
|
||||
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
|
||||
@@ -14,6 +15,7 @@ from a local fixture so CI does not depend on the store being online.
|
||||
| `26.zip` | HLTB for Deck | 2.0.12 | `bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558` |
|
||||
|
||||
The matching source projects are
|
||||
[MusicControl](https://github.com/nikurasu/MusicControl),
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky),
|
||||
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner),
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||
PLUGINS = (
|
||||
{'id': 12, 'name': 'MusicControl', 'version': '1.1.6', 'api_version': 'legacy', 'folder': 'MusicControl', 'sha256': '246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62'},
|
||||
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2,
|
||||
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
|
||||
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
|
||||
@@ -14,6 +15,14 @@ PLUGINS = (
|
||||
'folder': 'Bluetooth', 'sha256': 'f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5'},
|
||||
{'id': 26, 'name': 'HLTB for Deck', 'version': '2.0.12', 'api_version': 1,
|
||||
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
|
||||
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
|
||||
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
|
||||
)
|
||||
|
||||
# This archive has a legacy IIFE frontend with no manifest API version.
|
||||
LEGACY_PLUGINS = (
|
||||
{'id': 7, 'name': 'CSS Loader', 'version': '2.1.3', 'api_version': 'legacy',
|
||||
'folder': 'SDH-CssLoader', 'sha256': '99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface;\n})();');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI;\n})();');
|
||||
assert.notEqual(testSource,source);
|
||||
const states=[];
|
||||
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
@@ -111,3 +111,32 @@ assert.equal(window.DFL.findModuleChild(()=>true),null);
|
||||
window.DFL.Navigation.CloseSideMenus();
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0);
|
||||
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
|
||||
// Legacy backend methods preserve Decky keyword arguments and success envelopes.
|
||||
const legacyReply=window.__legacy.callPluginMethod('get_setting',{key:'theme'});
|
||||
const legacyCall=messages.filter(message=>message.type==='framey:call').at(-1);
|
||||
assert.equal(legacyCall.legacy,true);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(legacyCall.args)),[{key:'theme'}]);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:legacyCall.id,
|
||||
result:{success:true,result:'dark'}}});
|
||||
assert.equal((await legacyReply).result,'dark');
|
||||
assert.equal((await window.__legacy.callPluginMethod('get_setting',[])).success,false);
|
||||
const failedLegacy=window.__legacy.callPluginMethod('get_setting',{});
|
||||
const failedCall=messages.filter(message=>message.type==='framey:call').at(-1);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:failedCall.id,error:'Unavailable'}});
|
||||
assert.equal((await failedLegacy).success,false);
|
||||
function GameRoute(){return React.createElement('p',null,window.DFL.useParams().appid);}
|
||||
const removeGame=api.routerHook.addRoute('/plugin/game/:appid/:view?',GameRoute);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
|
||||
assert.equal(window.DFL.useParams().appid,'123');
|
||||
assert.equal(window.DFL.useParams().view,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,1);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true);
|
||||
assert.equal(window.DFL.useParams().view,'art');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art/extra'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123?admin=1'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game//art'),false);
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute));
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
|
||||
removeGame();
|
||||
assert.equal(window.DFL.useParams().appid,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
|
||||
@@ -36,3 +36,9 @@ assert.equal(plugin.frameyQuickActions[0].label,'Read saved setting');
|
||||
assert.equal(await plugin.frameyQuickActions[0].onClick(),'Saved setting is on');
|
||||
assert.equal(calls[0].method,'get_setting');
|
||||
assert.deepEqual(Array.from(calls[0].args),['enabled',false]);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),true);
|
||||
assert.equal(window.DFL.useParams().topic,'saved-setting');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/library/home'),false);
|
||||
plugin.onDismount();
|
||||
assert.equal(window.DFL.useParams().topic,undefined);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),false);
|
||||
@@ -60,6 +60,7 @@ assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2');
|
||||
assert.equal(el('deckyJobProgress').value,100);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// A stopped Decky service must still offer recovery by removing its owned plugin.
|
||||
ctx.mockApi=async path => path==='/api/decky' ?
|
||||
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
|
||||
@@ -81,6 +82,28 @@ await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"b".rep
|
||||
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// A failed refresh must remove old install buttons. A late older response
|
||||
// must not replace the newer catalog the wearer just requested.
|
||||
ctx.mockApi=async path => {assert.equal(path,'/api/decky/store');throw Error('offline');};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('loadDeckyStore()',ctx);
|
||||
assert.match(el('deckyStoreSummary').textContent,/Store unavailable: offline/);
|
||||
assert.equal(el('deckyStoreList').children.length,0);
|
||||
let finishOld, finishNew;
|
||||
ctx.mockApi=path => {assert.equal(path,'/api/decky/store');
|
||||
return new Promise(resolve=>{if(!finishOld)finishOld=resolve;else finishNew=resolve;});};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
const old=vm.runInContext('loadDeckyStore()',ctx);
|
||||
const current=vm.runInContext('loadDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children.length,0);
|
||||
assert.equal(el('deckyStoreSummary').textContent,'Loading official Decky store…');
|
||||
finishNew({plugins:[{id:98,name:'Current',version:'2.0',description:'Latest'}]});
|
||||
await current;
|
||||
finishOld({plugins:[{id:97,name:'Stale',version:'1.0',description:'Older'}]});
|
||||
await old;
|
||||
assert.match(el('deckyStoreList').children[0].children[0].textContent,/Current/);
|
||||
assert.doesNotMatch(el('deckyStoreList').children[0].children[0].textContent,/Stale/);
|
||||
|
||||
// A saved job does not survive a server restart. Clear it instead of offering
|
||||
// Check result forever for an ID that the new server cannot know.
|
||||
session.set('frameyPluginJob',JSON.stringify({job:'old-job',action:'install',name:'Plugin 2'}));
|
||||
|
||||
@@ -17,10 +17,29 @@ import io
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_decky_store
|
||||
import framey_panel
|
||||
from framey_official import PLUGINS, archive
|
||||
from framey_official import LEGACY_PLUGINS, PLUGINS, archive
|
||||
|
||||
|
||||
class OfficialBundles(unittest.TestCase):
|
||||
def test_legacy_css_loader_store_archive_has_unversioned_frontend(self):
|
||||
plugin = LEGACY_PLUGINS[0]
|
||||
data = archive(plugin)
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
|
||||
self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']), plugin['folder'])
|
||||
with tempfile.TemporaryDirectory() as root, zipfile.ZipFile(io.BytesIO(data)) as bundle:
|
||||
manifest = json.loads(bundle.read(plugin['folder'] + '/plugin.json'))
|
||||
self.assertNotIn('api_version', manifest)
|
||||
frontend = bundle.read(plugin['folder'] + '/dist/index.js')
|
||||
self.assertIn(b'deckyFrontendLib.definePlugin', frontend)
|
||||
bundle.extractall(root)
|
||||
folder = Path(root) / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
with patch.object(framey_panel, 'PLUGINS', Path(root)):
|
||||
page = framey_panel.panel(plugin['id']).decode()
|
||||
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
|
||||
self.assertIsNotNone(encoded)
|
||||
self.assertEqual(base64.b64decode(encoded.group(1)), frontend)
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
|
||||
def test_bluetooth_initial_panel_uses_safe_token_fallback(self):
|
||||
with zipfile.ZipFile(io.BytesIO(archive(next(p for p in PLUGINS if p['id'] == 18)))) as bundle:
|
||||
@@ -49,7 +68,7 @@ class OfficialBundles(unittest.TestCase):
|
||||
plugin['folder'])
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
||||
manifest = json.loads(z.read(plugin['folder'] + '/plugin.json'))
|
||||
self.assertEqual(manifest['api_version'], plugin['api_version'])
|
||||
self.assertEqual(manifest.get('api_version', 'legacy'), plugin['api_version'])
|
||||
z.extractall(plugins)
|
||||
folder = plugins / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
|
||||
@@ -58,6 +58,31 @@ class Panel(unittest.TestCase):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.panel(15)
|
||||
|
||||
def test_legacy_iife_is_admitted_without_api_manifest(self):
|
||||
(self.folder/'plugin.json').write_text(json.dumps({'name':'Legacy', 'flags':[]}))
|
||||
(self.folder/'dist/index.js').write_text(
|
||||
'(function(DFL, React){return DFL.definePlugin(serverAPI=>({content:React.createElement("p",null,"Legacy")}));})(DFL, SP_REACT);')
|
||||
self.assertIn('framey-bundle', framey_panel.panel(14).decode())
|
||||
(self.folder/'dist/index.js').write_text('window.unrecognized = true;')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.panel(14)
|
||||
|
||||
def test_legacy_keyword_rpc_is_scoped_and_validated(self):
|
||||
import shlex
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
payload = json.loads(shlex.split(command)[2])
|
||||
self.assertEqual(payload, {'name':'Legacy', 'method':'read_setting',
|
||||
'args':[{'key':'mode'}], 'legacy':True})
|
||||
self.assertIn('loader/call_legacy_plugin_method', stdin)
|
||||
return json.dumps({'result':{'success':True, 'result':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'Legacy'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True),
|
||||
{'result':{'success':True, 'result':'dark'}})
|
||||
for args, legacy in [([],True), ([[]],True), ([{},{}],True), ([{}],'true')]:
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy)
|
||||
|
||||
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
|
||||
manifest = self.folder/'plugin.json'
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':0}))
|
||||
|
||||
+60
-11
@@ -26,6 +26,7 @@
|
||||
const routes = new Map();
|
||||
const routeListeners = new Set();
|
||||
let currentRoute = null;
|
||||
let currentParams = {};
|
||||
let nextId = 0;
|
||||
let plugin;
|
||||
// A sandboxed iframe has an opaque origin, so its native localStorage getter
|
||||
@@ -59,13 +60,13 @@
|
||||
parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*');
|
||||
setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable.
|
||||
|
||||
function ask(method, args) {
|
||||
function ask(method, args, legacy=false) {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(Error('Decky backend timed out')); }, 30000);
|
||||
pending.set(id, {resolve, reject, timer});
|
||||
parent.postMessage({type:'framey:call', id, method, args}, '*');
|
||||
parent.postMessage({type:'framey:call', id, method, args, legacy}, '*');
|
||||
});
|
||||
}
|
||||
addEventListener('message', event => {
|
||||
@@ -137,21 +138,54 @@
|
||||
R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close'));
|
||||
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
|
||||
const closeModal = () => modalRoot.render(null);
|
||||
const localPath = path => typeof path === 'string' && path.length <= 120 &&
|
||||
/^\/[A-Za-z0-9/_-]+$/.test(path) && !path.includes('//');
|
||||
const routePattern = path => typeof path === 'string' && path.length <= 120 &&
|
||||
/^\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??)(?:\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??))*$/.test(path) &&
|
||||
!/\?\//.test(path) &&
|
||||
new Set([...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].map(match => match[1])).size ===
|
||||
[...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].length;
|
||||
function matchRoute(pattern, path) {
|
||||
const expected = pattern.slice(1).split('/');
|
||||
const actual = path.slice(1).split('/');
|
||||
if (actual.length > expected.length) return null;
|
||||
const params = {};
|
||||
for (let index = 0; index < expected.length; index++) {
|
||||
const segment = expected[index], value = actual[index];
|
||||
if (segment[0] === ':') {
|
||||
const optional = segment.endsWith('?'), name = segment.slice(1, optional ? -1 : undefined);
|
||||
if (value === undefined) { if (!optional) return null; }
|
||||
else params[name] = value;
|
||||
} else if (segment !== value) return null;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
function activeRoute(path) {
|
||||
if (routes.has(path)) return {component:routes.get(path), params:{}};
|
||||
for (const [pattern, component] of routes) {
|
||||
const params = matchRoute(pattern, path);
|
||||
if (params) return {component, params};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function navigate(path) {
|
||||
if (path !== null && (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || !routes.has(path))) return false;
|
||||
if (path !== null && !localPath(path)) return false;
|
||||
const match = path === null ? null : activeRoute(path);
|
||||
if (path !== null && !match) return false;
|
||||
currentRoute = path;
|
||||
currentParams = match?.params || {};
|
||||
for (const listener of routeListeners) listener(path);
|
||||
return true;
|
||||
}
|
||||
const routerHook = {
|
||||
addRoute(path, component) {
|
||||
if (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) ||
|
||||
if (!routePattern(path) ||
|
||||
(typeof component !== 'function' && !R.isValidElement(component)))
|
||||
throw Error('Framey route needs a local path and React component');
|
||||
routes.set(path, component);
|
||||
return () => {routes.delete(path); if(currentRoute === path) navigate(null);};
|
||||
return () => routerHook.removeRoute(path);
|
||||
},
|
||||
removeRoute(path) {routes.delete(path); if(currentRoute === path) navigate(null);},
|
||||
removeRoute(path) {routes.delete(path); if(currentRoute && !activeRoute(currentRoute)) navigate(null);},
|
||||
// Steam routes and global components have no equivalent in the isolated panel.
|
||||
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
|
||||
};
|
||||
@@ -170,7 +204,7 @@
|
||||
afterPatch: () => () => {}, createReactTreePatcher: () => () => {},
|
||||
findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null,
|
||||
appDetailsClasses: {}, appDetailsHeaderClasses: {},
|
||||
useParams: () => ({}),
|
||||
useParams: () => currentParams,
|
||||
};
|
||||
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
|
||||
const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => {
|
||||
@@ -228,8 +262,8 @@
|
||||
...actions.map((action, index) => R.createElement('button', {key:index, type:'button',
|
||||
disabled:running !== null, onClick:()=>runAction(action,index)}, action.label))),
|
||||
message && R.createElement('p', {role:'status'}, message),
|
||||
R.createElement('div', null, route ? (typeof routes.get(route) === 'function' ?
|
||||
R.createElement(routes.get(route)) : routes.get(route)) :
|
||||
R.createElement('div', null, route ? (typeof activeRoute(route)?.component === 'function' ?
|
||||
R.createElement(activeRoute(route).component) : activeRoute(route)?.component) :
|
||||
(view === 'settings' ? plugin.settings : plugin.content)));
|
||||
}
|
||||
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
|
||||
@@ -247,15 +281,30 @@
|
||||
useQuickAccessVisible: () => true,
|
||||
}),
|
||||
};
|
||||
const legacyServerAPI = {
|
||||
routerHook, toaster,
|
||||
callPluginMethod: (method, kwargs={}) => {
|
||||
if (!kwargs || typeof kwargs !== 'object' || Array.isArray(kwargs))
|
||||
return Promise.resolve({success:false, result:'Legacy plugin arguments must be an object'});
|
||||
// Decky's legacy route takes keyword arguments and returns this envelope.
|
||||
return ask(method, [kwargs], true).catch(failure=>({success:false, result:String(failure.message || failure)}));
|
||||
},
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
};
|
||||
async function start() {
|
||||
try {
|
||||
await seeded;
|
||||
const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0));
|
||||
const url = URL.createObjectURL(new Blob([bytes], {type:'text/javascript'}));
|
||||
// Old Decky builds are expressions returning the initializer, rather than
|
||||
// ES modules. A module wrapper preserves the sandbox and avoids eval.
|
||||
const legacy = /^\s*\(function\s*\(/.test(new TextDecoder().decode(bytes));
|
||||
const url = URL.createObjectURL(new Blob(legacy ?
|
||||
['const __frameyLegacy =\n', bytes, '\nexport default __frameyLegacy;'] : [bytes], {type:'text/javascript'}));
|
||||
let module;
|
||||
try { module = await import(url); } finally { URL.revokeObjectURL(url); }
|
||||
if (typeof module.default !== 'function') throw Error('The Decky frontend has no plugin entry point');
|
||||
plugin = await module.default();
|
||||
plugin = await module.default(legacyServerAPI);
|
||||
if (legacy && plugin?.title && !plugin.titleView) plugin.titleView = plugin.title;
|
||||
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
|
||||
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
|
||||
R.createElement(PluginSurface, {plugin})));
|
||||
|
||||
+14
-6
@@ -44,12 +44,16 @@ def local_bundle(plugin_id):
|
||||
raise FileNotFoundError('Framey plugin manifest unavailable')
|
||||
flags = data.get('flags', [])
|
||||
version = data.get('api_version')
|
||||
if type(version) is not int or version not in (1, 2) or not isinstance(flags, list) or any(x in flags for x in ('root', '_root')):
|
||||
raise FileNotFoundError('This Decky frontend is not API v1/v2 or needs root')
|
||||
if ((version is not None and (type(version) is not int or version not in (1, 2))) or
|
||||
not isinstance(flags, list) or any(x in flags for x in ('root', '_root'))):
|
||||
raise FileNotFoundError('This Decky frontend has an unsupported API or needs root')
|
||||
if not entry.is_file() or entry.stat().st_size > 8*1024*1024:
|
||||
raise FileNotFoundError('Framey plugin frontend unavailable')
|
||||
bundle = entry.read_bytes()
|
||||
if version == 1 and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle:
|
||||
legacy = re.match(rb'\s*\(function\s*\(', bundle) is not None
|
||||
if version is None and not legacy:
|
||||
raise FileNotFoundError('This legacy frontend is not a supported Decky IIFE bundle')
|
||||
if version == 1 and not legacy and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle:
|
||||
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
|
||||
return bundle
|
||||
except (OSError, ValueError):
|
||||
@@ -76,23 +80,27 @@ import json, pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
|
||||
from frame_steam import Page
|
||||
payload = json.loads(sys.argv[1])
|
||||
expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call('loader/call_plugin_method'," + json.dumps(payload['name']) + ',' + json.dumps(payload['method']) + ',' + ','.join(json.dumps(x) for x in payload['args']) + ')})()'
|
||||
route = 'loader/call_legacy_plugin_method' if payload.get('legacy') else 'loader/call_plugin_method'
|
||||
arguments = [payload['name'], payload['method'], *payload['args']]
|
||||
expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call(" + ','.join(json.dumps(x) for x in [route, *arguments]) + ')})()'
|
||||
page = Page()
|
||||
try: print(json.dumps({'result':page.eval(expression)}))
|
||||
finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def call_method(ssh, plugin_id, method, args):
|
||||
def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(method, str) or not METHOD.fullmatch(method):
|
||||
raise PanelError('Invalid plugin method')
|
||||
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args)) > 8192:
|
||||
raise PanelError('Plugin method arguments are too large')
|
||||
if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))):
|
||||
raise PanelError('Legacy plugin methods need one keyword argument object')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args}
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)),
|
||||
stdin=METHOD_SCRIPT, timeout=25))
|
||||
+10
-5
@@ -103,7 +103,8 @@ 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=[], deckyStoreIndex=0, activeFrameyId=null, frameySession=null, deckyBusy=false;
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
|
||||
activeFrameyId=null, frameySession=null, deckyBusy=false;
|
||||
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.':
|
||||
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
|
||||
@@ -117,10 +118,14 @@ async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
button.disabled=deckyBusy;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(){byId('deckyStorePanel').hidden=false;
|
||||
async function loadDeckyStore(){const request=++deckyStoreRequest;byId('deckyStorePanel').hidden=false;
|
||||
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
|
||||
byId('deckyStoreSummary').textContent='Loading official Decky store…';
|
||||
try{const data=await api('/api/decky/store');deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
|
||||
catch(error){byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
|
||||
try{const data=await api('/api/decky/store');if(request!==deckyStoreRequest)return;
|
||||
deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
|
||||
catch(error){if(request!==deckyStoreRequest)return;
|
||||
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
|
||||
byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
|
||||
function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren();
|
||||
const q=byId('deckySearch').value.trim().toLowerCase();
|
||||
const filter=byId('deckyStoreFilter').value;
|
||||
@@ -229,7 +234,7 @@ window.addEventListener('message',async event=>{
|
||||
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type!=='framey:call'||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const pluginId=activeFrameyId,reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args});reply.result=data.result;}
|
||||
try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
});
|
||||
|
||||
+1
-1
@@ -581,7 +581,7 @@ def framey_method_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'))
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'), body.get('legacy', False))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
Reference in new issue
Block a user