mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 02:01:52 +01:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d186dfbc60 | ||
|
|
020f93a187 | ||
|
|
69a912cc7d | ||
|
|
aa6078c11a | ||
|
|
e89ff39cff | ||
|
|
4cfbd5883f | ||
|
|
33fdd9d296 | ||
|
|
6b6ec64381 | ||
|
|
8296657a45 |
No files matched your search
+3
-2
@@ -78,8 +78,9 @@ change Frame Control owned plugin folders under
|
||||
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. Its separate Framey iframe now hosts
|
||||
compatible API v2 plugin pages, settings, local routes, and panel toasts.
|
||||
shows installed plugins and store actions. Its separate Framey iframe hosts
|
||||
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
|
||||
with settings, local routes, and panel toasts where supported.
|
||||
Steam route patches and system notifications remain unavailable. The panel
|
||||
still needs a worn-headset visual and controller check. See the
|
||||
[current compatibility table](frame-plugin-author.md#framey-api-v2-compatibility).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# API v1 panel check, 25 October 2026
|
||||
|
||||
The unmodified official Storage Cleaner 1.4.0 archive (store ID 50, SHA-256
|
||||
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`)
|
||||
declares `api_version: 1`, but its bundled frontend connects to Decky's modern
|
||||
frontend API. Framey admitted that bundle from a local store receipt and
|
||||
rendered it in Chromium inside an iframe with `sandbox="allow-scripts
|
||||
allow-popups"`.
|
||||
|
||||
A test parent returned empty lists and `0B` for the plugin's two list and two
|
||||
size backend calls. Chromium reported `framey:ready`, displayed Shader Cache
|
||||
and Compatibility Data sections, and showed all four cleanup buttons disabled.
|
||||
No cleanup action was invoked. The backend was a stub; the test did not run the
|
||||
plugin's Python code or connect to a headset.
|
||||
|
||||
This confirms a panel shell for this exact archive with empty data. Storage
|
||||
Cleaner may remove compatibility data and local game saves when used with a
|
||||
real backend. Its cleanup actions, game names, SteamClient app details, VR
|
||||
visibility, and controller input remain unverified on Frame. Do not infer that
|
||||
its deletion controls are safe or complete in VR from this browser check.
|
||||
@@ -29,7 +29,8 @@ before describing it as working in VR. Until a QAM bridge is verified, a Frame
|
||||
Control extension is the simplest route for a self-contained read-only VR panel.
|
||||
|
||||
Frame Control now has an experimental [Framey panel host](framey-loader.md)
|
||||
for API v2 Decky frontend bundles. It renders common settings controls in the
|
||||
for API v2 Decky frontend bundles and API v1 bundles that use Decky's modern
|
||||
frontend API initializer. It renders common settings controls in the
|
||||
separate SteamVR dashboard panel, but Steam library and store page patches
|
||||
remain unavailable. Wearer-visible use is not yet verified.
|
||||
The [settings sample](../examples/framey-settings/README.md) shows an official
|
||||
@@ -47,7 +48,7 @@ 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).
|
||||
|
||||
## Framey API v2 compatibility
|
||||
## Framey Decky API compatibility
|
||||
|
||||
Framey hosts a plugin's frontend in an isolated iframe in the Frame Control
|
||||
SteamVR panel. The table describes the current implementation and its tests;
|
||||
@@ -56,9 +57,11 @@ 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. |
|
||||
| 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 |
|
||||
| `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check |
|
||||
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
|
||||
| `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 |
|
||||
@@ -72,6 +75,11 @@ 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.
|
||||
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
|
||||
destructive actions work in VR. The
|
||||
[Storage Cleaner browser check](evidence/framey-api-v1-browser-2026-10-25.md)
|
||||
used empty stub data and did not invoke its cleanup actions.
|
||||
Use the Decky Python backend for settings that must survive browser cache loss.
|
||||
The Framey `localStorage` bridge is for disposable frontend caches, including
|
||||
the cache read attempted by Steam Status 1.2.3. It does not expose another
|
||||
|
||||
+15
-7
@@ -23,8 +23,10 @@ rejects unsafe ZIP members, path collisions, special files and root plugins,
|
||||
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
|
||||
only API v2 bundles with `dist/index.js`; other installed plugins remain
|
||||
manageable but have no Framey panel. The store manager does not currently
|
||||
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
|
||||
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.
|
||||
|
||||
## What the compatibility host supplies
|
||||
@@ -70,10 +72,13 @@ that a panel-lifetime substitute avoids those errors while preserving the
|
||||
opaque origin. A [second browser check](evidence/framey-cache-browser-2026-10-25.md)
|
||||
tested the per-plugin cache after reopening a panel. These checks used a stub
|
||||
backend and no worn headset.
|
||||
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4
|
||||
and Steam Status 1.2.3 archives, checks their API v2 manifests and frontend
|
||||
bytes, and verifies they can be packed as Framey panels. It does not execute
|
||||
their actual backend code or prove their frontend controls work in VR.
|
||||
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4,
|
||||
Steam Status 1.2.3, and Storage Cleaner 1.4.0 archives, checks their API v1/v2
|
||||
manifests and frontend bytes, and verifies they can be packed as Framey panels.
|
||||
It does not execute their actual backend code or prove their frontend controls
|
||||
work in VR. Storage Cleaner rendered an
|
||||
[empty-data panel in local Chromium](evidence/framey-api-v1-browser-2026-10-25.md).
|
||||
Its cleanup actions were not tested; they may remove local game saves.
|
||||
|
||||
The fake Frame integration test now writes a Decky setting through Framey's
|
||||
scoped backend endpoint, reads it in a new CEF request, updates the plugin,
|
||||
@@ -85,7 +90,10 @@ The in-headset **Open official store** view loads the full official catalogue.
|
||||
Search matches names, authors, and descriptions. Filters show non-root plugins
|
||||
or installed plugins, and Previous/Next buttons browse 20 results per page.
|
||||
The root label is a compatibility warning; other APIs are checked only when a
|
||||
plugin is opened. The store still needs the managed Decky user service running
|
||||
plugin is opened. Both `root` and `_root`
|
||||
store tags mark a plugin as needing root, and duplicate store IDs are omitted
|
||||
because an ID alone could select a different archive than the visible row.
|
||||
The store still needs the managed Decky user service running
|
||||
before its Install or Update buttons are enabled. If a plugin prevents Decky
|
||||
from starting, Remove remains available for Frame Control-owned plugins while
|
||||
the managed service is stopped; it does not restart the service.
|
||||
|
||||
@@ -5,7 +5,12 @@ This is a small Decky API v2 plugin for authors. Its panel uses the official
|
||||
backend method, stores one boolean under `decky.decky_SETTINGS_DIR`,
|
||||
and reads it back when the panel opens. The backend rejects other keys and
|
||||
values. The frontend also shows a local toast after a successful save. A
|
||||
missing, damaged, or old non-boolean value reads as the caller's default;
|
||||
Framey settings tab exposes the same toggle, and a read-only quick action
|
||||
fetches the saved value without changing it. These two fields are Framey
|
||||
additions; Decky's own menu still uses the main panel content. The quick
|
||||
action calls the backend, so it reads the persisted setting rather than a
|
||||
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.
|
||||
|
||||
## Build
|
||||
@@ -29,7 +34,8 @@ 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. The fake Frame store flow is exercised by
|
||||
against Framey's actual host script, including the quick action's scoped
|
||||
backend call. 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.
|
||||
|
||||
|
||||
+12
-1
@@ -61,10 +61,21 @@ function Content() {
|
||||
}
|
||||
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 }) })] });
|
||||
}
|
||||
var index = definePlugin(() => ({
|
||||
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'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
|
||||
export { index as default };
|
||||
|
||||
+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(() => ({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\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,WAAA,EAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,8CAAA,EAAA,GAAA,CAAA,QAAA,CAAA,6BAAA,CAAA,CAAA;AACA;AAEA,MAAA,QAAA,GAAA,GAAA,CAAA,QAAA;AAIA,MAAA,OAAA,GAAA,GAAA,CAAA,OAAA;AAQA,MAAA,YAAA,GAAA,CAAA,EAAA,KAAA;AACA,IAAA,OAAA,CAAA,GAAA,IAAA,KAAA;AACA,QAAA,OAAA,EAAA,CAAA,GAAA,IAAA,CAAA;AACA,IAAA,CAAA;AACA,CAAA;;ACnCA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AACA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AAEA,SAAA,OAAA,Line 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, 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
|
||||
@@ -49,8 +49,19 @@ function Content() {
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => ({
|
||||
export default definePlugin(() => Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: <span aria-hidden="true">⚙</span>,
|
||||
content: <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: <Content />,
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
@@ -143,7 +143,7 @@ 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')
|
||||
|
||||
def test_two_pinned_official_api_v2_plugins_install_and_remove(self):
|
||||
def test_pinned_official_api_v1_and_v2_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:
|
||||
@@ -160,7 +160,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'], 2)
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['api_version'], plugin['api_version'])
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':plugin['id'],'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
|
||||
BIN
Binary file not shown.
+5
-3
@@ -9,10 +9,12 @@ from a local fixture so CI does not depend on the store being online.
|
||||
| --- | --- | --- | --- |
|
||||
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
|
||||
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
|
||||
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
|
||||
|
||||
The matching source projects are
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky) and
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky).
|
||||
The test verifies the ZIP digest, archive policy, API v2 manifest, installed
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky), and
|
||||
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner).
|
||||
The test verifies the ZIP digest, archive policy, API v1/v2 manifest, installed
|
||||
frontend bytes, managed store receipt, and removal. It does **not** run their
|
||||
Python backends or prove their JavaScript panels render in the VR shell.
|
||||
@@ -4,10 +4,12 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||
PLUGINS = (
|
||||
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4',
|
||||
{'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',
|
||||
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
|
||||
'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'},
|
||||
{'id': 50, 'name': 'Storage Cleaner', 'version': '1.4.0', 'api_version': 1,
|
||||
'folder': 'decky-storage-cleaner', 'sha256': '1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ const input=nodes(window.DFL.TextField({label:'Alias',value:'Old',placeholder:'N
|
||||
onChange:()=>changed++}),'input')[0];
|
||||
const slider=nodes(window.DFL.SliderField({label:'Speed',value:2,min:0,max:10,step:2,disabled:true,
|
||||
onChange:()=>changed++}),'input')[0];
|
||||
for(const control of [toggle,dropdown,button,input,slider])assert.equal(control.props.disabled,true);
|
||||
const checkbox=nodes(window.DFL.DialogCheckbox({label:'Game',disabled:true,
|
||||
onChange:()=>changed++}),'input')[0];
|
||||
for(const control of [toggle,dropdown,button,input,slider,checkbox])assert.equal(control.props.disabled,true);
|
||||
assert.equal(input.props.value,'Old');
|
||||
assert.equal(slider.props.type,'range');
|
||||
assert.equal(dropdown.props.value,'1');
|
||||
@@ -53,6 +55,7 @@ dropdown.props.onChange({target:{value:'0'}});
|
||||
button.props.onClick({});
|
||||
input.props.onChange({target:{value:'New'}});
|
||||
slider.props.onChange({target:{value:'4'}});
|
||||
checkbox.props.onChange({target:{checked:true}});
|
||||
assert.equal(changed,0);
|
||||
let entered='';
|
||||
const readyInput=nodes(window.DFL.TextField({label:'Alias',value:'Old',onChange:e=>{entered=e.target.value;}}),'input')[0];
|
||||
|
||||
@@ -10,8 +10,16 @@ const React={Fragment:Symbol('fragment'),Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
|
||||
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
|
||||
const listeners=[];
|
||||
const calls=[];
|
||||
const parent={postMessage(message){
|
||||
if(message.type!=='framey:call')return;
|
||||
calls.push(message);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,
|
||||
data:{type:'framey:reply',id:message.id,result:true}});
|
||||
}};
|
||||
const context=vm.createContext({window,document:{getElementById:()=>({})},
|
||||
navigator:{language:'en'},parent:{postMessage(){}},addEventListener(){},
|
||||
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(host,context);
|
||||
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
|
||||
@@ -23,3 +31,8 @@ const content=plugin.content.type();
|
||||
assert.equal(content.type,window.DFL.PanelSection);
|
||||
assert.equal(content.props.title,'Framey settings sample');
|
||||
assert.equal(content.children[0][0].type,window.DFL.PanelSectionRow);
|
||||
assert.equal(plugin.settings.type,plugin.content.type);
|
||||
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]);
|
||||
@@ -41,6 +41,15 @@ class ArchiveTests(unittest.TestCase):
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertEqual([(p['id'], p['author']) for p in frame_decky_store.catalog()], [(42, 'Panel Author')])
|
||||
|
||||
def test_catalog_marks_underscore_root_and_omits_ambiguous_store_ids(self):
|
||||
records = [{'id': 42, 'name': 'Needs root', 'tags': ['_root'],
|
||||
'versions': [{'name': '1.0', 'hash': 'a' * 64}]},
|
||||
{'id': 43, 'name': 'First', 'versions': [{'name': '1.0', 'hash': 'b' * 64}]},
|
||||
{'id': 43, 'name': 'Second', 'versions': [{'name': '2.0', 'hash': 'c' * 64}]}]
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen',
|
||||
return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertEqual([(p['id'], p['root']) for p in frame_decky_store.catalog()], [(42, True)])
|
||||
|
||||
def test_accepts_hashed_non_root_archive(self):
|
||||
data, digest = archive()
|
||||
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The pinned store bundles remain valid API v2 Framey panel inputs."""
|
||||
"""Pinned official Decky bundles remain valid Framey panel inputs."""
|
||||
import sandbox # noqa: F401
|
||||
import base64
|
||||
import hashlib
|
||||
@@ -30,7 +30,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'], 2)
|
||||
self.assertEqual(manifest['api_version'], plugin['api_version'])
|
||||
z.extractall(plugins)
|
||||
folder = plugins / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
|
||||
@@ -58,10 +58,16 @@ class Panel(unittest.TestCase):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.panel(15)
|
||||
|
||||
def test_legacy_root_symlink_and_unmanaged_are_refused(self):
|
||||
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}))
|
||||
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':1}))
|
||||
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
|
||||
(self.folder/'dist/index.js').write_text(
|
||||
'// __DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit\n'
|
||||
"export default () => ({content: SP_REACT.createElement('p', null, 'OK')});")
|
||||
self.assertIn('framey-bundle', framey_panel.panel(14).decode())
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':['root']}))
|
||||
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
|
||||
|
||||
@@ -59,8 +59,13 @@ def catalog():
|
||||
'description': str(item.get('description') or '')[:300],
|
||||
'author': author[:100],
|
||||
'version': latest['name'][:40], 'sha256': latest['hash'],
|
||||
'root': 'root' in tags})
|
||||
return result
|
||||
'root': 'root' in tags or '_root' in tags})
|
||||
# A repeated store ID makes a clicked row ambiguous: change() resolves by ID
|
||||
# and could install a different archive than the one the user reviewed.
|
||||
counts = {}
|
||||
for item in result:
|
||||
counts[item['id']] = counts.get(item['id'], 0) + 1
|
||||
return [item for item in result if counts[item['id']] == 1]
|
||||
|
||||
|
||||
def archive_info(data, expected_hash, expected_name):
|
||||
|
||||
+6
-2
@@ -109,6 +109,10 @@
|
||||
const ButtonItem = ({label, description, children, disabled, onClick}) => R.createElement(Field, {label, description},
|
||||
R.createElement('button', {type:'button', disabled:!!disabled,
|
||||
onClick:e=>{if(!disabled)onClick?.(e);}}, children || label));
|
||||
const DialogCheckbox = ({label, checked, defaultChecked, disabled, onChange}) =>
|
||||
R.createElement('label', null,
|
||||
R.createElement('input', {type:'checkbox', checked, defaultChecked, disabled:!!disabled,
|
||||
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}), label);
|
||||
const TextField = ({label, description, value, placeholder, disabled, bIsPassword,
|
||||
mustBeNumeric, mustBeURL, mustBeEmail, onChange}) => R.createElement(Field, {label, description},
|
||||
R.createElement('input', {type:bIsPassword?'password':mustBeNumeric?'number':mustBeURL?'url':mustBeEmail?'email':'text',
|
||||
@@ -147,9 +151,9 @@
|
||||
const DFL = window.DFL = {
|
||||
definePlugin: init => init,
|
||||
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem,
|
||||
Dropdown: DropdownItem, ButtonItem, TextField, SliderField,
|
||||
Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField,
|
||||
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
|
||||
DialogButton: passthrough('button'), ConfirmModal,
|
||||
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
|
||||
staticClasses: {Title:'framey-title'},
|
||||
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null),
|
||||
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
|
||||
|
||||
+8
-4
@@ -17,7 +17,7 @@ class PanelError(ValueError):
|
||||
|
||||
|
||||
def local_bundle(plugin_id):
|
||||
"""Read only an API v2 bundle with a store receipt; reject linked paths."""
|
||||
"""Read a modern Decky bundle with a store receipt; reject linked paths."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0 or not PLUGINS.is_dir() or PLUGINS.is_symlink():
|
||||
raise FileNotFoundError('Framey plugin unavailable')
|
||||
for folder in PLUGINS.iterdir():
|
||||
@@ -43,11 +43,15 @@ def local_bundle(plugin_id):
|
||||
if not isinstance(data, dict):
|
||||
raise FileNotFoundError('Framey plugin manifest unavailable')
|
||||
flags = data.get('flags', [])
|
||||
if data.get('api_version') != 2 or not isinstance(flags, list) or any(x in flags for x in ('root', '_root')):
|
||||
raise FileNotFoundError('This Decky frontend is not API v2 or needs root')
|
||||
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 not entry.is_file() or entry.stat().st_size > 8*1024*1024:
|
||||
raise FileNotFoundError('Framey plugin frontend unavailable')
|
||||
return entry.read_bytes()
|
||||
bundle = entry.read_bytes()
|
||||
if version == 1 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):
|
||||
raise FileNotFoundError('Framey plugin files are invalid')
|
||||
raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store')
|
||||
|
||||
Reference in new issue
Block a user