Compare commits

...
Author SHA1 Message Date
Alex Southwell 0a463dea23 Merge remote-tracking branch 'origin/main' into docs/real-mac-air-test
# Conflicts:
#	docs/current-support-matrix.md
2026-10-26 11:44:18 +11:00
Alex Southwell e6430c39ed Merge pull request #272 from saphid/docs/v0437-smoke-evidence
Record v0.4.37 real Frame smoke evidence
2026-10-26 00:41:35 +00:00
Alex Southwell f2e73d98f4 Merge pull request #271 from saphid/feat/framey-ui-fallbacks
Framey: restore detached settings and private Steam JSON cache
2026-10-26 00:39:30 +00:00
Alex Southwell 78d284b154 Merge pull request #270 from saphid/feat/framey-next-compat
Framey: relay real Decky backend events to open panels
2026-10-26 00:33:44 +00:00
SAPHID 2c4581f122 Record v0.4.37 real Frame release smoke 2026-10-26 00:31:56 +00:00
SAPHID 65fee8ca8d Fill detached Steam UI and private JSON cache fallbacks 2026-10-26 00:29:30 +00:00
Alex Southwell 4372c913a6 Merge pull request #269 from saphid/fix/framey-update-name-collision
Framey: reject plugin name collisions on update
2026-10-26 00:26:08 +00:00
SAPHID 52a0974a84 Merge remote-tracking branch 'origin/main' into feat/framey-next-compat
# Conflicts:
#	docs/frame-plugin-author.md
2026-10-26 00:19:38 +00:00
SAPHID f910c3b278 Relay scoped Decky backend events to Framey panels 2026-10-26 00:19:19 +00:00
SAPHID 9bc5294f36 Reject Decky plugin name collisions during updates 2026-10-26 00:15:48 +00:00
Alex Southwell 19e8b826c6 Merge remote-tracking branch 'origin/main' into docs/real-mac-air-test 2026-10-26 10:35:28 +11:00
Alex Southwell 879e84468b Record physical Intel Mac testing and clarify unsigned app opening 2026-10-26 10:30:30 +11:00
26 changed files with 1284 additions and 73 deletions

No files matched your search

