mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 08:01:53 +01:00
Merge pull request #270 from saphid/feat/framey-next-compat
Framey: relay real Decky backend events to open panels
This commit is contained in:
16 files changed
+833
-23
No files matched your search
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:12:47.296778+00:00",
|
||||
"method": "Local Chromium actual Framey headset page and opaque official plugin iframe; production method/event bridge relayed by SSH to real ARM64 Decky 3.3.0 in a private removable user service. Test relay restricts RPC methods and timer durations. Backend code is unmodified, no fake plugin replies. Only file paths to private install and outer status/data APIs differ. Not worn VR.",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"hostSourceSha256": {
|
||||
"framey-host.js": "3bad52546c0b78c20e6479c91899f1f3864b74687c6fce37e776abededfb5f1c",
|
||||
"framey-events.js": "89046f4c89bdab0f578b910170a044d5ada540db0f7b71d6a370c68b4b008c3f",
|
||||
"framey_panel.py": "8ca4b3ba5f0d51c51bb3601f9ccd24c4af397fbf8f63c383a3042e1ae3f7b39b",
|
||||
"headset.html": "ebaf1711639a929ec7287c01dbbbe8eb08f000b852fdc877f31c9d99e5fe69fc"
|
||||
},
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"powerSettingsChanged": false,
|
||||
"plugins": [
|
||||
{
|
||||
"id": 19,
|
||||
"name": "Notebook",
|
||||
"version": "0.1.5",
|
||||
"sha256": "c35b09de4bba4b4157fbe84cb7b0d20fc3924f78fa3c48ae17686b4314c1cc0c",
|
||||
"calls": [
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 0,
|
||||
"empty": true,
|
||||
"data": ""
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "savePage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"timestamp": 1792973680303
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [
|
||||
{
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [],
|
||||
"checks": {
|
||||
"pointerStrokeSaved": true,
|
||||
"sameStrokeLoadedAfterBackendRestart": true,
|
||||
"defaultAppId": 0,
|
||||
"existingNotesTouched": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312",
|
||||
"calls": [
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
300
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "cancel_timer",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
5
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
294.9945251941681
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
289.99201130867004
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
284.9877219200134
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
-0.005679607391357422
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"event": "simple_timer_event",
|
||||
"args": [
|
||||
"Your session has ended!",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"uiStart300Seconds": true,
|
||||
"uiCancel": true,
|
||||
"shortTimer5Seconds": true,
|
||||
"completionToastVisible": true,
|
||||
"recentTimersAndSubtleModeSurvivedRestart": true,
|
||||
"subscriptionsAfterPanelClose": 0
|
||||
},
|
||||
"upstreamError": "Simple Timer 1.0.0 _main after restarting with an expired timer raises AttributeError: 'Plugin' object has no attribute 'timer_task' (main.py:94,129). Frontend read requests still load recent timers and subtle mode."
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"serviceActive": false,
|
||||
"independentlyRechecked": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
"regressions": {
|
||||
"unitTests": 1606,
|
||||
"unitSkips": 16,
|
||||
"browserPlugins": 116,
|
||||
"browserReady": 96,
|
||||
"browserFailed": 20,
|
||||
"fakeFrameInstalled": 60,
|
||||
"fakeFrameRejected": 56
|
||||
},
|
||||
"limitations": [
|
||||
"Timer sound asset and suspend action unsupported; no suspend/power action run.",
|
||||
"Closing a Framey panel removes its event channel; it is not a global background notification service.",
|
||||
"Notebook default notebook only (appid 0): no per-game auto-selection or background artwork.",
|
||||
"No worn VR, physical controller, keyboard, or optical check."
|
||||
]
|
||||
}
|
||||
@@ -67,7 +67,8 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `SteamClient` subscriptions and `DFL.Router.MainRunningApp` | Explicit inert registration methods return disposable handles; no events or attached running game. Mutating Steam/power methods are unavailable. | Host contract tests; these are safe fallbacks, not live Steam functionality |
|
||||
| `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. |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e; real ARM64 Notebook save/reopen across backend restart and Simple Timer settings/recents persistence via local Chromium. No worn-VR claim. |
|
||||
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
|
||||
| `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. |
|
||||
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
|
||||
@@ -75,7 +76,7 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
| `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
| `fetchNoCors`, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
|
||||
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
|
||||
|
||||
@@ -4,9 +4,9 @@ Snapshot: 2026-10-25T23:19:48.854504+00:00; 116 current visible official plugins
|
||||
|
||||
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
|
||||
|
||||
**Works: 0 · Partial: 43 · Broken: 62 · N/A: 11.**
|
||||
**Works: 1 · Partial: 42 · Broken: 62 · N/A: 11.**
|
||||
|
||||
- **Works** requires verified useful plugin behavior with its real backend. None qualifies from this audit.
|
||||
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
|
||||
- **Partial** means a limited frontend/settings surface initialized or rendered. Backend-dependent functionality remains unverified; it is not an end-to-end success claim.
|
||||
- **Broken** means a concrete archive, frontend or Steam dependency blocks the audited version. An artificial backend/network denial alone is not evidence that the real backend is broken.
|
||||
- **N/A** means the plugin targets hardware or a display/UI surface that Framey does not provide. The store disables installation for these versions.
|
||||
@@ -27,7 +27,7 @@ Open the browser audit URL in local Chromium, run `runAudit()` and inspect/downl
|
||||
|
||||
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
|
||||
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. All other official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md).
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. All other official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md).
|
||||
|
||||
## Every current plugin
|
||||
|
||||
@@ -44,7 +44,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: Cannot read properties of null (reading 'm_GamepadUIAudioStore'). | static archive, local browser, fake-Frame |
|
||||
| 18: Bluetooth 3.1.1 | 1 / 2 | partial | Controls render with fixture replies; actual Bluetooth discovery, labels and connections remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 19: Notebook 0.1.5 | legacy / legacy | partial | Notebook initializes; real note load/save remains unverified. | static archive, local browser, fake-Frame |
|
||||
| 19: Notebook 0.1.5 | legacy / legacy | works | Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Per-game auto-selection and background artwork unavailable; worn-VR input unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 21: PowerTools 2.0.3 | legacy / legacy | N/A | Not supported on Frame: Targets AMD Ryzen power controls; Frame uses an ARM64 SoC. | static archive, local browser, fake-Frame |
|
||||
| 22: Memory Deck 0.1.8 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: memory-deck/bin/libscanmem.so.1.0.0. | static archive, local browser, fake-Frame |
|
||||
| 23: Animation Changer 1.3.2 | legacy / legacy | partial | Initializer succeeds; forced backend failure prevents loading animations. Boot-video changes untested. | static archive, local browser, fake-Frame |
|
||||
@@ -102,7 +102,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 93: Deck Settings 1.2.6 | 1 / 2 | broken | Initializer succeeds; game selection fails because SteamClient.InstallFolder.GetInstallFolders is unavailable. | static archive, local browser, fake-Frame |
|
||||
| 94: Decky DoT DNS 0.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 95: Decky-SpoofDPI 0.12.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-spoof-dpi/bin/spoofdpi. | static archive, local browser, fake-Frame |
|
||||
| 96: Simple Timer 1.0.0 | 1 / 2 | partial | Timer panel initializes; live timer persistence, notifications and audio are unverified. | static archive, local browser, fake-Frame |
|
||||
| 96: Simple Timer 1.0.0 | 1 / 2 | partial | Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 97: Decky-Framegen 0.11.15 | 1 / 2 | broken | Plugin archive size or SHA-256 did not match the store. | static archive, local browser, fake-Frame |
|
||||
| 98: Decky-Lookup 0.1.0 | 1 / 2 | partial | Initializer succeeds; game-page context actions and live lookups remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 99: Decky Zerotier 0.3.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
|
||||
@@ -51,6 +51,25 @@ 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.
|
||||
|
||||
Decky API v1/v2 `addEventListener` and `removeEventListener` receive real backend
|
||||
events while the panel is open. The parent resolves the plugin name from its
|
||||
installed store receipt, polls Steam's existing Decky event connection, and sends
|
||||
only subscribed events to that panel session. A subscription is established
|
||||
before a backend call can emit its response. The bridge allows 32 event names,
|
||||
queues at most 64 events and 48 KiB, and drops any event whose arguments exceed
|
||||
8 KiB. Polling occurs about once a second, so this is unsuitable for high-rate
|
||||
input/audio streams. Closing/reopening a panel removes its subscriptions;
|
||||
abandoned CEF subscriptions expire after 15 seconds. This does not deliver
|
||||
global notifications while a Framey panel is closed.
|
||||
|
||||
The [26 October live checks](evidence/framey-events-backend-2026-10-26.json)
|
||||
used the actual official Notebook and Simple Timer Python backends in a private
|
||||
removable ARM64 Decky service. Notebook's default notebook saved a pointer stroke
|
||||
and loaded it after a backend restart. Simple Timer's start/cancel controls,
|
||||
countdown, completion toast and persisted recents/subtle mode worked. Simple
|
||||
Timer remains partial: its own expired-timer startup raises a missing
|
||||
`timer_task` error, sound/suspend are unsupported, and worn VR was not checked.
|
||||
|
||||
The host currently supplies sections, rows, buttons, toggles, text fields,
|
||||
sliders, grouped dropdowns,
|
||||
scroll containers, a modal, locale information, local panel routes and toasts,
|
||||
|
||||
@@ -44,6 +44,7 @@ addEventListener('message',event=>{
|
||||
if(data.type==='audit:log'&&active.logs.length<8)active.logs.push({kind:data.kind,message:data.message.split('\\n')[0].slice(0,400)});
|
||||
if(data.type==='framey:error')active.error=String(data.message).slice(0,500);
|
||||
if(data.type==='framey:ready')active.ready=true;
|
||||
if(data.type==='framey:events')event.source.postMessage({type:'framey:reply',id:data.id,result:{events:[],cursor:0}},'*');
|
||||
if(data.type==='framey:call'){
|
||||
if(!active.methods.includes(data.method))active.methods.push(data.method);
|
||||
event.source.postMessage({type:'framey:reply',id:data.id,error:'Read-only compatibility audit: backend method blocked'},'*');}
|
||||
|
||||
@@ -22,7 +22,7 @@ def render(data):
|
||||
'Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. '
|
||||
'Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.', '',
|
||||
f"**Works: {counts['works']} · Partial: {counts['partial']} · Broken: {counts['broken']} · N/A: {counts['na']}.**", '',
|
||||
'- **Works** requires verified useful plugin behavior with its real backend. None qualifies from this audit.',
|
||||
'- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.',
|
||||
'- **Partial** means a limited frontend/settings surface initialized or rendered. Backend-dependent functionality remains unverified; it is not an end-to-end success claim.',
|
||||
'- **Broken** means a concrete archive, frontend or Steam dependency blocks the audited version. An artificial backend/network denial alone is not evidence that the real backend is broken.',
|
||||
'- **N/A** means the plugin targets hardware or a display/UI surface that Framey does not provide. The store disables installation for these versions.', '',
|
||||
@@ -46,7 +46,12 @@ def render(data):
|
||||
'Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. '
|
||||
'The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, '
|
||||
'and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) '
|
||||
'record exactly what ran. All other official backends remain unexecuted. '
|
||||
'record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) '
|
||||
'verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, '
|
||||
'countdown/completion events, a visible toast and persisted recents/subtle mode. '
|
||||
'Only private test settings were written. Simple Timer remains partial because its own expired-timer startup '
|
||||
'raises a missing `timer_task` error; sound/suspend are unsupported. '
|
||||
'All other official backends remain unexecuted. '
|
||||
'The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. '
|
||||
'No worn-VR official-plugin result is claimed. '
|
||||
'Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), '
|
||||
|
||||
@@ -206,3 +206,35 @@ assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
|
||||
removeGame();
|
||||
assert.equal(window.DFL.useParams().appid,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
|
||||
|
||||
// Event subscription is acknowledged before a method that immediately emits.
|
||||
const onMessage=listeners.find(x=>x.type==='message').handler;
|
||||
const eventValues=[];
|
||||
const callback=(...args)=>eventValues.push(args);
|
||||
assert.equal(api.addEventListener('timer:tick',callback),callback);
|
||||
const subscribe=messages.at(-1);
|
||||
assert.equal(subscribe.type,'framey:events');
|
||||
assert.deepEqual(Array.from(subscribe.args[0]),['timer:tick']);
|
||||
const before=messages.length;
|
||||
const timerCall=api.call('load_remaining_seconds');
|
||||
assert.equal(messages.length,before);
|
||||
// Other windows cannot inject backend replies.
|
||||
onMessage({source:{},data:{type:'framey:reply',id:subscribe.id,result:{events:[],cursor:0}}});
|
||||
await Promise.resolve();assert.equal(messages.length,before);
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:subscribe.id,
|
||||
result:{events:[{event:'timer:tick',args:[12,'seconds']}],cursor:1}}});
|
||||
for(let i=0;i<12;i++)await Promise.resolve();
|
||||
assert.deepEqual(eventValues,[[12,'seconds']]);
|
||||
const timerRequest=messages.at(-1);
|
||||
assert.equal(timerRequest.type,'framey:call');assert.equal(timerRequest.method,'load_remaining_seconds');
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:timerRequest.id,result:null}});await timerCall;
|
||||
api.removeEventListener('timer:tick',callback);
|
||||
const unsubscribeRequest=messages.at(-1);assert.equal(unsubscribeRequest.type,'framey:events');
|
||||
assert.equal(unsubscribeRequest.args[0].length,0);
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:unsubscribeRequest.id,
|
||||
result:{events:[{event:'timer:tick',args:[99]}],cursor:2}}});
|
||||
for(let i=0;i<12;i++)await Promise.resolve();assert.equal(eventValues.length,1);
|
||||
assert.throws(()=>api.addEventListener('bad event',callback),/Invalid Decky event/);
|
||||
assert.throws(()=>api.addEventListener('tick',null),/Invalid Decky event/);
|
||||
listeners.find(x=>x.type==='unload').handler();
|
||||
assert.equal(messages.at(-1).type,'framey:events:close');
|
||||
@@ -0,0 +1,49 @@
|
||||
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 source=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
|
||||
const elements=new Map(),listeners=[],requests=[],replies=[];
|
||||
const el=id=>{if(!elements.has(id))elements.set(id,{removeAttribute(){},scrollIntoView(){}});return elements.get(id);};
|
||||
const frame={postMessage:data=>replies.push(data)};
|
||||
el('frameyFrame').contentWindow=frame;
|
||||
const context=vm.createContext({document:{getElementById:el,createElement:()=>({})},
|
||||
location:{hash:'',pathname:'/'},history:{replaceState(){}},
|
||||
sessionStorage:{getItem:()=>null,setItem(){}},localStorage:{getItem:()=>null,setItem(){}},
|
||||
window:{addEventListener:(type,fn)=>listeners.push({type,fn})},
|
||||
setTimeout(){},setInterval(){},console,
|
||||
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
|
||||
vm.runInContext(source,context);
|
||||
vm.runInContext('deckyState={plugins:[{storeId:96}]};openFrameyPanel(96,"Timer")',context);
|
||||
let session=vm.runInContext('frameySession',context);
|
||||
const onMessage=listeners.find(x=>x.type==='message').fn;
|
||||
const send=(data,origin=frame)=>onMessage({source:origin,data});
|
||||
const subscription={type:'framey:events',id:1,session,args:[['tick'],0]};
|
||||
await send({...subscription,session:'old'});
|
||||
await send(subscription,{});
|
||||
await send({...subscription,args:[['bad event'],0]});
|
||||
assert.equal(requests.length,0);
|
||||
const first=send(subscription);
|
||||
assert.equal(requests.length,1);
|
||||
assert.equal(requests[0].path,'/api/framey/events');
|
||||
assert.equal(requests[0].body.id,96);
|
||||
assert.equal(requests[0].body.session,session);
|
||||
// WindowProxy survives navigation: stale same-plugin replies need a session check.
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
const secondSession=vm.runInContext('frameySession',context);
|
||||
assert.notEqual(secondSession,session);
|
||||
assert.equal(requests[1].body.events.length,0); // Closes only the original subscription.
|
||||
assert.equal(requests[1].body.session,session);
|
||||
requests[0].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[42]}],cursor:1})});
|
||||
await first;assert.equal(replies.length,0);
|
||||
await send({type:'framey:call',id:2,session,method:'cancel_timer',args:[]});
|
||||
assert.equal(requests.length,2); // Old document cannot call a newly opened plugin.
|
||||
session=secondSession;
|
||||
const second=send({...subscription,id:3,session});
|
||||
requests[2].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[7]}],cursor:2})});
|
||||
await second;
|
||||
assert.equal(replies.length,1);assert.equal(replies[0].result.events[0].args[0],7);
|
||||
el('frameyClose').onclick();
|
||||
assert.equal(requests[3].body.session,session);assert.equal(requests[3].body.events.length,0);
|
||||
for(const request of requests.slice(1))request.resolve({ok:true,json:async()=>({events:[],cursor:0})});
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source=readFileSync(new URL('../../ui/framey-events.js',import.meta.url),'utf8');
|
||||
const handlers=new Set(),timers=new Map();let now=1000,nextTimer=0;
|
||||
const backend={addEventListener(name,listener){assert.equal(name,'loader/plugin_event');handlers.add(listener);},
|
||||
removeEventListener(name,listener){assert.equal(name,'loader/plugin_event');handlers.delete(listener);}};
|
||||
const window={DeckyBackend:backend};
|
||||
const context=vm.createContext({window,Date:{now:()=>now},
|
||||
setInterval:fn=>{timers.set(++nextTimer,fn);return nextTimer;},clearInterval:id=>timers.delete(id)});
|
||||
const poll=(session='one',name='Simple Timer',events=['tick'],cursor=0)=>
|
||||
JSON.parse(JSON.stringify(vm.runInContext(source,context)({session,name,events,cursor})));
|
||||
const emit=(plugin,event,...args)=>{for(const handler of handlers)handler({plugin,event,args});};
|
||||
assert.deepEqual(poll(),{events:[],cursor:0});
|
||||
assert.equal(handlers.size,1);
|
||||
emit('Other plugin','tick',10);emit('Simple Timer','other',99);emit('Simple Timer','tick',42,'seconds');
|
||||
assert.deepEqual(poll().events,[{sequence:1,event:'tick',args:[42,'seconds']}]);
|
||||
assert.equal(handlers.size,1); // Polling never duplicates the backend subscription.
|
||||
assert.deepEqual(poll('one','Simple Timer',['tick'],1).events,[]);
|
||||
assert.equal(poll().events.length,1); // Retry a lost HTTP response without losing its event.
|
||||
assert.throws(()=>poll('one','Another plugin'),/belongs to another plugin/);
|
||||
poll('two','Another plugin');emit('Another plugin','tick',123);
|
||||
assert.deepEqual(poll('two','Another plugin').events[0].args,[123]);
|
||||
assert.equal(poll().events.length,1);
|
||||
emit('Simple Timer','tick','x'.repeat(8193));assert.equal(poll().events.length,1);
|
||||
for(let index=0;index<100;index++)emit('Simple Timer','tick',index);
|
||||
assert.equal(poll().events.length,64);
|
||||
assert.equal(poll().events.at(-1).args[0],99);
|
||||
for(let index=0;index<20;index++)emit('Simple Timer','tick','x'.repeat(8000));
|
||||
assert.ok(JSON.stringify(poll()).length<50*1024);
|
||||
poll('one','Simple Timer',[]);assert.equal(handlers.size,1);
|
||||
now+=16000;for(const timer of [...timers.values()])timer();
|
||||
assert.equal(handlers.size,0);assert.equal(timers.size,0);
|
||||
poll();assert.equal(handlers.size,1);
|
||||
// A Decky frontend reconnect must register against the replacement backend.
|
||||
let replaced=0;
|
||||
window.DeckyBackend={...backend,addEventListener(name,fn){replaced++;backend.addEventListener(name,fn);}};
|
||||
poll();assert.equal(replaced,1);assert.equal(handlers.size,1);
|
||||
poll('one','Simple Timer',[]);assert.equal(handlers.size,0);
|
||||
@@ -20,6 +20,46 @@ import server
|
||||
|
||||
|
||||
class Panel(unittest.TestCase):
|
||||
def test_decky_event_queue_scope_bounds_expiry_and_reconnect(self):
|
||||
for name in ('framey_events.mjs', 'framey_event_parent.mjs'):
|
||||
with self.subTest(script=name):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page'/name)],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_events_validate_before_ssh_and_resolve_name_from_store_receipt(self):
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
import shlex
|
||||
payload = json.loads(shlex.split(command)[2])
|
||||
self.assertEqual(payload, {'name':'Simple Timer', 'session':'test-123.45',
|
||||
'events':['timer:tick'], 'cursor':5})
|
||||
compile(stdin, '<event poll>', 'exec')
|
||||
self.assertIn('loader/plugin_event', stdin)
|
||||
return json.dumps({'events':[{'event':'timer:tick', 'args':[42]}], 'cursor':6})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':96,'name':'Simple Timer'}]}):
|
||||
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5)['cursor'], 6)
|
||||
for plugin_id, session, events, cursor in [
|
||||
(True,'test',['tick'],0), (96,'bad session',['tick'],0),
|
||||
(96,'test',['a']*33,0), (96,'test',['bad event'],0),
|
||||
(96,'test',[None],0), (96,'test',['tick'],-1),
|
||||
(96,'test',['tick'],True), (96,'test',['tick'],2**53),
|
||||
(19,'test',['tick'],0),
|
||||
]:
|
||||
with self.subTest(plugin_id=plugin_id, session=session, events=events, cursor=cursor):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.poll_events(lambda *a, **k:self.fail('Unexpected SSH'),
|
||||
plugin_id, session, events, cursor)
|
||||
|
||||
def test_event_action_has_same_headset_boundary_as_methods(self):
|
||||
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.framey_events_action({'id':96,'session':'test','events':['tick']})
|
||||
with patch.object(server, 'HEADSET_RUNTIME', True), \
|
||||
patch.object(framey_panel, 'poll_events', return_value={'events':[], 'cursor':0}) as poll:
|
||||
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick']})['events'], [])
|
||||
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0)
|
||||
|
||||
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)
|
||||
@@ -124,6 +164,7 @@ class Panel(unittest.TestCase):
|
||||
with tarfile.open(fileobj=io.BytesIO(gzip.decompress(packed))) as archive:
|
||||
names = set(archive.getnames())
|
||||
self.assertTrue({'ui/framey_panel.py','ui/framey-host.js',
|
||||
'ui/framey-events.js',
|
||||
'ui/vendor/react-18.3.1.min.js','ui/vendor/react-dom-18.3.1.min.js',
|
||||
'ui/vendor/REACT-LICENSE.txt','ui/vendor/REACT-DOM-LICENSE.txt'} <= names)
|
||||
|
||||
|
||||
@@ -1005,13 +1005,14 @@
|
||||
"https://github.com/popsUlfr/SDH-Notebook",
|
||||
"https://github.com/popsUlfr/SDH-Notebook.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Notebook initializes; real note load/save remains unverified.",
|
||||
"status": "works",
|
||||
"reason": "Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Per-game auto-selection and background artwork unavailable; worn-VR input unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 19,
|
||||
@@ -1069,6 +1070,16 @@
|
||||
"sha256": "c35b09de4bba4b4157fbe84cb7b0d20fc3924f78fa3c48ae17686b4314c1cc0c",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"realFrame": {
|
||||
"evidence": "docs/evidence/framey-events-backend-2026-10-26.json",
|
||||
"checks": {
|
||||
"pointerStrokeSaved": true,
|
||||
"sameStrokeLoadedAfterBackendRestart": true,
|
||||
"defaultAppId": 0,
|
||||
"existingNotesTouched": false
|
||||
},
|
||||
"method": "local Chromium + real Frame SSH; private actual Decky 3.3.0 ARM64 service"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7436,12 +7447,13 @@
|
||||
"https://github.com/decktools-xyz/simple-timer.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Timer panel initializes; live timer persistence, notifications and audio are unverified.",
|
||||
"reason": "Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 96,
|
||||
@@ -7471,6 +7483,18 @@
|
||||
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"realFrame": {
|
||||
"evidence": "docs/evidence/framey-events-backend-2026-10-26.json",
|
||||
"checks": {
|
||||
"uiStart300Seconds": true,
|
||||
"uiCancel": true,
|
||||
"shortTimer5Seconds": true,
|
||||
"completionToastVisible": true,
|
||||
"recentTimersAndSubtleModeSurvivedRestart": true,
|
||||
"subscriptionsAfterPanelClose": 0
|
||||
},
|
||||
"method": "local Chromium + real Frame SSH; private actual Decky 3.3.0 ARM64 service"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -13342,5 +13366,5 @@
|
||||
}
|
||||
],
|
||||
"fakeFrameMethod": "116 exact archives validated and attempted through real HTTP/SSH in private disposable fake Frame; 60 installed, frontend bytes/receipt checked, then removed; 56 rejected. Official backend code never executed.",
|
||||
"realFrameMethod": "2026-10-25: shared lock held; isolated temporary Decky 3.3.0 ARM64 user service with only exact ProtonDB/Steam Status backends; read-only settings RPCs succeeded via existing Steam CEF websocket. Temporary service/base removed and lock released; battery 100 to 99; no power settings changed. No official plugin panel was opened in VR."
|
||||
"realFrameMethod": "2026-10-25: shared lock held; isolated temporary Decky 3.3.0 ARM64 user service with only exact ProtonDB/Steam Status backends; read-only settings RPCs succeeded via existing Steam CEF websocket. Temporary service/base removed and lock released; battery 100 to 99; no power settings changed. No official plugin panel was opened in VR. Follow-up 2026-10-26: safe isolated Notebook draw/save/reopen and Simple Timer event/start/cancel/persistence checks; see docs/evidence/framey-events-backend-2026-10-26.json."
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* Executed in Steam CEF, using Decky 3.3.0's loader/plugin_event envelope. */
|
||||
(function(payload) {
|
||||
const backend = window.DeckyBackend;
|
||||
if (!backend?.addEventListener || !backend?.removeEventListener)
|
||||
throw Error('Decky backend events are unavailable');
|
||||
let bridge = window.__frameyEventBridgeV1;
|
||||
if (bridge && bridge.backend !== backend) {
|
||||
for (const key of [...bridge.sessions.keys()]) bridge.remove(key);
|
||||
bridge = null;
|
||||
}
|
||||
if (!bridge) {
|
||||
const sessions = new Map();
|
||||
const remove = key => {
|
||||
const item = sessions.get(key);
|
||||
if (item) backend.removeEventListener('loader/plugin_event', item.listener);
|
||||
sessions.delete(key);
|
||||
if (!sessions.size) {clearInterval(bridge.timer); bridge.timer = null;}
|
||||
};
|
||||
bridge = window.__frameyEventBridgeV1 = {backend, sessions, remove, timer:null};
|
||||
}
|
||||
const key = payload.session;
|
||||
let item = bridge.sessions.get(key);
|
||||
if (item && item.name !== payload.name) throw Error('Framey event session belongs to another plugin');
|
||||
if (!payload.events.length) {
|
||||
bridge.remove(key);
|
||||
return {events:[], cursor:0};
|
||||
}
|
||||
if (!item) {
|
||||
if (bridge.sessions.size >= 4) throw Error('Too many Framey event sessions');
|
||||
item = {name:payload.name, names:new Set(), queue:[], sequence:0, touched:Date.now()};
|
||||
item.listener = data => {
|
||||
if (data?.plugin !== item.name || !item.names.has(data.event) || !Array.isArray(data.args)) return;
|
||||
try {
|
||||
const encoded = JSON.stringify(data.args);
|
||||
if (encoded.length > 8192) return;
|
||||
item.queue.push({sequence:++item.sequence, event:data.event, args:JSON.parse(encoded)});
|
||||
// Bound both event count and the CDP response, even for a noisy plugin.
|
||||
while (item.queue.length > 64 || JSON.stringify(item.queue).length > 48*1024) item.queue.shift();
|
||||
} catch (_) {} // Non-JSON event data cannot cross the iframe bridge.
|
||||
};
|
||||
bridge.sessions.set(key, item);
|
||||
backend.addEventListener('loader/plugin_event', item.listener);
|
||||
if (!bridge.timer) bridge.timer = setInterval(() => {
|
||||
for (const [session, state] of bridge.sessions)
|
||||
if (Date.now() - state.touched > 15000) bridge.remove(session);
|
||||
}, 5000);
|
||||
}
|
||||
item.names = new Set(payload.events);
|
||||
item.touched = Date.now();
|
||||
return {events:item.queue.filter(event => event.sequence > payload.cursor && item.names.has(event.event)),
|
||||
cursor:item.sequence};
|
||||
})
|
||||
+63
-4
@@ -29,6 +29,9 @@
|
||||
let currentParams = {};
|
||||
let nextId = 0;
|
||||
let plugin;
|
||||
const eventListeners = new Map();
|
||||
let eventCursor = 0, eventRevision = 0, subscribedRevision = -1;
|
||||
let eventRequest = null, eventTimer = null, eventClosed = false;
|
||||
// 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.
|
||||
@@ -63,15 +66,66 @@
|
||||
parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*');
|
||||
setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable.
|
||||
|
||||
function ask(method, args, legacy=false) {
|
||||
function request(method, args, legacy=false, type='framey:call') {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(Error('Decky backend timed out')); }, 30000);
|
||||
pending.set(id, {resolve, reject, timer});
|
||||
parent.postMessage({type:'framey:call', id, method, args, legacy}, '*');
|
||||
parent.postMessage({type, id, method, args, legacy, session:cacheSession}, '*');
|
||||
});
|
||||
}
|
||||
function syncEvents() {
|
||||
if (eventClosed) return Promise.resolve();
|
||||
if (eventRequest) return eventRequest.then(() =>
|
||||
subscribedRevision !== eventRevision ? syncEvents() : undefined);
|
||||
const revision = eventRevision;
|
||||
eventRequest = request('events', [[...eventListeners.keys()], eventCursor], false, 'framey:events')
|
||||
.then(data => {
|
||||
subscribedRevision = revision;
|
||||
if (!data || !Array.isArray(data.events) || !Number.isSafeInteger(data.cursor))
|
||||
throw Error('Invalid Decky event response');
|
||||
eventCursor = data.cursor;
|
||||
for (const event of data.events) {
|
||||
if (!Array.isArray(event.args)) continue;
|
||||
for (const listener of eventListeners.get(event.event) || [])
|
||||
Promise.resolve().then(() => listener(...event.args)).catch(failure =>
|
||||
console.warn('Decky event listener failed', failure));
|
||||
}
|
||||
}).finally(() => {eventRequest = null;});
|
||||
return eventRequest.then(() => subscribedRevision !== eventRevision ? syncEvents() : undefined);
|
||||
}
|
||||
function refreshEvents() {
|
||||
clearTimeout(eventTimer);
|
||||
syncEvents().catch(failure => console.warn('Decky event bridge unavailable', failure.message))
|
||||
.finally(() => {
|
||||
clearTimeout(eventTimer);
|
||||
if (!eventClosed && eventListeners.size)
|
||||
eventTimer = setTimeout(refreshEvents, 1000);
|
||||
});
|
||||
}
|
||||
function addPluginEvent(event, listener) {
|
||||
if (typeof event !== 'string' || !/^[A-Za-z0-9_:/.-]{1,128}$/.test(event) || typeof listener !== 'function')
|
||||
throw Error('Invalid Decky event listener');
|
||||
if (!eventListeners.has(event)) {
|
||||
if (eventListeners.size >= 32) throw Error('Too many Decky event names');
|
||||
eventListeners.set(event, new Set());
|
||||
}
|
||||
eventListeners.get(event).add(listener);
|
||||
eventRevision++; refreshEvents();
|
||||
return listener; // Decky returns the registered callback, not a disposer.
|
||||
}
|
||||
function removePluginEvent(event, listener) {
|
||||
const listeners = eventListeners.get(event);
|
||||
if (!listeners?.delete(listener)) return;
|
||||
if (!listeners.size) eventListeners.delete(event);
|
||||
eventRevision++; refreshEvents();
|
||||
}
|
||||
async function ask(method, args, legacy=false) {
|
||||
// Subscribe before a backend call can emit its immediate response events.
|
||||
if (eventListeners.size && (eventRequest || subscribedRevision !== eventRevision)) await syncEvents();
|
||||
return request(method, args, legacy);
|
||||
}
|
||||
addEventListener('message', event => {
|
||||
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
|
||||
event.data.session === cacheSession && acceptSeed) {
|
||||
@@ -90,7 +144,12 @@
|
||||
pending.delete(event.data.id); clearTimeout(item.timer);
|
||||
if (event.data.error) item.reject(Error(event.data.error)); else item.resolve(event.data.result);
|
||||
});
|
||||
addEventListener('unload', () => { try { plugin?.onDismount?.(); } catch (_) {} });
|
||||
addEventListener('unload', () => {
|
||||
eventClosed = true; clearTimeout(eventTimer);
|
||||
try { plugin?.onDismount?.(); } catch (_) {}
|
||||
// Parent tears down the remote session on close; CEF also expires it after 15s.
|
||||
parent.postMessage({type:'framey:events:close', session:cacheSession}, '*');
|
||||
});
|
||||
addEventListener('pointerup', event => {
|
||||
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
|
||||
}, {passive:true});
|
||||
@@ -366,7 +425,7 @@
|
||||
_version: Math.min(version, 2),
|
||||
call: (method, ...args) => ask(method, args),
|
||||
callable: method => (...args) => ask(method, args),
|
||||
addEventListener: () => {}, removeEventListener: () => {},
|
||||
addEventListener:addPluginEvent, removeEventListener:removePluginEvent,
|
||||
routerHook, toaster,
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
executeInTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
|
||||
|
||||
@@ -9,6 +9,8 @@ import frame_decky
|
||||
|
||||
|
||||
METHOD = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,63}\Z')
|
||||
EVENT = re.compile(r'[A-Za-z0-9_:/.-]{1,128}\Z')
|
||||
SESSION = re.compile(r'[A-Za-z0-9_.-]{1,100}\Z')
|
||||
PLUGINS = Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
|
||||
|
||||
@@ -104,3 +106,36 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)),
|
||||
stdin=METHOD_SCRIPT, timeout=25))
|
||||
|
||||
|
||||
EVENT_SCRIPT = r'''
|
||||
import json, pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
|
||||
from frame_steam import Page
|
||||
payload = json.loads(sys.argv[1])
|
||||
source = __FRAMEY_EVENTS_SOURCE__
|
||||
page = Page()
|
||||
try: print(json.dumps(page.eval('(' + source + ')(' + json.dumps(payload) + ')')))
|
||||
finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def poll_events(ssh, plugin_id, session, events, cursor=0):
|
||||
"""Read only this store-owned plugin's subscribed events; never trust its claimed name."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(session, str) or not SESSION.fullmatch(session):
|
||||
raise PanelError('Invalid Framey event session')
|
||||
if (not isinstance(events, list) or len(events) > 32 or
|
||||
any(not isinstance(event, str) or not EVENT.fullmatch(event) for event in events)):
|
||||
raise PanelError('Invalid Decky event names')
|
||||
if type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
|
||||
raise PanelError('Invalid Decky event cursor')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'session':session, 'events':events, 'cursor':cursor}
|
||||
# Embed only our bundled source as a Python string; plugin data stays in JSON.
|
||||
source = Path(__file__).with_name('framey-events.js').read_text(encoding='utf-8')
|
||||
script = EVENT_SCRIPT.replace('__FRAMEY_EVENTS_SOURCE__', repr(source))
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)), stdin=script, timeout=25))
|
||||
+27
-4
@@ -197,6 +197,7 @@ byId('deckyJobCheck').onclick=checkDeckyJob;
|
||||
function openFrameyPanel(id,name){
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
|
||||
byId('frameyCompatibility').textContent=compatibilityText(installed);
|
||||
closeFrameyEvents();
|
||||
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
|
||||
byId('frameyPanelStatus').textContent='Loading plugin panel…';
|
||||
byId('frameyFrame').src=`/framey/panel/${id}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
|
||||
@@ -204,7 +205,13 @@ function openFrameyPanel(id,name){
|
||||
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
|
||||
byId('frameyPanelStatus').textContent='No compatible panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
let frameyEventsActive=false;
|
||||
function closeFrameyEvents(){
|
||||
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
frameyEventsActive=false;
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{closeFrameyEvents();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))||'{}');
|
||||
@@ -237,11 +244,27 @@ window.addEventListener('message',async event=>{
|
||||
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
|
||||
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
||||
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type!=='framey:call'||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const pluginId=activeFrameyId,reply={type:'framey:reply',id:req.id};
|
||||
if(req.type==='framey:events:close'){
|
||||
if(req.session===frameySession)closeFrameyEvents();return;
|
||||
}
|
||||
if(req.type==='framey:events'){
|
||||
if(req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||!Array.isArray(req.args)||
|
||||
req.args.length!==2||!Array.isArray(req.args[0])||req.args[0].length>32||
|
||||
req.args[0].some(name=>typeof name!=='string'||!/^[A-Za-z0-9_:/.-]{1,128}$/.test(name))||
|
||||
!Number.isSafeInteger(req.args[1])||req.args[1]<0)return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
frameyEventsActive=req.args[0].length>0;
|
||||
try{reply.result=await api('/api/framey/events',{id:pluginId,session,events:req.args[0],cursor:req.args[1]});}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
|
||||
event.source.postMessage(reply,'*');
|
||||
return;
|
||||
}
|
||||
if(req.type!=='framey:call'||req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
});
|
||||
window.addEventListener('pointerup',event=>{if(event.isTrusted)
|
||||
api('/api/framey/check',{kind:'input'}).catch(()=>{});},{passive:true});
|
||||
|
||||
@@ -586,6 +586,16 @@ def framey_method_action(body):
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
def framey_events_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.poll_events(ssh, body.get('id'), body.get('session'),
|
||||
body.get('events'), body.get('cursor', 0))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
def framey_check_action(body):
|
||||
"""Record only a live, explicitly started worn-headset check."""
|
||||
if not HEADSET_RUNTIME or body.get('kind') not in ('input', 'panel'):
|
||||
@@ -3452,6 +3462,7 @@ POST = {
|
||||
"/api/decky": decky,
|
||||
"/api/decky/plugin": decky_plugin,
|
||||
"/api/framey/method": framey_method_action,
|
||||
"/api/framey/events": framey_events_action,
|
||||
"/api/framey/check": framey_check_action,
|
||||
"/api/headset": headset_action,
|
||||
"/api/headset/quick": headset_quick,
|
||||
|
||||
Reference in new issue
Block a user