mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 00:02:24 +01:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d186dfbc60 | ||
|
|
020f93a187 | ||
|
|
69a912cc7d | ||
|
|
aa6078c11a | ||
|
|
e89ff39cff | ||
|
|
4cfbd5883f | ||
|
|
33fdd9d296 | ||
|
|
45bd789cb7 | ||
|
|
1db65d6c82 | ||
|
|
6b6ec64381 | ||
|
|
1e69ca4194 | ||
|
|
8296657a45 | ||
|
|
2b2d72ac9a | ||
|
|
fe4e09032d | ||
|
|
1356ea4df1 | ||
|
|
c79ba7571f |
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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Framey cache check, 25 October 2026
|
||||
|
||||
The official Steam Status 1.2.3 ZIP pinned in
|
||||
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
|
||||
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
|
||||
allow-popups"`, so its native storage remained unavailable. The new Framey
|
||||
storage facade accepted a set/get probe without a `SecurityError`. Steam
|
||||
Status rendered and issued four `get_settings` calls to a stub parent backend.
|
||||
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
|
||||
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
|
||||
snapshot and the plugin wrote the same cache key again. The browser log had
|
||||
zero `SecurityError` or `localStorage` errors.
|
||||
|
||||
The parent was a local test page with in-memory Decky backend replies. One
|
||||
public status fetch also failed in the test browser, so this check does not
|
||||
establish offline correctness or the freshness of Steam Status data. The
|
||||
per-plugin save/reopen protocol, stale panel session rejection, and cache
|
||||
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
|
||||
live Decky settings, VR visibility, and controller input remain unverified.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Framey session cache browser check, 25 October 2026
|
||||
|
||||
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
|
||||
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
|
||||
used a per-panel memory substitute. It did **not** change the iframe's sandbox
|
||||
flags or give plugin code Frame Control's UI key.
|
||||
|
||||
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
|
||||
from `tests/fixtures/framey-official/123.zip` (SHA-256
|
||||
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
|
||||
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
|
||||
four `get_settings` calls. That reply was a stub, not the Decky backend.
|
||||
|
||||
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
|
||||
The parent could not read the iframe document (`contentDocument` was null).
|
||||
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
|
||||
Managers and Players Online. Its public status request returned HTTP 200.
|
||||
No cache `SecurityError` appeared in the captured console, while the
|
||||
same archive had logged that error with the v0.4.34 host.
|
||||
|
||||
Node host tests also checked item updates, removal, clear, key order, string
|
||||
conversion, quota rejection, and preservation of an existing item after a
|
||||
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
|
||||
values **in memory**. Closing or reloading the panel loses this cache. This
|
||||
check did not test offline behavior, persistent plugin settings, a live Decky
|
||||
backend, SteamVR visibility, controller input, or headset performance.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Steam Status in the local Framey browser, 25 October 2026
|
||||
|
||||
This check used the unmodified official Steam Status 1.2.3 archive already
|
||||
pinned in `tests/fixtures/framey-official/123.zip`. Its SHA-256 is
|
||||
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`.
|
||||
The archive declares Decky API v2. No headset was contacted for this check.
|
||||
|
||||
On a Linux PC, we extracted the archive to a disposable plugin folder with a
|
||||
Frame Control store receipt. A local HTTP server served the page from
|
||||
`framey_panel.panel(123)`, Frame Control's React 18.3.1 runtime and its
|
||||
`framey-host.js`. A parent page put the panel in an iframe with
|
||||
`sandbox="allow-scripts"` and returned `{}` for the plugin's `get_settings`
|
||||
backend calls. This was a **stub**, not the Decky service.
|
||||
|
||||
Chromium reported `framey:ready` and four `get_settings` calls. The page showed
|
||||
Steam Status, Store, Community, WebAPI, Connection Managers, Players Online,
|
||||
and other controls. Its own public `https://steamstatus.schelstraete.org/api/v1/status`
|
||||
request returned HTTP 200 and populated the status view. A click on **Refresh**
|
||||
sent Framey's trusted pointer event to the parent; this test did not prove a
|
||||
fresh status fetch or settings write. The [browser screenshot](framey-steam-status-browser-2026-10-25.png)
|
||||
contains only the local test page and public service data.
|
||||
|
||||
The plugin logged `SecurityError` while reading and writing `localStorage` in
|
||||
the opaque-origin iframe. It caught the errors and rendered anyway. Its cache
|
||||
and offline behavior in Framey are **not verified**. The real Decky backend,
|
||||
live settings, SteamVR visibility, controller input and headset performance
|
||||
were not tested. This result supports only local frontend rendering for this
|
||||
exact archive and Frame Control v0.4.34 host code.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -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,12 +57,15 @@ 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 |
|
||||
| `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.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
@@ -71,3 +75,13 @@ 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
|
||||
plugin's cache or the Frame Control UI key. A real Steam Status panel reopen
|
||||
and a worn-headset settings check are still needed.
|
||||
+30
-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
|
||||
@@ -37,6 +39,12 @@ through Decky's *existing* websocket connection. Framey never opens a second
|
||||
Decky websocket, because Decky allows only one frontend connection. The iframe
|
||||
does not receive Frame Control's UI key. React and ReactDOM are bundled for
|
||||
offline use under their MIT licenses in `ui/vendor`.
|
||||
Framey gives each plugin a bounded `localStorage` substitute because browser
|
||||
storage is denied to an opaque-origin iframe. The parent panel keeps a separate
|
||||
cache for each plugin: up to 64 keys, 64 KiB per value and 128 KiB total. It
|
||||
restores the cache when a panel reopens if the parent's browser storage is
|
||||
available. If storage is denied or cleared, the cache lasts only for the current
|
||||
panel. Use the plugin backend for settings that must survive cache loss.
|
||||
|
||||
The host currently supplies sections, rows, buttons, toggles, text fields,
|
||||
sliders, grouped dropdowns,
|
||||
@@ -55,10 +63,22 @@ live Decky settings persistence, or badges on Steam game pages. The last item
|
||||
has no supported injection target in the Frame Control panel.
|
||||
The [browser and fake Frame evidence](evidence/framey-browser-2026-10-25.md)
|
||||
records exactly what was exercised.
|
||||
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.
|
||||
The pinned official Steam Status 1.2.3 frontend also
|
||||
[rendered in local Chromium](evidence/framey-steam-status-browser-2026-10-25.md)
|
||||
with a stubbed settings reply. Its public status request worked, but its cache
|
||||
logged `localStorage` errors in Framey's v0.4.34 opaque iframe. A later
|
||||
[local browser check](evidence/framey-session-cache-2026-10-25.md) confirmed
|
||||
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,
|
||||
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,
|
||||
@@ -70,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];
|
||||
@@ -85,9 +88,10 @@ assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav'
|
||||
// API v2 backend calls remain request-scoped and errors return to the caller.
|
||||
const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test');
|
||||
const reply=api.call('get_setting','mode','default');
|
||||
assert.equal(messages[0].method,'get_setting');
|
||||
assert.deepEqual(Array.from(messages[0].args),['mode','default']);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:messages[0].id,result:'dark'}});
|
||||
const call=messages.find(message=>message.type==='framey:call');
|
||||
assert.equal(call.method,'get_setting');
|
||||
assert.deepEqual(Array.from(call.args),['mode','default']);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:call.id,result:'dark'}});
|
||||
assert.equal(await reply,'dark');
|
||||
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
|
||||
const parentSource=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
|
||||
const hostSource=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8')
|
||||
.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
|
||||
assert.match(hostSource,/framey:cache:init/);
|
||||
const values=new Map();
|
||||
const localStorage={getItem:key=>values.get(key)??null,setItem:(key,value)=>values.set(key,value)};
|
||||
const elements=new Map();
|
||||
const el=id=>{if(!elements.has(id))elements.set(id,{id,hidden:false,removeAttribute(){},scrollIntoView(){}});
|
||||
return elements.get(id);};
|
||||
const parentListeners=[];
|
||||
const parent=vm.createContext({document:{getElementById:el,createElement:()=>({})},
|
||||
location:{hash:'',pathname:'/'},history:{replaceState(){}},
|
||||
sessionStorage:{getItem:()=>null,setItem(){}},localStorage,
|
||||
window:{addEventListener:(type,fn)=>parentListeners.push({type,fn})},
|
||||
setTimeout(){},setInterval(){},fetch(){throw Error('No network expected');},console});
|
||||
vm.runInContext(parentSource,parent);
|
||||
vm.runInContext('deckyState={plugins:[{storeId:14}]};openFrameyPanel(14,"Steam Status")',parent);
|
||||
let session=vm.runInContext('frameySession',parent);
|
||||
const onParent=parentListeners.find(x=>x.type==='message').fn;
|
||||
|
||||
function iframe(){
|
||||
const listeners=[];const queued=[];let live=false;
|
||||
const frameWindow={location:{hash:`#${session}`},React:{Fragment:'fragment',Component:class {},createElement:()=>({}),isValidElement:()=>true},
|
||||
ReactDOM:{createRoot:()=>({render(){}})}};
|
||||
const proxy={postMessage:message=>listeners.find(x=>x.type==='message')?.fn({source:hostParent,data:message})};
|
||||
el('frameyFrame').contentWindow=proxy;
|
||||
const hostParent={postMessage:message=>live?onParent({source:proxy,data:message}):queued.push(message)};
|
||||
const context=vm.createContext({window:frameWindow,document:{getElementById:()=>({})},
|
||||
navigator:{language:'en'},parent:hostParent,DOMException,
|
||||
addEventListener:(type,fn)=>listeners.push({type,fn}),setTimeout(){},clearTimeout(){}});
|
||||
vm.runInContext(hostSource,context);
|
||||
for(const message of queued)onParent({source:proxy,data:message});
|
||||
live=true;
|
||||
const send=message=>onParent({source:proxy,data:message});
|
||||
return {storage:frameWindow.localStorage,send};
|
||||
}
|
||||
let first=iframe();
|
||||
assert.equal(first.storage.getItem('status-cache'),null);
|
||||
first.storage.setItem('status-cache','{"online":true}');
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
|
||||
first.storage.setItem('temporary','discard');
|
||||
first.storage.removeItem('temporary');
|
||||
const oldSession=session;
|
||||
vm.runInContext('openFrameyPanel(14,"Steam Status")',parent);
|
||||
session=vm.runInContext('frameySession',parent);
|
||||
assert.notEqual(session,oldSession);
|
||||
let reopened=iframe();
|
||||
assert.equal(reopened.storage.getItem('status-cache'),'{"online":true}');
|
||||
assert.equal(reopened.storage.length,1);
|
||||
assert.equal(reopened.storage.key(0),'status-cache');
|
||||
assert.throws(()=>reopened.storage.setItem('huge','x'.repeat(65537)),/Framey plugin cache is full/);
|
||||
// A malformed or over-limit message from a plugin must not poison saved data.
|
||||
reopened.send({type:'framey:cache:save',session,entries:{huge:'x'.repeat(65537)}});
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
|
||||
reopened.send({type:'framey:cache:save',session:oldSession,entries:{poison:'yes'}});
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
|
||||
reopened.storage.clear();
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{}');
|
||||
@@ -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']}))
|
||||
|
||||
@@ -20,6 +20,11 @@ import server
|
||||
|
||||
|
||||
class Panel(unittest.TestCase):
|
||||
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_vr_store_browsing_and_search(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_store.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
@@ -53,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):
|
||||
|
||||
+48
-2
@@ -21,6 +21,36 @@
|
||||
let currentRoute = null;
|
||||
let nextId = 0;
|
||||
let plugin;
|
||||
// A sandboxed iframe has an opaque origin, so its native localStorage getter
|
||||
// throws SecurityError. Keep the familiar synchronous API inside the iframe;
|
||||
// the parent stores only this plugin's bounded snapshot under its own origin.
|
||||
const cache = new Map();
|
||||
const cacheLimit = 128 * 1024;
|
||||
const cacheSession = window.location?.hash.slice(1) || '';
|
||||
let acceptSeed;
|
||||
const seeded = new Promise(resolve => {acceptSeed = resolve;});
|
||||
const storage = {
|
||||
get length() {return cache.size;},
|
||||
key(index) {return [...cache.keys()][index] ?? null;},
|
||||
getItem(key) {return cache.get(String(key)) ?? null;},
|
||||
setItem(key, value) {
|
||||
const name = String(key), data = String(value);
|
||||
const next = new Map(cache); next.set(name, data);
|
||||
if (next.size > 64 || name.length > 256 || data.length > 65536 ||
|
||||
JSON.stringify(Object.fromEntries(next)).length > cacheLimit)
|
||||
throw new DOMException('Framey plugin cache is full', 'QuotaExceededError');
|
||||
cache.set(name, data); persistCache();
|
||||
},
|
||||
removeItem(key) {cache.delete(String(key)); persistCache();},
|
||||
clear() {cache.clear(); persistCache();},
|
||||
};
|
||||
function persistCache() {
|
||||
parent.postMessage({type:'framey:cache:save', session:cacheSession,
|
||||
entries:Object.fromEntries(cache)}, '*');
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', {configurable:true, value:storage});
|
||||
parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*');
|
||||
setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable.
|
||||
|
||||
function ask(method, args) {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
|
||||
@@ -32,6 +62,17 @@
|
||||
});
|
||||
}
|
||||
addEventListener('message', event => {
|
||||
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
|
||||
event.data.session === cacheSession && acceptSeed) {
|
||||
const entries = event.data.entries;
|
||||
if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
|
||||
for (const [key, value] of Object.entries(entries))
|
||||
if (key.length <= 256 && typeof value === 'string' && value.length <= 65536)
|
||||
cache.set(key, value);
|
||||
}
|
||||
acceptSeed(); acceptSeed = null;
|
||||
return;
|
||||
}
|
||||
if (event.source !== parent || !event.data || event.data.type !== 'framey:reply') return;
|
||||
const item = pending.get(event.data.id);
|
||||
if (!item) return;
|
||||
@@ -68,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',
|
||||
@@ -106,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'); }},
|
||||
@@ -197,6 +242,7 @@
|
||||
};
|
||||
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'}));
|
||||
let module;
|
||||
|
||||
+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')
|
||||
|
||||
+29
-4
@@ -103,7 +103,7 @@ 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, deckyBusy=false;
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=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.':
|
||||
@@ -186,17 +186,42 @@ async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.get
|
||||
byId('deckyJobCheck').onclick=checkDeckyJob;
|
||||
function openFrameyPanel(id,name){
|
||||
if(!(deckyState?.plugins||[]).some(p=>p.storeId===id))return;
|
||||
activeFrameyId=id;byId('frameyTitle').textContent=name;
|
||||
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
|
||||
byId('frameyPanelStatus').textContent='Loading plugin panel…';
|
||||
byId('frameyFrame').src=`/framey/panel/${id}`;
|
||||
byId('frameyFrame').src=`/framey/panel/${id}#${frameySession}`;
|
||||
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
|
||||
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
|
||||
byId('frameyPanelStatus').textContent='No compatible API v2 panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{activeFrameyId=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
byId('frameyClose').onclick=()=>{activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
function frameyCacheRead(id){
|
||||
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
|
||||
if(!data||typeof data!=='object'||Array.isArray(data))return {};
|
||||
const pairs=Object.entries(data);
|
||||
return pairs.length<=64&&pairs.every(([key,value])=>key.length<=256&&typeof value==='string'&&value.length<=65536)&&
|
||||
JSON.stringify(data).length<=128*1024?data:{};
|
||||
}catch(_){return {};}
|
||||
}
|
||||
function frameyCacheSave(id,entries){
|
||||
if(!entries||typeof entries!=='object'||Array.isArray(entries))return;
|
||||
const pairs=Object.entries(entries);
|
||||
if(pairs.length>64||pairs.some(([key,value])=>key.length>256||typeof value!=='string'||value.length>65536))return;
|
||||
const encoded=JSON.stringify(entries);
|
||||
if(encoded.length>128*1024)return;
|
||||
try{localStorage.setItem(frameyCacheKey(id),encoded);}catch(_){} // Private browsing can deny storage.
|
||||
}
|
||||
window.addEventListener('message',async event=>{
|
||||
if(activeFrameyId===null||event.source!==byId('frameyFrame').contentWindow)return;
|
||||
const req=event.data;if(!req||typeof req!=='object')return;
|
||||
if(req.type==='framey:cache:init'&&req.session===frameySession){
|
||||
event.source.postMessage({type:'framey:cache:seed',session:frameySession,
|
||||
entries:frameyCacheRead(activeFrameyId)},'*');return;
|
||||
}
|
||||
if(req.type==='framey:cache:save'){
|
||||
if(req.session===frameySession)frameyCacheSave(activeFrameyId,req.entries);
|
||||
return;
|
||||
}
|
||||
if(req.type==='framey:ready'){byId('frameyPanelStatus').textContent='Plugin panel ready';
|
||||
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
|
||||
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
||||
|
||||
Reference in new issue
Block a user