+28 -5
View File
@@ -173,16 +173,39 @@ distribution's. If you already have `adb`, the app uses yours.
<details>
<summary><b>macOS: the app isn't notarized</b></summary>
There's no paid Apple developer account behind it, so macOS says the app is
damaged or can't be checked. Drag it to Applications, then clear the download
quarantine once, only for a download you trust from the releases linked above:
The app has an **ad hoc signature**, no Apple Developer ID signature, and no
notarization. The DMG is unsigned. First verify the download against the
`SHA256SUMS` from the same release, then open the DMG and drag **Frame Control**
to **Applications**.
Check your processor before opening: v0.4.36 and earlier publish only
`mac-arm64` for Apple Silicon. On an Intel Mac, this build cannot run;
removing quarantine or installing Rosetta does not make an ARM app run on Intel.
On a real Intel MacBook Air with macOS 15.7.7, opening v0.4.35 and v0.4.36
reported: **“The application cannot be opened because it has an incorrect
executable format.”** This is an architecture failure, not an observed
Gatekeeper warning. See the [real-Mac test record](docs/evidence/mac-desktop-2026-10-26.md).
On a compatible Mac, if macOS blocks an unverified developer, try opening the
app once, then go to **System Settings → Privacy & Security → Open Anyway**
and confirm **Open**. Only do this for a download you verified and trust.
[Apple's opening instructions](https://support.apple.com/102445) describe the
current security controls. The exact warning and this override flow have not
yet been verified here on Apple Silicon.
If a trusted, verified copy still reports “damaged” and offers no Open Anyway
option, the Terminal fallback removes only this app's download quarantine
(it does not sign or notarize the app):
```sh
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
```
The first time, macOS also asks to allow local network access (for SSH) and
control of Terminal (for the password prompts).
Then open it from Applications. Do not disable Gatekeeper globally. Depending
on the features you use, macOS may also request Local Network access, permission
to control Terminal, or Screen Recording; approve only the feature you intend
to use. Those permission prompts were not verified in the release test above.
</details>
<details>
+2 -1
View File
@@ -4,7 +4,8 @@ Current for Frame Control **v0.4.37**, 26 October 2026. “Real Frame + physical
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.36 tag `1701e62`, Frame OS 0.5.6 build `20261022.6153909`: [release smoke evidence](evidence/v0436-release-smoke-2026-10-25.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): v0.4.35/v0.4.36 DMGs verified and installed; both fail to launch because they are ARM64-only. Separate Intel candidate launched: UI, native preference read/write (restored), bundled Python logs, latest-version check and offline OBS Start/Stop checked; released UI browsed 116 Decky store entries | No | Candidate results are not released-build acceptance. Native Open at Login state, Gatekeeper override, real OBS/Frame stream, native settings across restart, headset transfer and wearer interaction remain unverified | 26 Oct, v0.4.35/v0.4.36 and Intel candidate `6ea9eb2`: [real-Mac evidence](evidence/mac-desktop-2026-10-26.md) |
| Core title smoke test | Linux lxso2 installed and removed reserved native/Proton titles on the real Frame; all 13 steps passed. ARM64 and Windows titles launched | No | The x86_64 title could not launch because Steam Linux Runtime 4.0 was not installed. The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, v0.4.37 tag `5239dae`, Frame OS 0.5.6 build `20261022.6153909`: [release smoke evidence](evidence/v0437-release-smoke-2026-10-26.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
+5 -2
View File
@@ -88,8 +88,11 @@ entries. Install, update, and remove need an explicit confirmation. They only
change Frame Control owned plugin folders under
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
back an update if the service restart fails. The current VR shell does not
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
back an update if the service restart fails. An update is also refused if its
new plugin name is already used by another installed plugin, because Decky
routes backend calls by name. This collision check has local tests only.
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 hosts
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
with settings, local routes, and panel toasts where supported.
@@ -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."
]
}
@@ -0,0 +1,107 @@
{
"checkedAtUtc": "2026-10-26T00:29:00Z",
"scope": "Exact archived frontends only. Production Framey host with bounded SteamClient JSON cache, image fallback and inert Steam subscriptions/context. Opaque iframe; all backend calls and network denied. No headset or plugin backends used in this follow-up.",
"baseHostCommit": "52a0974",
"before": {
"ready": 96,
"failed": 20
},
"after": {
"ready": 98,
"failed": 18
},
"regressions": [],
"observations": [
{
"id": 13,
"name": "Pause Games",
"ready": true,
"error": null,
"logs": [],
"methods": [
"load_settings"
]
},
{
"id": 43,
"name": "Tailscale Control",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_tailscale_state"
]
},
{
"id": 46,
"name": "Steamback",
"ready": false,
"error": "Cannot read properties of undefined (reading 'm_CurrentUser')",
"logs": [],
"methods": []
},
{
"id": 66,
"name": "PlayTime",
"ready": false,
"error": "Cannot read properties of undefined (reading 'm_mapApps')",
"logs": [
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
}
],
"methods": [
"fetch_playtime_information",
"statistics_for_last_two_weeks"
]
},
{
"id": 120,
"name": "Reroll",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_setting",
"version"
]
}
],
"tailscaleFocusedCheck": {
"rendered": [
"Settings",
"Toggle Tailscale",
"Exit Node IP",
"Toggle LAN Access",
"Advanced Settings",
"Device Status",
"Tailscale Setup"
],
"assetError": "Hardcoded localhost:1337 QR SVG blocked by image CSP",
"actions": "Inspected only; no toggles, exit nodes, installs or connection actions"
},
"pauseGamesFocusedCheck": {
"rendered": [
"Pause before Suspend",
"Pause on focus loss"
],
"actions": "Inspected only; no process signaling or suspend actions"
},
"storageChecks": [
"Per-plugin cache survives panel reopen",
"Missing key rejects Not found",
"JSON serialization and cache quota enforced",
"Malformed and stale-session cache updates rejected"
],
"unverified": [
"Real backends for these plugins",
"Tailscale network state/actions",
"Live games and pause/resume",
"Worn VR/controller/keyboard"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

+112
View File
@@ -0,0 +1,112 @@
# Physical Mac desktop check, 26 October 2026
Host: **MacBook Air (MacBookAir9,1), Intel Core i3 1.1 GHz, macOS 15.7.7
(24G720), x86_64**. This is physical hardware. The console is an unattended
server with display/automation restrictions. No Apple Silicon runtime result
is inferred from this test.
## Released installers: verified, installed, cannot run on Intel
GitHub's latest release was v0.4.35 at the start; v0.4.36 became latest during
the check. Each DMG and its SHA256SUMS were downloaded from that exact release
with `gh release download`. Both passed `shasum -a 256 -c SHA256SUMS
--ignore-missing` (only the downloaded asset was checked):
| Release | Asset | SHA-256 |
|---|---|---|
| v0.4.35 | Frame-Control-mac-arm64.dmg | `eda670748679ca02fcf242870cd53954b22cf8b93af47a193c9903bd08a8334f` |
| v0.4.36 | Frame-Control-mac-arm64.dmg | `4d13068f0cfd3616823d63c82333afa7e368c1d57d375e9564f5f7ed4f0ebbd7` |
Each read-only DMG was mounted with `hdiutil attach -readonly -nobrowse`.
There was no existing `/Applications/Frame Control.app`; the app was copied
there with `ditto`, the command-line equivalent of the DMG's drag to
Applications. `open "/Applications/Frame Control.app"` returned exactly:
> The application cannot be opened because it has an incorrect executable format.
`file` identified the executable as `Mach-O 64-bit executable arm64`.
`codesign -dv` identified an ad hoc signature with no TeamIdentifier. The
v0.4.35 `spctl -a -vv` assessment returned `rejected`. The v0.4.36 assessment
stalled and is not counted as a completed assessment.
**No Gatekeeper warning dialog was observed.** LaunchServices rejected the
architecture first. The CLI download had no `com.apple.quarantine` attribute,
so this was not a browser-quarantine test. Neither right-click Open, Open
Anyway nor removing quarantine was tested. On this Intel Mac there is no
honest opening workaround for either released ARM64 app. Rosetta does not
run ARM64 apps on Intel. The README now separates that fact from Apple's
recommended Open Anyway flow for compatible Macs and labels the latter
unverified here. No global Gatekeeper setting was changed.
## Separate Intel candidate: partial native runtime pass
[PR #262](https://github.com/saphid/frame-control/pull/262), commit `6ea9eb2`,
adds native x64 DMG/ZIP builds, pinned x64 Python, an x86_64 Swift helper, and
CI architecture checks. Swift 6.2.4 rejected an ambiguous
`Data.withUnsafeMutableBytes` overload in the helper; an explicit
`UnsafeMutableRawBufferPointer` annotation compiled successfully on this Air.
`npm run dist:mac:x64` completed locally, producing an ad hoc signed app,
ZIP and DMG. The unpacked candidate was copied to
`/Applications/Frame Control Intel Test.app` and launched with telemetry off,
a task-local server data directory and a temporary loopback debug port.
This is **not** the published v0.4.36 binary.
The first launch stalled for several minutes before app code ran. A process
sample showed only `_dyld_start`, a 12 KiB footprint and no loaded image
list, while `syspolicyd` used about 89% CPU. It later proceeded without any
system-service restart; the app window and server loaded. The initial stall
is a host observation, not proof of a Frame Control bug. Restarting that
security daemon was rejected by automatic approval review, and was not done.
| Check | Observed result and limit |
|---|---|
| Native window and IPC bridge | The Electron renderer displayed Apps and the offline identity warning; `window.frameApp` was present. Native renderer inspected through its temporary loopback CDP port; this does not prove every macOS permission dialog or physical click |
| Settings | Native preference read returned `{}`. Writing `mvSeparate="0"` read back correctly. The original preference file was restored byte-for-byte (or removed when originally absent) in a finally block. Persistence across an app restart is not claimed |
| Open at Login | Source is opt-in and focused login-item tests passed. Native menu read via System Events timed out; an OS login session and actual checkbox state remain unverified. No login item was enabled |
| Logs | `~/Library/Logs/Frame Control/server.log` exists. Its startup line proves the candidate used `Contents/Resources/python/bin/python3` and its bundled `ui/server.py`; HTTP reads and offline failures were logged. Show Server Log menu interaction was not verified |
| Update check | Actual `frameApp.update.check()` completed with `status="none"`, `current="0.4.36"`, latest `0.4.36`, and `error=null`. No update was installed; native dialog dismissal was not verified |
| OBS setup and lifecycle | Native status reported supported/idle, Start on port 15123 reported starting, Stop reported stopping, then idle. The Frame's SSH identity rejection prevented a headset stream. This checks local lifecycle only: no OBS app, audio/video delivery or receiver tested |
| Decky store | The exact released UI/server resources, separately served by this Mac's existing Python 3.9.6, loaded 116 official entries in local Chromium. Forty rows rendered; searching ProtonDB found ProtonDB Badges 1.3.4; all Install buttons stayed disabled while offline. No loader or plugin was installed. [Browser screenshot](mac-decky-browser-2026-10-26.png) |
| Other UI | Released Apps, Settings, Privacy & updates, Tools, Capture & Live and Decky views rendered. The privacy page correctly reported telemetry blocked by `FRAME_CONTROL_TELEMETRY=0`; sharing/contact choices were left alone. The activity log displayed the offline failure. The browser path hides native OBS buttons as designed; it is not a substitute for the native lifecycle check above |
| Framey preview | No standalone local preview control was found in the desktop UI. The Framey panel belongs to the headset runtime; no VR or plugin-backend result is claimed |
The released server refused a stale SSH host identity rather than silently
trusting it. That safety check was preserved; no host pin was replaced just
to make the test pass. Capture, media, game launches, power controls, installs,
sign-ins and notifications were not triggered without a working connection.
## Headset: read-only relay checks, no changes
`frame.local` resolved on the Air, but direct TCP/SSH did not connect. The
coordinator host lxso2 could reach it. The app's read-only `frame_status.py`
was piped through lxso2 to the Frame and returned:
- SteamOS **0.5.6**, build **20261022.6153909**, variant `vr`.
- Battery **99%, Charging**.
- Home storage: 996,984,774,656 bytes total, 769,023,275,008 bytes free.
- A read-only search counted **14** Steam screenshot JPEGs; none was copied.
The shared `/tmp/frame-test.lock` was owned by another Framey test on each
check. This job did not acquire, remove or replace it. File upload/delete,
OBS on the device and every other headset mutation were skipped. These relay
reads do not prove the native Mac app's direct SSH or file-transfer path.
The headset was never wiped or rebooted.
## Validation and follow-up
A disposable Linux checkout of exact commit `6ea9eb2` on lxso2 passed:
**1,590 server tests (16 skipped)** and **46 app tests**. On the physical Air,
**14 focused login-item/preferences/OBS tests** and **18 website tests** passed.
Full Mac server/release suites were attempted but spawned helper programs
stalled and timed out during the policy-service problem; they are not a pass.
Node 22's complete app run cancelled sign-in tests around an unreferenced
timeout; the exact same app suite passed on lxso2. CI uses Node 24.
Required next steps: pass exact-head Mac packaging/architecture CI, publish
an Intel asset through coordinator job mppkd9qczt, then repeat a normal
browser-download/quarantine installation of that release on this Air. Verify
Open Anyway and Open at Login through an authorized interactive console.
Acquire the shared headset lock before a temporary transfer or real OBS test.
Preserve all unverified labels until those checks actually pass. PR #113 was
not changed. No social post was made.
@@ -0,0 +1,24 @@
# v0.4.37 release smoke on a real Frame
On 26 October 2026 at 00:27 UTC, a physical Linux host ran the release
`scripts/frame-smoke.sh` against a real Steam Frame. The smoke scripts were
byte-identical to tag `v0.4.37` (`5239dae1a82c35635ef89f59eac9721c5014e2ed`).
The headset reported Frame OS 0.5.6, build `20261022.6153909`.
The test acquired `/tmp/frame-test.lock` with a unique owner marker before
reading the headset. SteamVR activity stayed at 3 (standby); initial proximity
raw was 6.3125 and battery was 99%. The test released only its own lock after
cleanup. Battery read 100% afterward.
All **13 of 13** smoke steps passed. ARM64 and Windows test titles installed,
launched, and were removed. The x86_64 title installed and was removed; its
launch reported that Steam Linux Runtime 4.0 was found but not installed, so
this result does **not** establish x86_64 game execution. Devkit properties and
status reads passed. No key was paired. The final cleanup removed the reserved
test titles and processes. The private result remains under
`/tmp/frame-v0437-smoke-results/`; this summary omits headset identifiers and
device status details.
Because the headset was in standby, this test did not verify wearer-visible
pixels, physical controller input, a Framey panel, or official Decky plugin
settings in VR. Those checks remain on the [worn checklist](../framey-vr-check.md).
+3 -2
View File
@@ -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
+11 -11
View File
@@ -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: 44 · Broken: 60 · 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. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. 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
@@ -40,11 +40,11 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 10: vibrantDeck 2.0.1 | legacy / legacy | N/A | Not supported on Frame: Changes X11 gamescope color properties, not the Frame SteamVR compositor. | static archive, local browser, fake-Frame |
| 11: Fantastic 0.5.1 | legacy / legacy | N/A | Not supported on Frame: Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. | static archive, local browser, fake-Frame |
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Legacy media controls initialize; player discovery and playback are unverified. | static archive, local browser, fake-Frame |
| 13: Pause Games 1.0.3 | 1 / 2 | broken | Frontend audit failed: SteamClient.System.UI.RegisterForSystemKeyEvents is not a function. | static archive, local browser, fake-Frame |
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings render locally with inert system-key subscriptions and empty detached Steam context. Live-game discovery, process pause/resume and suspend integration are unsupported/unverified; no backend actions ran. | static archive, local browser, fake-Frame |
| 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 |
@@ -59,9 +59,9 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 37: Discord Status 1.5.0 | 1 / 2 | broken | RunningApps and appStore are absent; Discord activity integration fails after initialization. | static archive, local browser, fake-Frame |
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | partial | Initializer succeeds; user PulseAudio/PipeWire mixer backend and volume changes unverified. | static archive, local browser, fake-Frame |
| 40: Controller Tools 2.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: ControllerTools/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
| 43: Tailscale Control 0.1.3 | legacy / legacy | broken | Initializer succeeds but React rejects a missing component (#130); no usable control claim. | static archive, local browser, fake-Frame |
| 43: Tailscale Control 0.1.3 | legacy / legacy | partial | Settings render locally after the SuspensefulImage fallback. Tailscale daemon/state, connection and exit-node changes unverified; bundled localhost QR asset remains unavailable. No network settings were changed. | static archive, local browser, fake-Frame |
| 44: DeckMTP 1.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: DeckMTP/bin/umtprd. | static archive, local browser, fake-Frame |
| 46: Steamback 1.0.0-1 | legacy / legacy | broken | Unsafe plugin archive member. Frontend audit failed: App is not defined. | static archive, local browser, fake-Frame |
| 46: Steamback 1.0.0-1 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of undefined (reading 'm_CurrentUser'). Steam account/app context is unavailable. | static archive, local browser, fake-Frame |
| 48: Quick Launch 1.2.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | Empty-data panel rendered; cleanup was deliberately not run because it can remove local data. | static archive, local browser, fake-Frame |
@@ -76,7 +76,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 63: Free Loader 1.6.0 | 1 / 2 | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. | static archive, local browser, fake-Frame |
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
| 66: PlayTime 3.2.5 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of undefined (reading 'GetJSON'). | static archive, local browser, fake-Frame |
| 66: PlayTime 3.2.5 | 1 / 2 | broken | Private SteamClient JSON cache now works; initialization still fails on missing Steam appStore.m_mapApps. Live app data and MobX game observations are unavailable; backend actions were denied. | static archive, local browser, fake-Frame |
| 67: Battery Tracker 0.2.0 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 68: Wine Cellar 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-wine-cellar/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
| 70: MicroSDeck 0.11.2 | legacy / 2 | broken | Plugin contains a non-ARM64 Linux binary: MicroSDeck/bin/backend. | 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 |
@@ -126,7 +126,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 117: DeckyParty File Manager 1.0.0 | legacy / legacy | partial | Initializer succeeds; privileged file operations and backend are unverified. No file changes were run. | static archive, local browser, fake-Frame |
| 118: Speed Test 1.0.2 | 1 / 2 | partial | Initializer succeeds; automatic bandwidth tests were blocked by the audit network policy. | static archive, local browser, fake-Frame |
| 119: Decky Clipboard 1.0.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-clipboard/bin/xclip. | static archive, local browser, fake-Frame |
| 120: Reroll 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: Reroll/bin/memscan.so. Frontend audit failed: window.SteamClient.Apps.RegisterForGameActionUserRequest is not a function. | static archive, local browser, fake-Frame |
| 120: Reroll 0.2.1 | 1 / 2 | broken | Frontend now initializes with inert game-action subscriptions, but the archive contains x86_64 memscan.so and requests root. No process-memory access was run. | static archive, local browser, fake-Frame |
| 121: DeckPass 1.1.1 | 1 / 2 | partial | Initializer succeeds; KeePass Flatpak discovery and secrets integration were not exercised. | static archive, local browser, fake-Frame |
| 123: Steam Status 1.2.3 | 2 / 2 | partial | Settings/public status rendered locally; cache errors fixed. Read-only get_settings RPC returned actual Decky 3.3.0 defaults on ARM64 Frame. Persistence, live backend diagnostics and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 124: AlarMe 1.6.3 | 1 / 2 | partial | Initializer succeeds; timers, reminders, suspend inhibition and audio are unverified. | static archive, local browser, fake-Frame |
@@ -301,7 +301,7 @@ All published Linux native payloads in this snapshot are x86_64; no published aa
| Decky-Framegen | bin/nvngx.dll, bin/dlssg_to_fsr3_amd_is_better.dll, bin/nvapi64.dll | Windows DLL payload targets x86 game translation rather than a native ARM backend; archive is 75,140,408 bytes, beyond the 50 MiB installer limit. |
| HueSync | bin/hello, py_modules/portio.so | portio and handheld LED backends depend on x86 I/O/unsupported hardware; ARM64 compilation alone is insufficient. |
| Decky Clipboard | bin/xclip | xclip needs ARM64 X11 libraries/server; root and gamescope assumptions also block this archive. |
| Reroll | bin/memscan.so | memscan.so needs ARM64 process-memory support and ABI testing; root requirement and missing Steam hook remain. |
| Reroll | bin/memscan.so | memscan.so needs ARM64 process-memory support and ABI testing; root requirement remains. Frontend initializes but no memory access was tested. |
| Decky Localsend | bin/localsend-core | LocalSend-Go commit 66edc509a28b9f877e7374504892dcbc653a4da4 cross-compiled successfully with CGO_ENABLED=0 GOOS=linux GOARCH=arm64. Static aarch64 ELF SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Not executed; source tip is not proven to match the shipped helper/protocol. Official ZIP is still x86_64. |
| Launch Options | bin/hello | Remove the template x86 bin/hello or publish an ARM64 rebuild; missing Steam module mapping also prevents frontend initialization. |
| Decky-Clipper | bin/gstreamer-1.0/libgstproxy.so, bin/gstreamer-1.0/libgstmidi.so, bin/gstreamer-1.0/libgstmpegtsmux.so, bin/gstreamer-1.0/gst-plugin-scanner, bin/gstreamer-1.0/libgstivfparse.so, … 115 total; full list in JSON | Rebuild all bundled GStreamer libraries/plugins for ARM64 and verify capture/codec ABI against Frame SteamVR; gamescope capture differs. |
+30
View File
@@ -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,
@@ -94,6 +113,17 @@ work. IndexedDB is hidden because the browser exposes it but denies access in
an opaque iframe; localForage can then select Framey's bounded localStorage.
Direct IndexedDB users remain unsupported.
`SuspensefulImage` renders a native image. SteamClient `Storage.GetJSON` and
`SetObject` use the same bounded per-plugin cache, including panel reopen
persistence; they do not read Steam's account/global JSON store. Missing keys
reject with `Not found`. Detached `SteamUIStore.RunningApps` is a frozen empty
list, and the suspend store has no methods. Optional Steam app/account globals
are undefined. These fallbacks let settings render without inventing live
games or account identity. [The exact-bundle follow-up](evidence/framey-ui-fallbacks-2026-10-26.json)
verifies Pause Games and Tailscale Control settings in local Chromium; their
primary backend actions remain unverified. PlayTime still requires Steam's
real app store and game observers.
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,
then checks it again. It also confirms removal blocks method calls and a
+1
View File
@@ -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'},'*');}
+9 -2
View File
@@ -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,9 +46,16 @@ 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. '
'A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings '
'with all backend/network actions denied; it does not verify process pausing or Tailscale changes. '
'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 '
+52 -1
View File
@@ -20,7 +20,7 @@ const window={React,ReactDOM:{createRoot:node=>{
const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)};
const context=vm.createContext({window,document:{getElementById:id=>{
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()},
navigator:{language:'en'}, parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
navigator:{language:'en'}, parent,DOMException,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(testSource,context);
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
@@ -175,6 +175,25 @@ assert.equal(unsubscribe.unregister,unsubscribe);
unsubscribe.unregister();unsubscribe();assert.equal(eventCount,0);
assert.equal(window.SteamClient.System.ShutdownPC,undefined);
assert.equal(window.DFL.Router.MainRunningApp,undefined);
assert.equal(window.DFL.Router.RunningApps.length,0);
assert.equal(window.DFL.Router.RunningApps,window.SteamUIStore.RunningApps);
assert.ok(Object.isFrozen(window.SteamUIStore));
assert.ok(Object.isFrozen(window.SuspendResumeStore));
assert.equal(window.SuspendResumeStore.InitiateSleep,undefined);
for(const name of ['App','appStore','appInfoStore','collectionStore'])assert.equal(window[name],undefined);
for(const register of [window.SteamClient.System.UI.RegisterForSystemKeyEvents,
window.SteamClient.Apps.RegisterForGameActionUserRequest]){
const dispose=register(()=>eventCount++);assert.equal(dispose.unregister,dispose);dispose();
}
assert.equal(eventCount,0);
const image=window.DFL.SuspensefulImage({src:'data:image/svg+xml,<svg/>',alt:'Setup',style:{width:50}});
assert.equal(image.type,'img');assert.equal(image.props.alt,'Setup');assert.equal(image.props.style.width,50);
await assert.rejects(window.SteamClient.Storage.GetJSON('missing'),/Not found/);
await window.SteamClient.Storage.SetObject('settings',{enabled:false,count:3});
assert.equal(await window.SteamClient.Storage.GetJSON('settings'),'{"enabled":false,"count":3}');
assert.equal(window.localStorage.getItem('steam-client:settings'),'{"enabled":false,"count":3}');
await assert.rejects(window.SteamClient.Storage.SetObject('large','x'.repeat(65537)),/Framey plugin cache is full/);
await assert.rejects(window.SteamClient.Storage.SetObject('undefined',undefined),/JSON value/);
assert.equal(window.indexedDB,undefined); // localForage must use bounded plugin storage.
const unpatch=window.DFL.beforePatch({},'method',()=>{});
assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch();
@@ -206,3 +225,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');
+7 -4
View File
@@ -38,7 +38,7 @@ function iframe(){
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};
return {storage:frameWindow.localStorage,steamStorage:frameWindow.SteamClient.Storage,send};
}
let first=iframe();
assert.equal(first.storage.getItem('status-cache'),null);
@@ -46,6 +46,7 @@ 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');
await first.steamStorage.SetObject('playtime-settings',{count:3});
const oldSession=session;
vm.runInContext('openFrameyPanel(14,"Steam Status")',parent);
session=vm.runInContext('frameySession',parent);
@@ -57,13 +58,15 @@ assert.equal(reopenedURL.searchParams.get('session'),session);
assert.equal(reopenedURL.hash.slice(1),session);
let reopened=iframe();
assert.equal(reopened.storage.getItem('status-cache'),'{"online":true}');
assert.equal(reopened.storage.length,1);
assert.equal(reopened.storage.length,2);
assert.equal(reopened.storage.key(0),'status-cache');
assert.equal(await reopened.steamStorage.GetJSON('playtime-settings'),'{"count":3}');
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.
const savedCache=values.get('framey-plugin-cache-v1:14');
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}"}');
assert.equal(values.get('framey-plugin-cache-v1:14'),savedCache);
reopened.send({type:'framey:cache:save',session:oldSession,entries:{poison:'yes'}});
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
assert.equal(values.get('framey-plugin-cache-v1:14'),savedCache);
reopened.storage.clear();
assert.equal(values.get('framey-plugin-cache-v1:14'),'{}');
+49
View File
@@ -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})});
+40
View File
@@ -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);
+51
View File
@@ -181,6 +181,27 @@ class StoreTransaction(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.folder.exists())
def test_remote_apply_rechecks_plugin_name_under_transaction_lock(self):
data, _ = archive()
other = self.folder.parent / 'Other'
other.mkdir()
(other / 'plugin.json').write_text('{"name":"Example"}')
result = self.apply('install', data)
self.assertNotEqual(result.returncode, 0)
self.assertIn('different plugin with this name', result.stderr)
self.assertFalse(self.folder.exists())
self.assertEqual((other / 'plugin.json').read_text(), '{"name":"Example"}')
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
(other / 'plugin.json').write_text('{"name":"Distinct"}')
self.assertEqual(self.apply('install', data).returncode, 0)
(other / 'plugin.json').write_text('{"name":"Example"}')
result = self.apply('update', data)
self.assertNotEqual(result.returncode, 0)
self.assertIn('different plugin with this name', result.stderr)
self.assertTrue(self.folder.exists())
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
class StoreHost(unittest.TestCase):
def test_reviewed_archive_changed_before_install_does_not_download(self):
@@ -219,6 +240,36 @@ class StoreHost(unittest.TestCase):
'install', 42)
network.assert_not_called()
def test_update_rejects_name_taken_by_another_plugin_before_download(self):
state = {'managed': True, 'running': True, 'plugins': [
{'storeId': 42, 'folder': 'Example', 'name': 'Old Name', 'version': '1.0'},
{'storeId': 43, 'folder': 'Other', 'name': 'Example', 'version': '1.0'}]}
catalog = [{'id': 42, 'name': 'Example', 'root': False,
'sha256': 'a' * 64, 'version': '2.0'}]
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
patch.object(frame_decky_store.urllib.request, 'urlopen',
side_effect=AssertionError('No download expected')):
with self.assertRaisesRegex(ValueError, 'different plugin with this name'):
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
'update', 42)
def test_update_allows_its_own_existing_name(self):
data, digest = archive()
state = {'managed': True, 'running': True, 'plugins': [
{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
catalog = [{'id': 42, 'name': 'Example', 'root': False,
'sha256': digest, 'version': '2.0'}]
calls = []
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(data)):
result = frame_decky_store.change(lambda command, **kwargs: calls.append(command) or '{}',
'update', 42)
self.assertEqual(result, {})
self.assertEqual(len(calls), 3) # Upload, apply, and temporary archive cleanup.
self.assertIn('update', calls[1])
def test_owned_plugin_can_be_removed_without_store_network(self):
state = {'managed': True, 'running': True, 'plugins':
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
+41
View File
@@ -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)
+19 -3
View File
@@ -175,6 +175,19 @@ try:
z.extractall(stage)
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
# Recheck under the transaction lock. Another store operation may
# have installed a colliding name while this archive downloaded.
proposed_name=json.loads((unpacked/'plugin.json').read_text()).get('name')
for peer in base.iterdir():
if peer==target or peer.is_symlink() or not peer.is_dir(): continue
manifest=peer/'plugin.json'
if manifest.is_symlink() or not manifest.is_file(): continue
try:
if manifest.stat().st_size>16384: continue
peer_data=json.loads(manifest.read_text())
if isinstance(peer_data,dict) and peer_data.get('name')==proposed_name:
raise RuntimeError('A different plugin with this name is already installed')
except (OSError,ValueError,UnicodeError): continue
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
# Stop even a failed unit before removal to cancel any pending auto-restart.
if running or action=='remove': service('stop','frame-control-decky.service')
@@ -228,9 +241,12 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
raise ValueError('Root plugins are not supported by the Frame user service')
if plugin.get('compatibility', {}).get('blockInstall'):
raise ValueError(plugin['compatibility']['reason'])
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']]
if action == 'install' and existing_name:
raise ValueError('A plugin with this name is already installed')
# Decky routes backend calls by plugin name. A store update may rename
# its plugin, so check names for updates as well as fresh installs.
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']
and (action == 'install' or p.get('storeId') != plugin_id)]
if existing_name:
raise ValueError('A different plugin with this name is already installed')
if action == 'update' and len(installed) != 1:
raise ValueError('Only an installed Frame Control store plugin can be updated')
data = None
+65 -31
View File
@@ -3,7 +3,7 @@
"checkedAtUtc": "2026-10-25T23:19:48.854504+00:00",
"source": "https://plugins.deckbrew.xyz/plugins",
"hostCommit": "779f9c5",
"method": "Exact SHA-256 verified archives. All frontends attempted in opaque-iframe Chromium for 1.2 s with backend calls and network denied; ready means initializer only. No official backend code was executed. See docs/framey-compatibility.md.",
"method": "Exact SHA-256 verified archives. All frontends attempted in opaque-iframe Chromium for 1.2 s with backend calls and network denied; ready means initializer only. Backend calls were denied in the baseline browser audit. Scoped live backend follow-ups are recorded separately in docs/framey-compatibility.md.",
"plugins": [
{
"id": 1,
@@ -579,8 +579,8 @@
"https://github.com/wynn1212/SDH-PauseGames",
"https://github.com/wynn1212/SDH-PauseGames.git"
],
"status": "broken",
"reason": "Frontend audit failed: SteamClient.System.UI.RegisterForSystemKeyEvents is not a function.",
"status": "partial",
"reason": "Settings render locally with inert system-key subscriptions and empty detached Steam context. Live-game discovery, process pause/resume and suspend integration are unsupported/unverified; no backend actions ran.",
"blockInstall": false,
"verification": [
"static archive",
@@ -590,8 +590,8 @@
"browser": {
"id": 13,
"name": "Pause Games",
"ready": false,
"error": "SteamClient.System.UI.RegisterForSystemKeyEvents is not a function",
"ready": true,
"error": null,
"logs": [],
"methods": [
"load_settings"
@@ -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"
}
},
{
@@ -3144,8 +3155,8 @@
"https://github.com/saumya-banthia/tailscale-control.git",
"https://github.com/tailscale-dev/deck-tailscale"
],
"status": "broken",
"reason": "Initializer succeeds but React rejects a missing component (#130); no usable control claim.",
"status": "partial",
"reason": "Settings render locally after the SuspensefulImage fallback. Tailscale daemon/state, connection and exit-node changes unverified; bundled localhost QR asset remains unavailable. No network settings were changed.",
"blockInstall": false,
"verification": [
"static archive",
@@ -3157,13 +3168,10 @@
"name": "Tailscale Control",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "Error: Minified React error #130; visit https://reactjs.org/docs/error-decoder.html?invariant=130&args[]=undefined&args[]= for the full message or use the non-minified dev environment for full errors and additional helpful warnings."
}
],
"methods": []
"logs": [],
"methods": [
"get_tailscale_state"
]
},
"archiveValidation": {
"id": 43,
@@ -3344,7 +3352,7 @@
"https://github.com/geeksville/steamback.git"
],
"status": "broken",
"reason": "Unsafe plugin archive member. Frontend audit failed: App is not defined.",
"reason": "Frontend audit failed: Cannot read properties of undefined (reading 'm_CurrentUser'). Steam account/app context is unavailable.",
"blockInstall": true,
"verification": [
"static archive",
@@ -3355,7 +3363,7 @@
"id": 46,
"name": "Steamback",
"ready": false,
"error": "App is not defined",
"error": "Cannot read properties of undefined (reading 'm_CurrentUser')",
"logs": [],
"methods": []
},
@@ -4915,7 +4923,7 @@
"https://github.com/your-username/PlayTime"
],
"status": "broken",
"reason": "Frontend audit failed: Cannot read properties of undefined (reading 'GetJSON').",
"reason": "Private SteamClient JSON cache now works; initialization still fails on missing Steam appStore.m_mapApps. Live app data and MobX game observations are unavailable; backend actions were denied.",
"blockInstall": false,
"verification": [
"static archive",
@@ -4926,9 +4934,21 @@
"id": 66,
"name": "PlayTime",
"ready": false,
"error": "Cannot read properties of undefined (reading 'GetJSON')",
"logs": [],
"methods": []
"error": "Cannot read properties of undefined (reading 'm_mapApps')",
"logs": [
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
}
],
"methods": [
"fetch_playtime_information",
"statistics_for_last_two_weeks"
]
},
"archiveValidation": {
"id": 66,
@@ -7436,12 +7456,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 +7492,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"
}
},
{
@@ -10351,7 +10384,7 @@
"https://github.com/kayon/memscan"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: Reroll/bin/memscan.so. Frontend audit failed: window.SteamClient.Apps.RegisterForGameActionUserRequest is not a function.",
"reason": "Frontend now initializes with inert game-action subscriptions, but the archive contains x86_64 memscan.so and requests root. No process-memory access was run.",
"blockInstall": true,
"verification": [
"static archive",
@@ -10361,11 +10394,12 @@
"browser": {
"id": 120,
"name": "Reroll",
"ready": false,
"error": "window.SteamClient.Apps.RegisterForGameActionUserRequest is not a function",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_setting"
"get_setting",
"version"
]
},
"archiveValidation": {
@@ -10375,7 +10409,7 @@
},
"backend": "Python + native payload",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"nativePort": "memscan.so needs ARM64 process-memory support and ABI testing; root requirement and missing Steam hook remain.",
"nativePort": "memscan.so needs ARM64 process-memory support and ABI testing; root requirement remains. Frontend initializes but no memory access was tested.",
"uiSurfaces": [
"QAM/global tabs",
"Steam stores"
@@ -13342,5 +13376,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."
}
+52
View File
@@ -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};
})
+85 -7
View File
@@ -11,6 +11,11 @@
: R.createElement(type, elementProps);
};
window.SP_JSX = {jsx, jsxs:jsx, Fragment:R.Fragment};
// Let plugins use optional chaining for Steam-only globals without inventing
// user identity or suspend state in this isolated document.
for (const name of ['App','appStore','appInfoStore','collectionStore']) window[name] = undefined;
window.SuspendResumeStore = Object.freeze({});
window.SteamUIStore = Object.freeze({RunningApps:Object.freeze([]), MainRunningApp:undefined});
// Steam normally fills these token maps. Official plugins can safely use
// their own fallback strings when the isolated panel has no Steam tokens.
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en'],
@@ -29,6 +34,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 +71,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 +149,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});
@@ -225,6 +289,7 @@
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
SuspensefulImage:({alt='', ...props})=>R.createElement('img', {...props, alt}),
Marquee:passthrough('span'), DialogBody:passthrough('div'), DialogBodyText:passthrough('p'),
DialogHeader:passthrough('h2'), DialogSubHeader:passthrough('h3'), DialogFooter:passthrough('footer'),
DialogLabel:passthrough('label'), DialogControlsSection:passthrough('section'),
@@ -267,17 +332,30 @@
Settings:{GetCurrentLanguage:async()=>steamLanguage(),
...register(['RegisterForSettingsChanges'])},
Apps:register(['RegisterForAppDetails','RegisterForGameActionTaskChange',
'RegisterForGameActionStart','RegisterForGameActionEnd']),
'RegisterForGameActionStart','RegisterForGameActionEnd','RegisterForGameActionUserRequest']),
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
UI:register(['RegisterForFocusChangeEvents'])},
UI:register(['RegisterForFocusChangeEvents','RegisterForSystemKeyEvents'])},
Input:register(['RegisterForControllerInputMessages','RegisterForControllerStateChanges']),
User:register(['RegisterForLoginStateChange','RegisterForPrepareForSystemSuspendProgress',
'RegisterForResumeSuspendedGamesProgress','RegisterForCurrentUserChanges']),
Overlay:register(['RegisterForOverlayActivated']),
UI:register(['RegisterForUIModeChanged']),
// This is private plugin cache, not Steam's account/global JSON store.
Storage:{
GetJSON:async key=>{
const value=storage.getItem('steam-client:'+String(key));
if(value===null)throw Error('Not found');
return value;
},
SetObject:async(key,value)=>{
const encoded=JSON.stringify(value);
if(encoded===undefined)throw Error('SteamClient.Storage requires a JSON value');
storage.setItem('steam-client:'+String(key),encoded);
},
},
};
DFL.Router = {...DFL.Navigation, MainRunningApp:undefined};
DFL.Router = {...DFL.Navigation, ...window.SteamUIStore};
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
const toaster = {toast: ({title, body, subtext, icon, logo, duration, showToast=true, onClick}={}) => {
const data = {title, body, subtext, icon, logo};
@@ -366,7 +444,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')),
+35
View File
@@ -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
View File
@@ -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});
+11
View File
@@ -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,