mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 21:03:09 +01:00
Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f5b37a755 | ||
|
|
77c54c0b01 | ||
|
|
d38af757ec | ||
|
|
211f4fb148 | ||
|
|
72fd779170 | ||
|
|
286e692147 | ||
|
|
fe51d29eb4 | ||
|
|
4b47c384e6 | ||
|
|
7baafc9a18 | ||
|
|
b0d8bf3968 | ||
|
|
b05a666e50 | ||
|
|
8feb19a6d3 | ||
|
|
f84a4da9b1 | ||
|
|
fc978a2afd | ||
|
|
9aff6adcec | ||
|
|
2d8b70d607 | ||
|
|
80657b8a8e | ||
|
|
21b85ba99d | ||
|
|
a38d598f14 | ||
|
|
08567ca571 | ||
|
|
4fc5b83873 | ||
|
|
d96330554a | ||
|
|
385ae8290b | ||
|
|
858edaf7eb | ||
|
|
f5213b7c44 | ||
|
|
0219cd10f0 | ||
|
|
b0c0599f45 | ||
|
|
1dcca6d13a | ||
|
|
212f0af35c | ||
|
|
5da87c8dc0 | ||
|
|
59ea09325c | ||
|
|
f88e2ed209 | ||
|
|
43a6b5e008 | ||
|
|
221d2b26ab | ||
|
|
087bb1ef9f | ||
|
|
70b4bb1368 | ||
|
|
b961fc4cef | ||
|
|
9af06c7845 | ||
|
|
9359b4ffbd | ||
|
|
0051ac4570 | ||
|
|
37e25e62ac | ||
|
|
dddfc6bea8 | ||
|
|
d1e526cb4b | ||
|
|
8230fdb19d | ||
|
|
2937a12864 | ||
|
|
71fe4a3ebb | ||
|
|
42f3b2e1e7 | ||
|
|
ca4c370b33 | ||
|
|
c50f96510f | ||
|
|
43e801091a | ||
|
|
b4e632b218 | ||
|
|
d84491c0ec | ||
|
|
83b8d13846 | ||
|
|
340ca88359 | ||
|
|
e62589417b | ||
|
|
03b3796d8d | ||
|
|
93da4e466f | ||
|
|
05093ea4d4 | ||
|
|
232b253e7a | ||
|
|
de8abe32c6 | ||
|
|
42492e7879 | ||
|
|
f50139fe6a | ||
|
|
698a643eb7 | ||
|
|
d0cef31656 | ||
|
|
4ac59f1a44 | ||
|
|
c91cfff797 | ||
|
|
d5e62f643d | ||
|
|
f3f574666f | ||
|
|
40ece4fef0 | ||
|
|
f52c020310 | ||
|
|
efe0c508f4 | ||
|
|
1af2bb45d9 | ||
|
|
daee952873 | ||
|
|
98909cb543 | ||
|
|
1903153d7b | ||
|
|
7172be17fc | ||
|
|
27af46dd20 | ||
|
|
761e952d8f | ||
|
|
0237e342d2 | ||
|
|
33d85dff1c | ||
|
|
ad57662d40 | ||
|
|
5381667947 | ||
|
|
7cdff4285a | ||
|
|
fe1da43171 | ||
|
|
1c1d941a1c | ||
|
|
c2f94c242d | ||
|
|
e6e42a93f0 | ||
|
|
10b196beaa | ||
|
|
3f74ff4bb0 | ||
|
|
f2f7a3f4c1 | ||
|
|
5c4c447d2d | ||
|
|
63f1f7fb39 | ||
|
|
c88736c6ce | ||
|
|
fa354ce365 | ||
|
|
fcdd1f22f5 | ||
|
|
d43e760aef | ||
|
|
a3e0ec01d4 | ||
|
|
a68c5584a9 |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.41",
|
||||
"version": "0.4.46",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.41",
|
||||
"version": "0.4.46",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.41",
|
||||
"version": "0.4.46",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# What we have tested
|
||||
|
||||
Release baseline: Frame Control **v0.4.40**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
Release baseline: Frame Control **v0.4.41**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG verified, installed and launched. Native UI, preference persistence across restart (original file restored), bundled Python logs, update check, offline OBS Start/Stop and 116-entry Decky store checked. A 40-byte relay upload/readback/delete passed under coordinator/headset locks | No | No quarantined Safari/Chrome download or Gatekeeper override test; native Open at Login/menu automation unavailable. Direct frame.local had no route and the app refused a stale SSH identity. Relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Quit exposed a server cleanup race; fix #284 shipped in v0.4.40 with physical-Mac source regression coverage, but the tagged Intel installer was not launched on physical Intel hardware | 26 Oct, official v0.4.39 tag `fd0b0a9`: [Intel release evidence](evidence/mac-v0439-2026-10-26.md); [earlier ARM64 failure](evidence/mac-desktop-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 ran the exact v0.4.40 tagged source against the real Frame; all 13 reserved native/Proton title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.40 tag `a4d8073`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0440-release-2026-10-26.md); [earlier candidate smoke](evidence/v0440-candidate-frame-2026-10-26.md) |
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG and ZIP hashes/architectures verified; clean DMG app installed and launched. Native UI, preferences across restart, update discovery of 0.4.40, offline OBS Start/Stop and 116-entry Decky store checked. Actual Open at Login menu toggled on, persisted across Quit/relaunch, then restored off. A 54-byte relay upload/readback/delete passed under coordinator/headset locks | No | Ordinary browser quarantine, right-click Open and Open Anyway remain unverified; the synthetic quarantine probe was inconclusive. Actual login startup untested. Direct frame.local had no route and app SSH trust failed; relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Earlier shutdown race fix #284 shipped in v0.4.40, whose Intel installer was not tested here | 26 Oct, official v0.4.39 tag `fd0b0a9`: [repeat Intel acceptance evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); [earlier release test](evidence/mac-v0439-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 ran the exact v0.4.41 tagged source against the real Frame; 13/13 reserved title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Optional Steam Linux Runtime 4 was absent, so the x86_64 test executable did not actually launch. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.41 tag `b5d50a8`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0441-release-2026-10-26.md); [earlier candidate smoke](evidence/v0441-candidate-frame-2026-10-26.md) |
|
||||
| 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) |
|
||||
@@ -21,10 +21,10 @@ Release baseline: Frame Control **v0.4.40**, 26 October 2026. Rows also identify
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
|
||||
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Open at login | No Frame needed; official v0.4.39 on the physical Intel Air toggled the actual native menu on, retained its checkmark after Quit/relaunch and restored it off. Linux autostart file previously toggled on a physical Linux host with an isolated config directory | No | A full OS login session and physical Windows host login remain untested | 26 Oct, v0.4.39: [native Intel toggle evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); Linux v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.40: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [post-#292 unworn sample exercise](evidence/v0440-release-2026-10-26.md#post-tag-292-panel-regression), [earlier standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. v0.4.40 classifies the 116 exact-version plugins as 1 works (Notebook default flow only), 49 partial, 55 broken and 11 not applicable. Post-tag PRs #289 and #293 give 3 works / 48 partial / 54 broken / 11 N/A on current main: TabMaster is partial, and Volume Mixer and Mixer work for tested per-app silent-stream volume control only. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.40: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [ARM64 audio evidence](evidence/framey-audio-arm64-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. v0.4.41 classifies the 116 exact-version plugins as 3 works, 48 partial, 54 broken and 11 not applicable. Notebook works for its default flow; Volume Mixer and Mixer work for tested per-app silent-stream volume control only. TabMaster is partial. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.41: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [ARM64 audio evidence](evidence/framey-audio-arm64-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; v0.4.40 recorded 101 local Chromium initializers ready and 15 failed, up from 98/18 in v0.4.39. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.40: [post-#294 unworn sample and scroll evidence](evidence/framey-scroll-probe-2026-10-26.md#post-294-exact-main-sample-check), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [post-release sample exercise](evidence/v0439-release-2026-10-26.md#post-release-framey-library-change-282), [worn checklist](framey-vr-check.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Fake Frame package download retry, 26 October 2026
|
||||
|
||||
The ARM64 fake Frame container sometimes fails before project tests start.
|
||||
Valve's `holo-packages.steamos.cloud` returned package responses with **164
|
||||
bytes missing** during the `pacman -Syu` layer in
|
||||
`tests/fakeframe/Containerfile`:
|
||||
|
||||
| Run | Package and outcome |
|
||||
| --- | --- |
|
||||
| [v0.4.44 candidate checks](https://github.com/saphid/frame-control/actions/runs/40296431563) | `iptables` download truncated; a failed-only rerun passed on the unchanged PR head. |
|
||||
| [PR #316 checks](https://github.com/saphid/frame-control/actions/runs/40298316496) | `c-ares` download truncated before e2e tests. |
|
||||
| [PR #318 checks](https://github.com/saphid/frame-control/actions/runs/40301478955) | First attempt truncated `iptables`; failed-only rerun on the unchanged documentation head truncated `rsync`. The six other jobs passed. |
|
||||
|
||||
The fake Frame image now retries that package transaction at most five times,
|
||||
with a two-second delay after each failed attempt except the last. A fifth
|
||||
failure still fails the build. The end-to-end tests and their assertions are
|
||||
unchanged. This retry affects only the fake Frame test image; it does not
|
||||
change a real headset or the released application.
|
||||
|
||||
Exact-head hosted CI must pass before this change is merged. A successful
|
||||
container build would show that at least one package attempt completed; it
|
||||
would not establish that Valve's package service is permanently healthy.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Headset smoke: unavailable x86 runtime is a skip
|
||||
|
||||
The v0.4.41 tagged smoke result recorded 13/13 passed even though its x86_64 launch outcome was `runtime missing`: Steam had acknowledged the title but had not started it. This follow-up separates an unavailable optional runtime from a verified launch. It keeps the smoke usable on a Frame without Steam Linux Runtime 4 while making the missing coverage explicit in its console summary and saved JSON.
|
||||
|
||||
Focused local `test_smoke_cleanup.py` ran **22 tests successfully**. The new regression starts an x86_64 title cycle with a fresh missing-runtime Steam log, verifies `skipped=true` and `outcome=runtime missing`, and verifies that the title is still removed. A result test checks separate passed/skipped/failed counts and a zero exit code when an optional runtime is absent but nothing fails.
|
||||
|
||||
The revised `scripts/frame-smoke.sh` then ran on the real Frame from this branch. It acquired its unique owner in `/tmp/frame-test.lock` before any headset read. Locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, the result was **12 passed / 1 skipped / 0 failed**. ARM64 and Proton test executables ran; x86_64 was explicitly skipped because Steam Linux Runtime 4 was not installed. Both title cleanup steps passed. The command exited 0, including release of its owned shared lock. The detailed result remains private at `tests/smoke/results/2026-10-26T050711Z-20261022.6153909.json` in the test checkout.
|
||||
|
||||
The headset was in standby and nobody wore it for this test. Title smoke does not prove wearer-visible pixels, physical controller input, official Decky panels, audio output or comfort. It also does not prove x86_64 runtime execution on this Frame; that path needs the optional runtime present. The hourly worn check remains enabled.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Chunked Framey frontend audit route, 26 October 2026
|
||||
|
||||
The local 116-plugin Chromium audit served chunked modules at a route that
|
||||
omitted the store receipt, while the production panel requested
|
||||
`/framey/module/<id>/<receipt>/<file>`. The audit parsed that receipt as part
|
||||
of the file path and returned 404. This made its result for chunked frontends
|
||||
weaker than the separate targeted panel checks.
|
||||
|
||||
The audit now accepts the production URL shape only when the plugin ID,
|
||||
receipt and JavaScript path are valid, the receipt equals the catalog SHA-256,
|
||||
and the cached archive bytes match that SHA-256. Invalid IDs, receipts, paths
|
||||
and changed archive bytes fail before the module is served. The production
|
||||
Framey panel and installer are unchanged.
|
||||
|
||||
## Exact archived browser check
|
||||
|
||||
Pinned official Deck Shelves 2.0.0 (store ID 135, SHA-256
|
||||
`b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91`)
|
||||
ran through the repaired audit in an opaque Chromium iframe. Its entry module
|
||||
and all eight JavaScript chunks returned HTTP 200 at receipt-bound URLs. The
|
||||
frontend reported ready and called `get_settings`; the audit denied that
|
||||
backend call and a Steam library read. Plugin network access remained blocked
|
||||
by the audit policy. The previous
|
||||
catalog browser observation had failed to resolve `./chunks/...` from a blob
|
||||
URL. This check verifies the current audit route and frontend initializer,
|
||||
not the official backend or a live shelf.
|
||||
|
||||
The focused route regression covers entry and chunk reads plus wrong receipt,
|
||||
path traversal, malformed ID and changed archive bytes. The catalog's
|
||||
**4 works / 50 partial / 51 broken / 11 N/A** statuses are unchanged. Deck
|
||||
Shelves remains partial; installed settings, home-shelf behavior, worn VR and
|
||||
physical controller input remain unverified.
|
||||
@@ -4,7 +4,17 @@ The exact official Deck Shelves 2.0.0 archive (store ID 135, SHA-256 `b566280ad3
|
||||
|
||||
Framey now serves JavaScript files from the managed plugin's `dist` directory using hierarchical module URLs. Its opaque `allow-scripts` iframe still has no Frame UI key. Local Chromium received HTTP 200 for the entry and eight chunks, reported the initializer ready, and rendered the settings panel with “Enable shelves on Home,” “Create default shelves,” “Behavior,” and “Shelves.” The browser audit denied the attempted `get_settings` backend call, Steam library read and plugin network access. No backend method executed.
|
||||
|
||||
The catalog moves this exact version from broken to **partial** for visible local settings only. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Settings writes, official backend behavior, real Frame module serving, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
|
||||
The catalog moves this exact version from broken to **partial** for visible local settings. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Actual Decky RPC, real Frame settings, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
|
||||
|
||||
## Isolated official backend settings check
|
||||
|
||||
I verified the same archive digest before loading its unchanged `Deck-Shelves/main.py` in local Python. A stub `decky` module supplied only a logger; `DECKY_PLUGIN_SETTINGS_DIR` and `DECKY_HOME` pointed inside a temporary directory. No Decky service, headset, Steam API, or network call was involved. Calling `_main`, `get_settings`, and `set_settings` returned the default disabled state, saved one shelf named `Local Test`, and returned that shelf from a fresh `Plugin` instance. The temporary directory was removed after the check.
|
||||
|
||||
```json
|
||||
{"archiveSha256":"b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91","defaultEnabled":false,"defaultShelves":0,"saveResult":true,"reopenedEnabled":true,"reopenedShelfTitle":"Local Test","settingsFileExists":true}
|
||||
```
|
||||
|
||||
This proves only the archive's Python settings logic under those local conditions. It does not prove that Decky 3.3.0 can dispatch these methods on the ARM64 Frame, that the opaque panel can save settings through the production bridge, or that a shelf appears on Steam Home or in VR.
|
||||
|
||||
## Exact source real Frame gates
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T09:28:48.295443+00:00",
|
||||
"plugin": {
|
||||
"id": 124,
|
||||
"name": "AlarMe",
|
||||
"version": "1.6.3",
|
||||
"sha256": "17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe"
|
||||
},
|
||||
"status": "partial",
|
||||
"sharedControls": {
|
||||
"ConfirmModalOfficialImporters": 43,
|
||||
"FocusableOfficialImporters": 65,
|
||||
"claim": "Import counts show shared surface, not successful behavior in every plugin. Only AlarMe received a real-backend follow-up."
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6",
|
||||
"arch": "aarch64",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"transport": "trusted SSH through coordinator host; receipt-bound real CEF Decky RPC/events",
|
||||
"isolation": "Exact official archive installed by production APPLY into a random private path. Removable unprivileged user service, not enabled, RuntimeMaxSec=900 and TimeoutStopSec=5.",
|
||||
"settings": {
|
||||
"timer_subtle_mode": true,
|
||||
"timer_volume": 0,
|
||||
"timer_auto_suspend": false,
|
||||
"timer_prevent_sleep": false,
|
||||
"pomodoro_subtle_mode": true,
|
||||
"pomodoro_volume": 0,
|
||||
"pomodoro_prevent_sleep": false,
|
||||
"overlay_enabled": false
|
||||
},
|
||||
"safeRpcResults": [
|
||||
{
|
||||
"method": "create_timer",
|
||||
"args": [
|
||||
300,
|
||||
"Framey silent private timer",
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"result": "91c1f87d"
|
||||
},
|
||||
{
|
||||
"method": "pause_timer",
|
||||
"args": [
|
||||
"91c1f87d"
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"method": "save_preset_from_timer",
|
||||
"args": [
|
||||
"91c1f87d"
|
||||
],
|
||||
"result": {
|
||||
"id": "47c046cd",
|
||||
"seconds": 300,
|
||||
"label": "Framey silent private timer",
|
||||
"subtle_mode": true,
|
||||
"auto_suspend": false,
|
||||
"prevent_sleep": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "remove_preset",
|
||||
"args": [
|
||||
"47c046cd"
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"method": "cancel_timer",
|
||||
"args": [
|
||||
"91c1f87d"
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"method": "stopwatch_start",
|
||||
"args": [],
|
||||
"result": {
|
||||
"status": "running",
|
||||
"start_time": 1793006404.791917,
|
||||
"elapsed_ms": 0,
|
||||
"laps": [],
|
||||
"prevent_sleep": false,
|
||||
"current_elapsed_ms": 1.0344982147216797
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "stopwatch_lap",
|
||||
"args": [],
|
||||
"result": {
|
||||
"status": "running",
|
||||
"start_time": 1793006404.791917,
|
||||
"elapsed_ms": 0,
|
||||
"laps": [
|
||||
{
|
||||
"label": "Lap 1",
|
||||
"absolute_ms": 31747.3783493042,
|
||||
"split_ms": 31747.3783493042
|
||||
}
|
||||
],
|
||||
"prevent_sleep": false,
|
||||
"current_elapsed_ms": 31750.77533721924,
|
||||
"total_lap_count": 1,
|
||||
"lap_limit_reached": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "stopwatch_pause",
|
||||
"args": [],
|
||||
"result": {
|
||||
"status": "paused",
|
||||
"start_time": null,
|
||||
"elapsed_ms": 34339.426040649414,
|
||||
"laps": [
|
||||
{
|
||||
"label": "Lap 1",
|
||||
"absolute_ms": 31747.3783493042,
|
||||
"split_ms": 31747.3783493042
|
||||
}
|
||||
],
|
||||
"prevent_sleep": false,
|
||||
"current_elapsed_ms": 34339.426040649414,
|
||||
"total_lap_count": 1,
|
||||
"lap_limit_reached": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "stopwatch_reset",
|
||||
"args": [],
|
||||
"result": {
|
||||
"status": "idle",
|
||||
"start_time": null,
|
||||
"elapsed_ms": 0,
|
||||
"laps": [],
|
||||
"current_elapsed_ms": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"eventCounts": {
|
||||
"alarme_timers_updated": 3,
|
||||
"alarme_sleep_inhibitor_updated": 6,
|
||||
"alarme_timer_tick": 124,
|
||||
"alarme_presets_updated": 2,
|
||||
"alarme_stopwatch_updated": 3
|
||||
},
|
||||
"finalReadback": {
|
||||
"timers": [],
|
||||
"stopwatch": {
|
||||
"status": "idle",
|
||||
"start_time": null,
|
||||
"elapsed_ms": 0,
|
||||
"laps": [],
|
||||
"current_elapsed_ms": 0
|
||||
},
|
||||
"sleep": {
|
||||
"active": false,
|
||||
"items": []
|
||||
},
|
||||
"presets": [
|
||||
{
|
||||
"id": "preset-5",
|
||||
"seconds": 300,
|
||||
"label": "5 minutes",
|
||||
"subtle_mode": false,
|
||||
"auto_suspend": false
|
||||
},
|
||||
{
|
||||
"id": "preset-10",
|
||||
"seconds": 600,
|
||||
"label": "10 minutes",
|
||||
"subtle_mode": false,
|
||||
"auto_suspend": false
|
||||
},
|
||||
{
|
||||
"id": "preset-15",
|
||||
"seconds": 900,
|
||||
"label": "15 minutes",
|
||||
"subtle_mode": false,
|
||||
"auto_suspend": false
|
||||
},
|
||||
{
|
||||
"id": "preset-30",
|
||||
"seconds": 1800,
|
||||
"label": "30 minutes",
|
||||
"subtle_mode": false,
|
||||
"auto_suspend": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"before": {
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"battery": [
|
||||
97
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"battery": [
|
||||
96
|
||||
]
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"lockReleased": true
|
||||
},
|
||||
"browser": {
|
||||
"host": "Repository headset.html and opaque allow-scripts iframe with bundled React 18.3.1; loopback RPC/event relay to the real ARM64 backend",
|
||||
"verified": [
|
||||
"labeled silent timer creation and live tick display",
|
||||
"timer pause through onActivate and backend readback",
|
||||
"save paused timer as preset through onActivate",
|
||||
"delete confirmation shows preset name and description",
|
||||
"Cancel closes dialog without dispatching remove_preset; backend readback retains preset",
|
||||
"Delete invokes remove_preset once and readback removes the private preset",
|
||||
"timer cancellation",
|
||||
"Watch tab activation; stopwatch start/lap/pause",
|
||||
"fresh panel reopen retains paused 34.339-second stopwatch and one lap",
|
||||
"stopwatch reset; no active timers or sleep inhibitors in final backend readback"
|
||||
],
|
||||
"domFixture": {
|
||||
"type": "fixture:result",
|
||||
"checks": [
|
||||
"description visible",
|
||||
"underlying panel inert",
|
||||
"dialog fixed",
|
||||
"Tab wraps in top dialog",
|
||||
"Cancel callback once",
|
||||
"parent draft preserved",
|
||||
"disabled actions",
|
||||
"old Close retains child",
|
||||
"programmatic Close skips callback",
|
||||
"OK does not close newly opened dialog",
|
||||
"Update changes body",
|
||||
"Escape cancels top dialog",
|
||||
"panel reactivated",
|
||||
"pointer Enter Space activate once each"
|
||||
]
|
||||
},
|
||||
"limits": "Local Chromium with real backend, not the deployed headset HTTP host or worn VR. Synthetic DOM checks use programmatic DOM events, not physical controllers."
|
||||
},
|
||||
"fixtureCorrection": {
|
||||
"issue": "Initial seed used settings/<display-name>; actual Decky uses settings/<folder>. Safety assertion failed on readback before any timer action.",
|
||||
"resolution": "First private service/data removed and lock released. Correct folder seed read back before second run exposed actions."
|
||||
},
|
||||
"cleanupAudit": {
|
||||
"servicesAudited": 2,
|
||||
"allBaseUnitArchiveAbsent": true,
|
||||
"allLoadStateNotFound": true,
|
||||
"allActiveStateInactive": true,
|
||||
"allMainPidZero": true,
|
||||
"sharedLock": "Fresh atomic lock acquired and released for cleanup audit"
|
||||
},
|
||||
"unverified": [
|
||||
"audio playback and sound imports",
|
||||
"alarms and reminders",
|
||||
"auto-suspend and /dev/uinput sleep inhibition",
|
||||
"in-game/global Steam overlay: no injection target in isolated panel",
|
||||
"background notifications after panel closes",
|
||||
"worn VR and physical controller/keyboard input"
|
||||
],
|
||||
"validation": {
|
||||
"fullPython": {
|
||||
"ran": 1628,
|
||||
"skipped": 16,
|
||||
"result": "pass"
|
||||
},
|
||||
"nodeContracts": [
|
||||
"tests/page/framey_actions.mjs",
|
||||
"tests/page/framey_running.mjs"
|
||||
],
|
||||
"siteTestFiles": 2,
|
||||
"currentStoreRefresh": {
|
||||
"plugins": 116,
|
||||
"exactIdsVersionsAndDigestsUnchanged": true
|
||||
},
|
||||
"fakeFrame": {
|
||||
"blocked": 56,
|
||||
"installed-removed": 60
|
||||
},
|
||||
"noContextBrowser": {
|
||||
"plugins": 116,
|
||||
"ready": 101,
|
||||
"failed": 15,
|
||||
"noRegressionsAgainstCatalogBaseline": true,
|
||||
"scope": "Initialization only, all backend/network denied; library-dependent plugins lack Steam context"
|
||||
},
|
||||
"contextReplayBrowser": {
|
||||
"plugins": 116,
|
||||
"ready": 104,
|
||||
"failed": 12,
|
||||
"initializationRegressionsAgainstPriorContextReplay": [],
|
||||
"scope": "Synthetic library/running replay; backend and network denied, initialization only"
|
||||
}
|
||||
},
|
||||
"reviewFollowUp": {
|
||||
"checkedAtUtc": "2026-10-26T10:10:05.650676+00:00",
|
||||
"change": "Update reinjects the scoped close callback into replacement React dialogs and uses the replacement close callback.",
|
||||
"verification": "Node regression: updated ConfirmModal OK and Cancel, with and without custom close callbacks; ClosedPromise resolves; original callback not retained; repeated Close/Update inert. Regression reproduced before the fix.",
|
||||
"limits": "No additional local Chromium or real Frame check for this small follow-up; prior runtime evidence applies to initial dialog and workflow paths."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# Framey activation and dialog follow-up — 2026-10-26
|
||||
|
||||
AlarMe 1.6.3 now has usable timer, preset and stopwatch controls in the local
|
||||
Framey host with its official backend running on the ARM64 Frame. It remains
|
||||
**partial**. This check does not establish audio, alarm/reminder, sleep-control,
|
||||
background-notification or worn-VR compatibility.
|
||||
|
||||
The exact archive is store ID 124, SHA-256
|
||||
`17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe`.
|
||||
See the [structured results](framey-dialog-controls-2026-10-26.json).
|
||||
|
||||
## Reproduced blockers and repairs
|
||||
|
||||
AlarMe uses DFL `Focusable.onActivate` for timer pause, save-as-preset, cancel,
|
||||
preset deletion, tabs and stopwatch controls. Framey's previous `div`
|
||||
passthrough ignored that callback: clicks on the save/pause controls did not
|
||||
dispatch backend calls. Focusable now maps pointer activation and Enter/Space
|
||||
to plugin callbacks, exposes a keyboard focus target and respects disabled
|
||||
controls. A click does not invoke both `onClick` and `onActivate` when a plugin
|
||||
supplies both; nested activation stops bubbling to the parent action.
|
||||
|
||||
The previous ConfirmModal omitted `strDescription`, Cancel, middle actions and
|
||||
disabled flags. showModal did not inject a `closeModal` callback into custom
|
||||
dialog components, and every Close handle cleared the same root. A confirmation
|
||||
opened by another dialog could therefore disappear when the earlier action
|
||||
closed. Dialogs also appeared below the panel's long page content.
|
||||
|
||||
Dialogs now occupy a scrollable overlay inside the opaque iframe. They preserve
|
||||
the underlying dialog's mounted state, receive scoped close callbacks, support
|
||||
Close/ClosedPromise/Update, show confirmation descriptions and cancel/middle
|
||||
actions, and respect disabled flags. The underlying plugin surface becomes
|
||||
inert while a dialog is open; Tab stays in the top dialog, Escape cancels it and
|
||||
closing restores focus. Steam popouts and controller navigation are not emulated.
|
||||
|
||||
43 official archives import ConfirmModal and 65 import Focusable in this
|
||||
snapshot. These are shared API surfaces, **not** verification or status
|
||||
promotions for every importing plugin.
|
||||
|
||||
## Real backend and local browser verification
|
||||
|
||||
Held the normal atomic shared headset lock. Installed the exact official archive
|
||||
through the production archive validation/extraction path into a random private
|
||||
directory. Used published Decky 3.3.0 ARM64, an unprivileged user service that was
|
||||
never enabled, with a 900-second runtime bound and five-second stop timeout.
|
||||
RPCs and events used Framey's production receipt-bound bridge through real Steam
|
||||
CEF. The Chromium host served repository headset.html, React and framey-host.js;
|
||||
its status endpoints were fixture-shaped, while AlarMe RPCs/events were live.
|
||||
|
||||
The initial private settings seed used the plugin display name instead of the
|
||||
archive folder. A safety assertion failed on readback of the defaults before any
|
||||
timer action. That service/data was removed and the lock released. The corrected
|
||||
run read back subtle mode, zero volume, no auto-suspend, no sleep inhibition and
|
||||
no overlay before exposing actions. Its relay allowed only the documented safe
|
||||
methods; sound, suspend, sleep-inhibitor and arbitrary plugin actions were denied.
|
||||
|
||||
Verified with actual browser clicks and real backend readback:
|
||||
|
||||
1. Add Label opened a custom timer dialog. Created a silent five-minute timer
|
||||
named `Framey silent private timer`; live backend ticks changed its display.
|
||||
2. Pause and save-as-preset activated after the Focusable repair. Backend
|
||||
readback reported a paused timer and its saved preset.
|
||||
3. Delete Preset showed the preset's name in its confirmation description.
|
||||
Cancel closed the dialog without dispatching `remove_preset`; readback kept
|
||||
the preset. Reopening and choosing Delete dispatched it once and removed it.
|
||||
4. Cancel removed the private timer. Watch opened the stopwatch; Start, Lap and
|
||||
Pause produced real backend state and events.
|
||||
5. Closing and reopening the panel retained the paused stopwatch at 34.339
|
||||
seconds and one lap. Reset cleared it. Final readback showed no active timers,
|
||||
no laps and no active sleep inhibitor.
|
||||
|
||||
A separate local Chromium DOM fixture passed 14 checks for nested-dialog state,
|
||||
scoped handles, descriptions, disabled buttons, Cancel, Escape, Tab wrapping,
|
||||
Close/ClosedPromise/Update and pointer/Enter/Space activation. These programmatic
|
||||
DOM checks do not verify physical input devices.
|
||||
|
||||
Review follow-up: Update also injects the scoped close callback into replacement
|
||||
dialogs. Node regression contracts update to ConfirmModal and exercise both OK
|
||||
and Cancel, with and without a plugin close callback, then await ClosedPromise.
|
||||
They verify that the replacement's callback runs once and the original callback
|
||||
is not retained. This extends the local host's Update behavior so replacements
|
||||
remain dismissible; this follow-up was not rerun in Chromium or on the headset.
|
||||
|
||||
## Cleanup and remaining limits
|
||||
|
||||
Removed both private service units, bases and uploaded archives; reloaded the
|
||||
user service manager and released the matching shared lock. A fresh locked audit
|
||||
confirmed both units were not found, inactive and PID zero, with all test paths
|
||||
absent. Managed Decky remained failed and the headset panel inactive, matching
|
||||
the initial state. Battery was 97% before and 96% after. No power settings changed.
|
||||
|
||||
AlarMe stays partial. Audio, sound imports, alarms/reminders, auto-suspend and
|
||||
`/dev/uinput` sleep inhibition were not exercised. Steam's global/in-game overlay
|
||||
has no injection target inside Framey's isolated panel. Notifications require
|
||||
the plugin panel to remain open. The deployed headset HTTP host, worn VR and
|
||||
physical controller/keyboard input were not tested in this pass.
|
||||
|
||||
The store and documentation use the same exact-digest catalog reason. Aggregate
|
||||
counts remain **4 works / 49 partial / 52 broken / 11 N/A**.
|
||||
|
||||
Validation: all 116 official IDs/versions/digests unchanged on store refresh;
|
||||
fake-Frame 60 installed/removed and 56 blocked; full Python suite 1,628 tests,
|
||||
16 skipped; Framey action/running Node contracts and both site test files pass.
|
||||
The no-Steam-context Chromium pass attempted all 116 frontends (101 initialized,
|
||||
15 failed); backend and network access were denied throughout that pass.
|
||||
The repeat with synthetic library/running context initialized 104 of 116, with no
|
||||
initialization regressions against the prior context-replay pass. Neither
|
||||
initialization pass is a useful-functionality check.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Free Loader duplicate archive entries, 26 October 2026
|
||||
|
||||
The exact official Free Loader 1.6.0 archive is store ID 63, SHA-256
|
||||
`05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a`.
|
||||
It has 23 ZIP entries. Seven names under `free-loader/py_modules/` occur
|
||||
twice. Each duplicate pair has identical uncompressed bytes and file type;
|
||||
five Python files and `.keep` differ only between mode `0644` and `0755`.
|
||||
The directory entry is identical. No path escape, symlink or alternate content
|
||||
was accepted.
|
||||
|
||||
The installer now accepts an exact duplicate name only when its bytes match
|
||||
and its mode differs at most in execute bits. Both desktop validation and the
|
||||
headset transaction enforce that rule before extraction. The existing member
|
||||
count, unpacked-size, per-member-size, root, path and ARM64 checks still run.
|
||||
Synthetic tests reject changed duplicate payloads and non-execute mode changes.
|
||||
|
||||
## Scoped verification
|
||||
|
||||
- The exact archive passed the revised validator. A private local transaction
|
||||
installed and removed its `free-loader` folder and receipt; cleanup left no
|
||||
plugin folder. The focused store suite passed 21/21 tests, including a
|
||||
duplicate-member install/remove and permission readback.
|
||||
- After merging current main, the full local Python suite passed **1,637
|
||||
tests, 16 skipped**. The generated compatibility document check and
|
||||
whitespace check passed.
|
||||
- The pinned official fake-Frame HTTP/SSH install/remove test passed with this
|
||||
archive alongside the other pinned fixtures. It ran in a uniquely named
|
||||
disposable Compose project; its containers, network and volumes were removed.
|
||||
The fake does not execute the plugin's Python backend.
|
||||
- The current Framey opaque Chromium host on main `d963305` initialized the
|
||||
exact frontend. Its **Free Games** view rendered, then displayed “Failed to
|
||||
load games!” because the audit intentionally denied all backend/network
|
||||
calls. It attempted `settings_read`, `read_deals` and logger methods.
|
||||
The browser audit's former `local_bundle` stub had returned HTTP 404 after
|
||||
receipt-bound panel lookup was introduced; the audit now supplies the
|
||||
digest-checked bundle and receipt to the shipped panel renderer.
|
||||
- Exact official Python backend code ran in a disposable Python 3.12 container
|
||||
with network disabled, read-only root, no capabilities, a private data mount
|
||||
and a minimal `decky` API stub. `_main`, `settings_read` and `read_deals`
|
||||
succeeded: 14 default settings, a 12-hour update frequency, an empty deals
|
||||
map, and only private `settings.json`/`deal_db.json` files. The container
|
||||
and its private files were removed. No feed refresh or notification ran.
|
||||
|
||||
This makes **partial** the accurate digest-scoped status. The earlier full
|
||||
116-plugin fake-Frame catalog audit blocked this archive before the validator
|
||||
change; the focused pinned flow now passes. Actual Decky 3.3.0 backend load,
|
||||
live giveaway feeds, notification delivery, headset install, worn UI and
|
||||
physical controller input remain unverified. No real Frame session or service
|
||||
was touched for this change.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Game Theme Music local panel check, 26 October 2026
|
||||
|
||||
The exact official Game Theme Music 1.7.1-1 archive (store ID 52, SHA-256 `9ce293ab4db212c03c6b96dfbc5e4d92e874d33387463f53223773921f75a80e`) passed the existing fake Frame install/remove audit. Its prior local Chromium run stopped before initialization with `Cannot read properties of null (reading 'type')`: the frontend dereferenced the result of `DFL.fakeRenderComponent` while looking for Steam's Library Context Menu.
|
||||
|
||||
Framey's isolated host now returns an inert local component type from that lookup. The patch API remains a no-op and does not expose or modify a Steam React tree. In the repository's opaque Chromium audit, the unchanged archive reported initializer ready and rendered a Settings view containing Music Volume, Default Muted, Use yt-dlp, Invidious Instance, Download Music, Overrides and Extras. The audit observed a `get_setting` request, but denied the backend call. It also denied plugin network requests; a YouTube/Invidious fetch failed as expected. The bounded root-text observation came from the test iframe and contained only these public UI labels.
|
||||
|
||||
The catalog therefore moves this digest from **broken** to **partial** for the visible local settings panel. No theme was played or injected into a Steam game page. Music search, playback, downloads, settings persistence, live ARM64 backend behavior, wearer-visible VR and physical controller input remain unverified. The earlier failed browser observation remains in the canonical JSON alongside this follow-up so the change in evidence is explicit.
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T08:14:37.777131+00:00",
|
||||
"plugin": {
|
||||
"id": 84,
|
||||
"name": "Junk-Store",
|
||||
"version": "1.1.13",
|
||||
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1"
|
||||
},
|
||||
"projection": {
|
||||
"field": "local_per_client_data.installed",
|
||||
"wire": "optional ninth scalar: boolean or null",
|
||||
"unknown": "null creates an empty detached metadata object; installed stays undefined",
|
||||
"privateFieldsCopied": false,
|
||||
"oldEightScalarRows": "accepted; running updates preserve existing library metadata",
|
||||
"liveObservation": false
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6",
|
||||
"arch": "aarch64",
|
||||
"uid": 1000,
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"transport": "trusted SSH through lxso2",
|
||||
"sharedLock": true,
|
||||
"runtime": "private bounded removable user service; not enabled",
|
||||
"library": {
|
||||
"apps": 1106,
|
||||
"games": 895,
|
||||
"installedTrue": 41,
|
||||
"installedFalse": 0,
|
||||
"installedUnknown": 1065
|
||||
},
|
||||
"protonRuntimeInstalledFields": "undefined for both EasyAntiCheat and BattlEye; no false value invented",
|
||||
"legendaryInstalled": false,
|
||||
"allowedMethods": [
|
||||
"execute_action",
|
||||
"get_websocket_port"
|
||||
],
|
||||
"staticActions": [
|
||||
"MainMenu/GetContent",
|
||||
"init/InitActions"
|
||||
],
|
||||
"noMutationActions": true
|
||||
},
|
||||
"localBrowser": {
|
||||
"host": "actual headset.html/framey-host.js, vendored React, opaque iframe",
|
||||
"backend": "exact official ARM64 plugin through production receipt-bound bridge",
|
||||
"library": "production receipt-bound snapshot from real Frame CEF",
|
||||
"outerStatusInstallCheck": "fixture responses",
|
||||
"passed": [
|
||||
"main menu",
|
||||
"About content and tab list",
|
||||
"Dependencies tab controls render; no buttons invoked",
|
||||
"Back to main menu",
|
||||
"close/reopen and About again"
|
||||
],
|
||||
"limits": [
|
||||
"optional localhost websocket blocked by test relay CSP",
|
||||
"RSS calls denied",
|
||||
"running-context relay returns fixture/unavailable data; no lifetime claims",
|
||||
"outer extension/status fixture is incomplete; unrelated outer status/extension errors excluded from plugin claim",
|
||||
"deployed headset HTTP endpoint unverified",
|
||||
"worn VR unverified"
|
||||
]
|
||||
},
|
||||
"cleanup": {
|
||||
"initialSshStop": "timed out while test unit deactivating",
|
||||
"recovery": "SIGKILL to exact owned unit only after matching lock owner; private files removed",
|
||||
"result": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"archiveAbsent": true,
|
||||
"state": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
|
||||
"normal": {
|
||||
"frame-control-decky.service": "inactive",
|
||||
"frame-control-headset.service": "inactive"
|
||||
},
|
||||
"battery": [
|
||||
99
|
||||
]
|
||||
},
|
||||
"freshLockedAudit": {
|
||||
"services": 1,
|
||||
"allArtifactsAbsent": true,
|
||||
"allUnloadedInactivePidZero": true
|
||||
},
|
||||
"lockReleased": true,
|
||||
"batteryBeforeAfter": [
|
||||
[
|
||||
99
|
||||
],
|
||||
[
|
||||
99
|
||||
]
|
||||
],
|
||||
"powerSettingChanged": false,
|
||||
"localRelayAndBrowserClosed": true
|
||||
},
|
||||
"verification": {
|
||||
"python": {
|
||||
"run": 1628,
|
||||
"skipped": 16,
|
||||
"failures": 0
|
||||
},
|
||||
"panelFocused": {
|
||||
"run": 22,
|
||||
"failures": 0
|
||||
},
|
||||
"nodeContracts": [
|
||||
"framey_actions.mjs",
|
||||
"framey_running.mjs"
|
||||
],
|
||||
"siteTestFiles": {
|
||||
"passed": 2,
|
||||
"failures": 0
|
||||
},
|
||||
"fakeFrame": {
|
||||
"blocked": 56,
|
||||
"installed-removed": 60
|
||||
},
|
||||
"officialStore": {
|
||||
"plugins": 116,
|
||||
"errors": 0,
|
||||
"identitySetUnchanged": true,
|
||||
"checkedAtUtc": "2026-10-26T08:21:01.653768+00:00"
|
||||
}
|
||||
},
|
||||
"status": "partial",
|
||||
"unverified": [
|
||||
"dependency/runtime installation",
|
||||
"login",
|
||||
"game listing/download/launch",
|
||||
"RSS",
|
||||
"optional websocket operations",
|
||||
"Steam shortcut mutation",
|
||||
"worn VR"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Junk-Store About and read-only installed metadata
|
||||
|
||||
Junk-Store 1.1.13 (official ID 84) now renders its About and Dependencies tabs
|
||||
without the `local_per_client_data.installed` crash recorded in the
|
||||
[script-entrypoint audit](framey-script-entrypoints-2026-10-26.md). It remains
|
||||
**partial**. The catalog and both store views use the same updated reason.
|
||||
|
||||
The library snapshot omitted the entire `local_per_client_data` object. The
|
||||
official frontend dereferences it to display its dependency controls. Under the
|
||||
shared headset lock, a read-only probe confirmed that the real Frame exposes
|
||||
this object for the two Proton runtimes, but neither has an `installed` boolean.
|
||||
Across 1,106 app overviews, 41 had `installed: true`, none had `false`, and 1,065
|
||||
had no installed boolean. Those absent values must not be relabelled as false.
|
||||
|
||||
The snapshot adds one bounded scalar per app: a Steam-supplied boolean or null.
|
||||
Framey's detached overview creates a minimal metadata object, copying `installed`
|
||||
only for a boolean. Null leaves that property undefined. No client IDs, paths,
|
||||
other metadata, live Steam objects or methods cross the iframe boundary. Eight
|
||||
scalar rows remain accepted, and running-app updates preserve library metadata.
|
||||
The snapshot still refreshes at panel open; it is not a live installation feed.
|
||||
|
||||
## Actual ARM64 backend and local Chromium
|
||||
|
||||
The unmodified, hash-verified official archive ran in published Decky 3.3.0 ARM64
|
||||
on Frame OS 0.5.6 using a private, bounded, removable user service. Legendary was
|
||||
absent; no dependency installer, runtime install, login, game operation or custom
|
||||
backend download was invoked. The exact plugin and loader digests are in the
|
||||
[redacted results](framey-installed-context-2026-10-26.json).
|
||||
|
||||
Local Chromium ran the actual Framey host and official frontend in an opaque
|
||||
iframe. The production receipt-bound bridges read the real Steam library and
|
||||
called the official ARM64 backend's static menu actions. About content and its
|
||||
tab list, Dependencies controls, Back, close/reopen, and About again passed.
|
||||
No dependency buttons were pressed. The real library counts matched the probe.
|
||||
|
||||
The optional localhost websocket was blocked by the test relay CSP; RSS was
|
||||
deliberately denied. Outer status/install/check replies were fixtures; incomplete
|
||||
outer extension/status and running-context replies are excluded from the plugin
|
||||
claim. The screenshot still used the catalog reason from before this follow-up.
|
||||
Dependency installation, login, game operations, RSS, websocket operations,
|
||||
shortcut changes, deployed headset HTTP and worn VR remain unverified.
|
||||
|
||||
## Cleanup and regression checks
|
||||
|
||||
The initial cleanup SSH timed out while the private Decky unit was deactivating.
|
||||
Recovery checked the matching lock owner, killed only that exact test unit, and
|
||||
removed its private base, unit file and archive. A fresh locked audit independently
|
||||
confirmed unloaded/inactive/PID 0 and no artifacts, then released its lock. Normal
|
||||
managed Decky/panel services stayed inactive; battery was 99 → 99. No power-setting
|
||||
action ran. Local relay and owned browser tab are closed.
|
||||
|
||||
- Full Python suite: 1,628 tests, 16 skipped, no failures; focused panel: 22 passed.
|
||||
- Framey action/running Node contracts passed, including true/false/unknown,
|
||||
malformed metadata, detached values, old rows and running-update preservation.
|
||||
- Both site test files passed. Generated docs and diff checks passed.
|
||||
- All 116 exact official archives passed the fake-Frame audit: 60 installed/removed,
|
||||
56 expected blocks; disposable project removed. Backend execution is stubbed in
|
||||
that harness. The fresh official store has 116 entries, no audit errors, and an
|
||||
unchanged ID/version/digest set.
|
||||
|
||||
No new works claim is made. Catalog totals remain **4 works / 49 partial / 52 broken
|
||||
/ 11 N/A**; only Junk-Store's verified scope and remaining limits change.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Framey receipt guard, unworn Frame exercise (26 October 2026)
|
||||
|
||||
The bounded `scripts/framey-vr-check.py --exercise` runner acquired `/tmp/frame-test.lock` with its own random owner before reading the real Frame. Its 05:26 UTC run used source commit `c2f94c242dac670e18259193311a7375f5a87942`. Preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. No wearer was confirmed.
|
||||
|
||||
The temporary Frame Control dashboard overlay became visible to OpenVR. The stereo compositor image changed, and the `/dev/video99` mirror produced a frame. The sample settings panel displayed its stored off value, accepted a trusted synthetic OpenVR pointer event, saved on through the real Decky Python backend, read the setting from disk, and showed on after a fresh iframe reopen. The quick action read on. A native slider reached 50. Text entry used CDP after OpenVR focus. The runner exited 0 with `input.passed=true` and `compositorChanged=true`.
|
||||
|
||||
The private capture files remain outside the repository at `/tmp/frame-pr301-unworn-vr-fixed/`. The before/after stereo SHA-256 values were `1683bf1dab70c1a0a1e8f313c8317dc04f992070b5b0de232de9008700130711` and `684a27eecf7bcbbc5308092ddbb1517c4596fb283e061e73dd6bb7f025954e8b`; the mirror frame SHA-256 was `5939149ea9c85fd2d86d3f8879c348f006c421a386bd66a93fe`. Room pixels are not committed.
|
||||
|
||||
An earlier locked run at 05:24 UTC exposed a missing SHA-256 in the runner's temporary sample receipt: the new guard correctly asked to reopen the panel. Commit `c2f94c2` gives that sample a content digest; the second run passed. Both runs reported no cleanup errors, restored `power.pauseCompositorOnStandby=false` and `power.turnOffScreensTimeout=5`, removed their power recovery timers, and released only their own locks. Battery remained 99%.
|
||||
|
||||
This is an **unworn sample-plugin result**. It does not prove wearer-visible pixels, physical controller input, native scrolling (the attempted scroll offset stayed 0), VR keyboard behavior, comfort, or any official Decky plugin's VR panel or backend. The hourly worn check remains enabled.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
||||
# Running-game context and Discord Status, 2026-10-26
|
||||
|
||||
Framey now projects running apps and the selected main app from Steam CEF into
|
||||
detached `SteamUIStore` / `DFL.Router` getters. It relays actual Steam lifetime
|
||||
callbacks while the panel is open. Public Steam CDN capsule URLs are projected
|
||||
for running apps; local artwork paths and URL query data never cross the bridge.
|
||||
The [machine-readable evidence](framey-running-context-2026-10-26.json) records
|
||||
the exact archives, fixtures, browser observations and cleanup results.
|
||||
|
||||
| Plugin / scope | Result | Verification |
|
||||
| --- | --- | --- |
|
||||
| Discord Status 1.5.0 | **Broken → partial.** Disconnected panel renders; activity construction accepts the synthetic running game without the previous missing-store/artwork errors. | Exact unmodified official frontend in opaque Chromium iframe; backend/network denied. Actual official `is_connected` returns false in real Decky 3.3.0 ARM64, with no Discord IPC present. |
|
||||
| Pause Games | **Partial.** Running-list initialization reaches `pid_from_appid` for the synthetic game. | Local Chromium fixture; all backend calls, including settings writes and process actions, denied. |
|
||||
| Notebook | **Works for its previously verified default notebook scope.** Per-game context is now supplied, but real per-game selection remains unverified. | Synthetic game causes the official frontend to request `loadPage`; backend calls denied in this pass. Earlier default save/reopen ARM64 evidence remains separate. |
|
||||
| PlayTime | **Partial.** Running snapshots do not satisfy its private MobX observation. | Local Chromium explicitly logs that its running-app observer is unavailable. No automatic tracking claim. |
|
||||
| 14 other focused panels | Labels retained. Initialization alone does not establish backend functionality. | Exact frontends with synthetic nonempty running/library snapshots and backend/network denied; Game Sync still stops on the denied `get_config` read. |
|
||||
|
||||
The bridge addresses a shared dependency detected in 34 official frontend
|
||||
bundles, including hardware-inapplicable plugins whose installation remains
|
||||
blocked. This is infrastructure coverage, not 34 working plugins. Catalog after
|
||||
this pass: **3 works / 49 partial / 53 broken / 11 N/A**.
|
||||
|
||||
## Real Frame checks and cleanup
|
||||
|
||||
Every device probe acquired the atomic `/tmp/frame-test.lock` and released only
|
||||
its matching owner. Read-only probes verified the actual RunningApps and
|
||||
MainRunningApp getter implementations, the lifetime API's object with an
|
||||
`unregister()` method, and a public `https://shared.steamstatic.com` capsule URL.
|
||||
|
||||
A private removable user service ran the published Decky 3.3.0 ARM64 executable
|
||||
and the exact unmodified Discord Status 1.5.0 store archive. Production
|
||||
`framey_panel.call_method` returned false from `is_connected`. Production
|
||||
`poll_running` verified two empty running-list reads, an actual lifetime
|
||||
subscription and explicit close. The device had no running apps: **nonempty
|
||||
reads and launch/exit callbacks were not observed on hardware**. Decky startup
|
||||
reinjected its frontend/restarted Steam CEF. No plugin game, power, audio or process-control
|
||||
action was invoked, and no Discord connection/activity mutation was allowed.
|
||||
|
||||
After close, CEF reported zero sessions and a null timer. The test service,
|
||||
unit and private base were removed. A fresh independently locked audit read
|
||||
`LoadState=not-found`, `ActiveState=inactive`, `MainPID=0`, absent paths and zero
|
||||
CEF sessions/timers. Battery stayed **99 → 99**; normal Decky stayed failed and
|
||||
the normal panel service stayed inactive. No headset wipe or system service
|
||||
installation was used.
|
||||
|
||||
## Local verification and limits
|
||||
|
||||
- Full current official store fetch: 116 IDs/versions/digests unchanged at
|
||||
2026-10-26 05:18:22 UTC.
|
||||
- Fake Frame: 116 transport cases, 60 install/remove cycles and 56 expected
|
||||
blockers; private Compose project and volumes removed. Plugin execution was
|
||||
stubbed.
|
||||
- Full local Chromium sweep: 103 of 116 initializer-ready with explicitly
|
||||
synthetic library/running data. No backend/network request was allowed.
|
||||
The focused Discord Status observation lasted eight seconds and constructed
|
||||
`Test Running Game` activity; the visible UI correctly remained disconnected.
|
||||
- Node contract tests cover actual-event queue projection, retry/deduplication,
|
||||
bounds, expiry, unregister, reconnect, update-before-callback ordering,
|
||||
invalid data, public-artwork filtering, stale sessions, overlapping reads,
|
||||
failure clearing stale state and late replies after close. Nonempty app rows
|
||||
and launch/exit notifications in these tests are explicit synthetic fixtures.
|
||||
|
||||
The queue retains at most 64 real notifications per session; overflow can lose
|
||||
old callbacks. Polling runs about every two seconds with no overlap. At most four
|
||||
CEF sessions survive; unreachable cleanup expires after 15 seconds, checked
|
||||
every five seconds. The bridge does not implement private MobX internals,
|
||||
suspend/input events, game actions or closed-panel automation.
|
||||
|
||||
Discord shortcut detection, real IPC connection/activity, local custom artwork,
|
||||
actual running-game transitions, worn VR and physical controller/keyboard use
|
||||
remain unverified. None of the new fixture checks promotes a plugin to works.
|
||||
|
||||
After #301 merged, running polls and both close paths adopted its pinned SHA-256
|
||||
receipt guard. Local Python/Node tests verify the parent carries that receipt,
|
||||
missing/stale receipts are rejected before CEF after plugin replacement, and a
|
||||
reopened panel can poll and close with the new receipt. Rejected stale closes
|
||||
retain the tested finite-lease cleanup. This authorization-only follow-up did
|
||||
not repeat the device checks above; their recorded hardware scope is unchanged.
|
||||
|
||||
## Post-merge unworn panel exercise
|
||||
|
||||
On merged main `daee952873255b86018cce1d2a4d3bd070ec4341`,
|
||||
`scripts/framey-vr-check.py --exercise --output /tmp/framey-post304-exercise`
|
||||
acquired its own `/tmp/frame-test.lock` before any headset read. At 05:54 UTC,
|
||||
preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%.
|
||||
The temporary sample panel passed the real Decky setting save and fresh-iframe
|
||||
reopen, a trusted synthetic OpenVR pointer, quick action, slider value 50 and
|
||||
CDP text entry after OpenVR focus. Stereo compositor SHA-256 changed from
|
||||
`50fcab1ee25e6d9398106cc0c24ec6616e07bcf4573b52be1774c715b756060b`
|
||||
to `98241806fb1f775e7f4c5ef0b0564bfd3c552ee896ff225b4d0cb5b7b53f70d1`;
|
||||
the `/dev/video99` mirror capture hash was
|
||||
`c284c05f86b60362168c1d1f58fe775974b70ac54bbfd57834802b892eb4509e`.
|
||||
The command exited 0 with `passed=true`. The original power values (`false` and
|
||||
`5.000000`) were read back, battery remained 99%, the recovery timer was removed,
|
||||
cleanup errors were empty and the run released only its own lock. The private
|
||||
full report is `/tmp/framey-post304-exercise/result.json` on the test host.
|
||||
|
||||
This exercise covered the sample panel path after the running-context merge. It
|
||||
did not observe a nonempty running-game list, a game lifetime callback, Discord
|
||||
activity, a worn display or physical controller input. Synthetic native scroll
|
||||
again did not move (`scrollTop=0`, no wheel event).
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T07:22:08.822239+00:00",
|
||||
"plugin": {
|
||||
"id": 84,
|
||||
"name": "Junk-Store",
|
||||
"version": "1.1.13",
|
||||
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
|
||||
"license": "BSD-3-Clause",
|
||||
"sourceModified": false
|
||||
},
|
||||
"installer": {
|
||||
"beforeScriptMode": "0644",
|
||||
"afterScriptMode": "0744",
|
||||
"policy": "Add owner execute for a shebang or ZIP-declared executable; do not restore archive privilege/write bits. Existing installs require update or reinstall."
|
||||
},
|
||||
"isolatedBackend": {
|
||||
"runtime": "published Decky 3.3.0 amd64",
|
||||
"loaderSha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
|
||||
"isolation": [
|
||||
"unprivileged",
|
||||
"no network",
|
||||
"no devices",
|
||||
"no capabilities",
|
||||
"read-only root",
|
||||
"private tmpfs home and tmp"
|
||||
],
|
||||
"beforeInit": {
|
||||
"result": {
|
||||
"Type": "Error",
|
||||
"Content": {
|
||||
"Message": "Error parsing json result Expecting value: line 1 column 1 (char 0)",
|
||||
"Data": "",
|
||||
"ActionName": "init",
|
||||
"ActionSet": "init"
|
||||
}
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"afterInit": {
|
||||
"result": {
|
||||
"Type": "Success",
|
||||
"Content": {
|
||||
"Message": "Initialized"
|
||||
}
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"beforeInitActions": {
|
||||
"result": {
|
||||
"Type": "Error",
|
||||
"Content": {
|
||||
"Message": "Error parsing json result Expecting value: line 1 column 1 (char 0)",
|
||||
"Data": "",
|
||||
"ActionName": "InitActions",
|
||||
"ActionSet": "init"
|
||||
}
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"afterInitActions": {
|
||||
"outerSuccess": true,
|
||||
"innerType": "ActionSet",
|
||||
"setName": "MainMenu",
|
||||
"actionCount": 4
|
||||
}
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6",
|
||||
"arch": "aarch64",
|
||||
"uid": 1000,
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"transport": "existing trusted SSH via lxso2",
|
||||
"sharedLock": true,
|
||||
"service": "private removable user service; not enabled; RuntimeMaxSec=600",
|
||||
"installedWithProductionApply": true,
|
||||
"scriptMode": "0o744",
|
||||
"legendaryInstalled": false,
|
||||
"backendMethods": [
|
||||
"execute_action",
|
||||
"get_websocket_port"
|
||||
],
|
||||
"staticActions": [
|
||||
"JunkStoreActions/GetContent",
|
||||
"JunkStoreActions/GetEpicActions",
|
||||
"MainMenu/GetContent",
|
||||
"MainMenu/JunkStoreInit",
|
||||
"init/InitActions"
|
||||
],
|
||||
"websocketPort": 8765,
|
||||
"websocketCommandsSent": false,
|
||||
"librarySnapshotCounts": {
|
||||
"apps": 1106,
|
||||
"games": 895
|
||||
},
|
||||
"explicitInitActionInvoked": false,
|
||||
"normalDeckyBeforeAfter": [
|
||||
"inactive",
|
||||
"inactive"
|
||||
],
|
||||
"normalPanelBeforeAfter": [
|
||||
"inactive",
|
||||
"inactive"
|
||||
],
|
||||
"batteryBeforeAfter": [
|
||||
[
|
||||
99
|
||||
],
|
||||
[
|
||||
100
|
||||
]
|
||||
],
|
||||
"powerSettingChanged": false,
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"lockReleased": true
|
||||
},
|
||||
"localBrowser": {
|
||||
"runtime": "local Chromium, real headset.html/framey-host.js, vendored React, official frontend in opaque iframe",
|
||||
"backend": "production receipt-bound bridge to actual ARM64 backend",
|
||||
"steamLibrary": "production receipt-bound GetLibrarySnapshot; only counts retained",
|
||||
"outerStatusInstallResponses": "fixtures",
|
||||
"passed": [
|
||||
"Custom Stores main menu and Games/News/About navigation items render",
|
||||
"Games route opens static Epic tab",
|
||||
"Back returns to main menu",
|
||||
"close/reopen returns to main menu"
|
||||
],
|
||||
"remainingFailure": "About: Cannot read properties of undefined (reading 'installed'); detached app overview lacks local_per_client_data.installed",
|
||||
"deniedActions": [
|
||||
"fetch_rss_feed",
|
||||
"EpicActions/GetContent"
|
||||
],
|
||||
"denialIsNotBackendFailure": true
|
||||
},
|
||||
"cleanupAudit": {
|
||||
"attempts": 3,
|
||||
"allPrivateBasesAbsent": true,
|
||||
"allPrivateUnitsAbsent": true,
|
||||
"allArchivesAbsent": true,
|
||||
"allServicesInactive": true,
|
||||
"freshAuditLockReleased": true
|
||||
},
|
||||
"verification": {
|
||||
"python": {
|
||||
"run": 1628,
|
||||
"skipped": 16,
|
||||
"failures": 0
|
||||
},
|
||||
"site": {
|
||||
"passed": 18
|
||||
},
|
||||
"focusedStore": {
|
||||
"passed": 19
|
||||
},
|
||||
"fakeFrame": {
|
||||
"blocked": 56,
|
||||
"installed-removed": 60
|
||||
},
|
||||
"officialStore": {
|
||||
"plugins": 116,
|
||||
"errors": 0,
|
||||
"identitySetUnchanged": true,
|
||||
"checkedAtUtc": "2026-10-26T07:22:17.371046+00:00"
|
||||
},
|
||||
"staticEntrypointArchives": 28
|
||||
},
|
||||
"unverified": [
|
||||
"Legendary dependency installation",
|
||||
"Epic login",
|
||||
"game listing",
|
||||
"game downloads or launch",
|
||||
"RSS feed",
|
||||
"Steam shortcut mutation",
|
||||
"optional websocket operations",
|
||||
"deployed headset HTTP endpoint",
|
||||
"worn VR"
|
||||
],
|
||||
"status": "partial"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# Executable script entrypoints: Junk-Store
|
||||
|
||||
Junk-Store 1.1.13 (official ID 84) moves **broken → partial**. The exact unmodified
|
||||
BSD-3-Clause archive is pinned in the [redacted results](framey-script-entrypoints-2026-10-26.json).
|
||||
No ARM64 rebuild or upstream plugin source patch was needed.
|
||||
|
||||
Python ZIP extraction drops execute permissions. This archive also publishes its
|
||||
shebang scripts as 0644, relying on upstream Decky's recursive permission setup.
|
||||
Framey's user service disables that setup. Direct script calls therefore fail.
|
||||
The installer now adds **owner execute only** to shebang scripts or ZIP-declared
|
||||
executables. It does not restore archive privilege/write bits, grant group/other
|
||||
execute, or change unmarked data permissions. Archive validation and installation
|
||||
blocks remain in place. Existing installations need update/reinstall for repair.
|
||||
|
||||
The upstream comparison is Decky 3.3.0's
|
||||
[permission setup](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/browser.py)
|
||||
and [Linux implementation](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/localplatform/localplatformlinux.py).
|
||||
Static inspection found 28 accepted official archives with shebang or declared
|
||||
executable members. This is shared installer coverage, not 28 functional claims.
|
||||
|
||||
## Before and after in an isolated real backend
|
||||
|
||||
Both runs used the exact official plugin and published Decky 3.3.0 amd64, with no
|
||||
network/devices/capabilities, a read-only root and private temporary home/tmp.
|
||||
The only difference was the production installer before/after this change.
|
||||
|
||||
| Check | Before | After |
|
||||
| --- | --- | --- |
|
||||
| `scripts/get-json.py` | 0644 | 0744 |
|
||||
| `execute_action(init, init)` | inner `Type:Error`, empty output / JSON parse error | `Type:Success`, `Initialized` |
|
||||
| `execute_action(init, InitActions)` | inner `Type:Error` | `Type:ActionSet`, `MainMenu`, four actions |
|
||||
|
||||
The legacy wrapper reports `success:true` even for the failing inner error.
|
||||
Startup log text alone is also insufficient: the baseline emits initialized/started
|
||||
messages despite those errors. Both private containers were removed.
|
||||
|
||||
## Real ARM64 Frame and local Chromium
|
||||
|
||||
Under the shared headset lock, the patched production installer installed the
|
||||
official archive into a private, bounded, removable Decky 3.3.0 ARM64 user service
|
||||
on Frame OS 0.5.6. The service was not enabled. Legendary was absent before and
|
||||
after; no dependency installer, login, download, launch or websocket command ran.
|
||||
|
||||
The production receipt-bound bridge called the actual backend through live Steam
|
||||
CEF. Static action sets, main-menu content and the Epic action set returned real
|
||||
results. `get_websocket_port` returned 8765. Explicit `init/init` success was tested
|
||||
in the isolated amd64 backend; it was not explicitly invoked in the final Frame run.
|
||||
|
||||
Local Chromium ran the actual Framey host and official frontend in an opaque
|
||||
iframe, relaying only allowed static calls to that ARM64 backend. Main menu,
|
||||
Games → static Epic tab, Back and fresh close/reopen passed. Steam library reads
|
||||
used the production receipt-bound bridge; only counts were retained. Outer
|
||||
status/install responses were fixtures, and the deployed headset HTTP endpoint
|
||||
was not tested.
|
||||
|
||||
The About route still crashes on missing `local_per_client_data.installed` in
|
||||
the detached app overview. Epic game-list and RSS calls were deliberately denied;
|
||||
their loading/error views do not demonstrate backend failure. Dependency
|
||||
installation, login, game operations, RSS, Steam shortcut mutation and worn VR
|
||||
remain unverified. This supports **partial**, not works.
|
||||
|
||||
Three temporary attempts were cleaned, including an initial Steam reinjection
|
||||
readiness failure and a test-relay omission of the library endpoint. A fresh
|
||||
shared-lock audit found all three services inactive/unloaded (PID 0), with their
|
||||
private bases, unit files and archives absent. Normal managed Decky/panel services
|
||||
stayed inactive; final battery was 99 → 100 while charging. No power-setting action
|
||||
ran. The audit lock was released, and local relays/browser tabs were closed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Python suite: 1,628 tests, 16 skipped, no failures; store suite: 19 passed.
|
||||
- Site suite: 18 passed. Two new regression tests failed before the fix.
|
||||
- All 116 official plugins: fake-Frame 60 installed/removed, 56 expected blocks;
|
||||
no actual plugin backends run by this harness. Disposable project cleaned.
|
||||
- Fresh official-store audit: 116 plugins, zero errors; ID/version/digest set
|
||||
matches the catalog. Other plugin statuses are unchanged.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Shotty Python compatibility, 26 October 2026
|
||||
|
||||
The exact official Shotty 0.1.0 archive (store ID 53, SHA-256 `ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe`) imports `click.get_app_dir` at startup but never calls it. The archive includes an empty `py_modules` directory and no `click` package. A prior actual Decky 3.3.0 amd64 backend check stopped at `ModuleNotFoundError: click`; its local frontend separately starts with Framey's read-only Steam library snapshot.
|
||||
|
||||
Frame Control now writes a small Linux `click.get_app_dir` compatibility module into **only this exact digest's** managed `Shotty/py_modules` folder during staging. Decky Loader adds each plugin's `py_modules` folder to that plugin's Python search path. The staging transaction records `shotty-click-app-dir` in the receipt and removes the whole staged folder on failure. A different Shotty digest receives no module. The official ZIP bytes and their verified hash remain unchanged.
|
||||
|
||||
An isolated transaction test installed the pinned archive, imported its unchanged `main.py` using the staged module, and called its `aggregate_all` method with a synthetic Steam screenshot under a temporary `HOME`. It copied one fixture image into that temporary home's `Pictures/Screenshots/Local Test Game` folder; reopening the copied bytes matched the source. Removal deleted the managed folder. A second synthetic Shotty archive with another digest installed without the module. The test neither started the plugin's continuous screenshot rescuer nor touched a person's Steam library or screenshots.
|
||||
|
||||
This supports **partial** compatibility for the local frontend and a narrow backend copy path. Actual Decky 3.3.0 ARM64 plugin startup, gamescope screenshot rescue, real screenshot aggregation, VR panel visibility, and physical controller use remain unverified. No headset lock was acquired or headset state changed for this check.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Tailscale Control read-only backend and Decky PATH, 26 October 2026
|
||||
|
||||
The audited archive was official Tailscale Control 0.1.3, store ID 43,
|
||||
SHA-256 `06411711b27101d438339000da3d91202d682862710b02ef3ddf2462e642448a`.
|
||||
Earlier local Chromium evidence showed its settings panel. This check focused
|
||||
on the Frame's CLI lookup; it did not run a live Framey panel RPC.
|
||||
|
||||
Each real Frame check acquired `/tmp/frame-test.lock` atomically with its own
|
||||
random owner, read SteamVR activity and proximity, and released only that
|
||||
owner's lock. The headset was in standby. The installed Tailscale CLI wrapper
|
||||
resides in `~/.local/bin`, which the systemd user manager did not put in the
|
||||
Decky service's inherited `PATH`.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Exact official backend with the current service PATH | Three read-only status methods returned `None`; `tailscale` was not found. |
|
||||
| Same backend in a disposable process with `~/.local/bin` prepended | The read-only methods returned active state, two exit-node entries and 16 device entries. |
|
||||
| Bounded transient systemd user unit with the proposed service PATH | Found `tailscale` and completed `tailscale status`. The unit ended inactive, PID 0, `LoadState=not-found`. |
|
||||
|
||||
The Frame Control managed Decky unit now sets `PATH` to `~/.local/bin` plus
|
||||
standard system directories when installed or updated. The install transaction
|
||||
rolls its prior unit back on failure. No Tailscale `up`, `down`, routing or
|
||||
exit-node change was run. No persistent test service, headset wake, controller
|
||||
input or settings write was used.
|
||||
|
||||
This supports CLI discovery and read-only backend data on the real Frame. It
|
||||
does not prove the production Framey-to-Decky RPC, connection controls, QR
|
||||
asset, worn panel visibility or physical controller usability. Tailscale
|
||||
Control remains **partial** in the digest-scoped catalog.
|
||||
|
||||
A later independent hourly read-only check found SteamVR activity 3
|
||||
(standby), proximity raw 3.0625 and battery 93%. It released its owned lock;
|
||||
no worn test was possible and the hourly schedule remains enabled.
|
||||
@@ -0,0 +1,405 @@
|
||||
{
|
||||
"baseCommit": "daee952873255b86018cce1d2a4d3bd070ec4341",
|
||||
"plugin": {
|
||||
"id": 58,
|
||||
"name": "Volume Boost",
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc"
|
||||
},
|
||||
"runtime": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6",
|
||||
"os": "Frame OS 0.5.6",
|
||||
"arch": "aarch64"
|
||||
},
|
||||
"verificationLimits": {
|
||||
"fakeFrame": "Prior exact archive HTTP/SSH install/receipt/removal; plugin execution stubbed, not repeated",
|
||||
"localBrowser": "Actual headset.html, React and Framey host, exact unmodified official frontend in opaque iframe; direct RPC relay to actual ARM64 backend",
|
||||
"realFrameSSH": "Production receipt-bound framey_panel.call_method over SSH and live Steam CEF DeckyBackend.call into actual Decky 3.3.0 ARM64 plugin; default speaker sink remained muted throughout",
|
||||
"wornVR": "Not tested",
|
||||
"audibleOutput": "Not tested; gain writes and readbacks only",
|
||||
"deployedHTTP": "Not tested; outer status/install/check responses were fixtures, methods used production call_method with exact current receipt"
|
||||
},
|
||||
"catalogRefresh": {
|
||||
"checkedAtUtc": "2026-10-26T06:10:58.537029+00:00",
|
||||
"plugins": 116,
|
||||
"changedIdsVersionsDigests": 0
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"timeUtc": "2026-10-26T06:07:19.370988+00:00",
|
||||
"locale": "LC_ALL=C for the private Decky service",
|
||||
"originalSink": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 0,
|
||||
"front-right": 0
|
||||
}
|
||||
},
|
||||
"writes": [
|
||||
{
|
||||
"request": {
|
||||
"value": 125,
|
||||
"channel": "both"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 81920,
|
||||
"front-right": 81920
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 125,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 125,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 75,
|
||||
"channel": "both"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 49152,
|
||||
"front-right": 49152
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 75,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 75,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 74,
|
||||
"channel": "both"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 48496,
|
||||
"front-right": 48496
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 74,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 74,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 33,
|
||||
"channel": "left"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 21626,
|
||||
"front-right": 0
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 33,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 67,
|
||||
"channel": "right"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 0,
|
||||
"front-right": 43909
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 67,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 33,
|
||||
"channel": "right"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 0,
|
||||
"front-right": 21626
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 33,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"reopen": "Fresh query/session, ready; UI returned to actual restored zero; no write on mount",
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"exactSinkRestored": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
{
|
||||
"timeUtc": "2026-10-26T06:12:04.889613+00:00",
|
||||
"locale": "Inherited user-service environment, matching production unit; no LANG or LC_ALL override",
|
||||
"originalSink": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 0,
|
||||
"front-right": 0
|
||||
}
|
||||
},
|
||||
"writes": [
|
||||
{
|
||||
"request": {
|
||||
"value": 150,
|
||||
"channel": "both"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 98304,
|
||||
"front-right": 98304
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 150,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 150,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"reopen": "Fresh query/session, ready; UI returned to actual restored zero; no write on mount",
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"exactSinkRestored": true,
|
||||
"lockReleased": true
|
||||
}
|
||||
],
|
||||
"guard": {
|
||||
"scope": "Private test helper only; not a shipped feature or plugin patch",
|
||||
"timeoutSeconds": 60,
|
||||
"runtimeMaxSec": 120,
|
||||
"sequence": [
|
||||
"Capture exact default sink identity, native per-channel volume integers and original mute",
|
||||
"Persist recovery state; start bounded device-side systemd watchdog before mute/write",
|
||||
"Require stereo front-left/front-right and mute readback before plugin call",
|
||||
"Only official get_volume_state/set_volume_state; value 0..150, known channels",
|
||||
"Read native Pulse volume and mute independently; read both channels through actual plugin",
|
||||
"Restore exact native volume while muted, verify, then restore original mute and verify exact snapshot",
|
||||
"Remove recovery state, stop only own watchdog; cleanup own service/base and release matching lock"
|
||||
],
|
||||
"automaticTimeoutExercised": false,
|
||||
"explicitRestoreExercised": true
|
||||
},
|
||||
"freshCleanupAudit": {
|
||||
"exactSinkRestored": true,
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"services": {
|
||||
"framey-boost-kp0z857q58-b4126054.service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
|
||||
"framey-boost-kp0z857q58-b4126054-guard.service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n"
|
||||
},
|
||||
"battery": [
|
||||
100
|
||||
]
|
||||
},
|
||||
"remaining": [
|
||||
"Audible sound/quality, perceived loudness and physical speaker gain are unverified",
|
||||
"Worn VR, headset controller input and Bluetooth sinks are unverified",
|
||||
"No default route change, playback or power-setting action was invoked",
|
||||
"Decky startup reinjects its frontend and can restart Steam CEF; normal managed services stayed inactive"
|
||||
],
|
||||
"repositoryChecks": {
|
||||
"python": "1,626 tests, 16 skips, no failures",
|
||||
"site": "18 passed",
|
||||
"catalogDocCheck": "passed",
|
||||
"diffCheck": "passed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Volume Boost sink controls on the ARM64 Frame
|
||||
|
||||
Volume Boost 0.0.7 (official store ID 58) now qualifies as **works for sink
|
||||
controls**. The exact official, unmodified Python backend ran in Decky 3.3.0
|
||||
ARM64 on Frame OS 0.5.6. Local Chromium rendered its official frontend through
|
||||
Framey's existing legacy bridge. Actual control changes reached the Frame's
|
||||
default speaker sink, including above-100% gain, with independent Pulse
|
||||
readbacks. The sink stayed muted; no audible-output or worn-VR claim is made.
|
||||
|
||||
[Digest-pinned observations and cleanup](framey-volume-boost-arm64-2026-10-26.json)
|
||||
record both runs. This supersedes the Volume Boost read-only limitation in the
|
||||
[previous ARM64 audio check](framey-audio-arm64-2026-10-26.md). No plugin patch,
|
||||
native rebuild or new compatibility shim was necessary. Volume Boost's backend
|
||||
uses Python and the Frame's existing ARM64 `pactl`; its hard-coded UID 1000
|
||||
matches the actual Frame user.
|
||||
|
||||
| Local Chromium action | Actual ARM64 result, independently read back |
|
||||
| --- | --- |
|
||||
| Linked numeric input 125% | Native Pulse channel values 81920 / 81920; plugin reads 125 / 125 |
|
||||
| Linked slider click, then ArrowDown | 75% then 74%; native values 49152 / 49152 then 48496 / 48496 |
|
||||
| Unlink, set left to 33% | Native values 21626 / 0; plugin reads 33 / 0 |
|
||||
| Set right to 67% | Native values 0 / 43909; plugin reads 0 / 67 |
|
||||
| Re-link | Official frontend issues a right-only 33% write; native values 0 / 21626 |
|
||||
| Close and reopen | New document/session, ready; backend reads and displayed linked value return to the restored 0%; no mount write |
|
||||
| Repeat linked numeric 150% with inherited service locale | Native values 98304 / 98304; plugin reads 150 / 150; reopen again reads restored zero |
|
||||
|
||||
The test restores the original sink state after **each** write. Therefore the
|
||||
separate-channel checks intentionally begin from 0% / 0%, rather than from the
|
||||
previous UI value. A right-only re-link request is the published plugin's
|
||||
behavior. These checks verify channel selection and preservation of the other
|
||||
channel; they do not claim a continuous unmuted listening session. Pulse's
|
||||
integer conversion truncates fractional native values; the evidence retains
|
||||
those integers rather than replacing them with rounded percentages.
|
||||
|
||||
## Runtime and transport
|
||||
|
||||
Each run separately acquired the atomic `/tmp/frame-test.lock`; neither stole
|
||||
an existing lock. A unique private Decky user service used its own plugin,
|
||||
settings and runtime directories, was never enabled, and had an eight-minute
|
||||
runtime limit. The upstream ARM64 loader matched GitHub's published SHA-256
|
||||
and ELF machine 183. The plugin ZIP matched the official digest.
|
||||
|
||||
The local relay served actual `ui/headset.html`, vendored React and
|
||||
`framey-host.js`, with exact official frontend bytes in an opaque
|
||||
`allow-scripts` iframe. Outer status/install/check responses were fixtures.
|
||||
Plugin calls used production `framey_panel.call_method`, including the current
|
||||
store receipt, SSH through lxso2, live Steam CEF and `DeckyBackend.call` into the
|
||||
actual plugin subprocess. Only the temporary install paths were redirected.
|
||||
This does not verify the deployed headset HTTP endpoint or a VR compositor.
|
||||
|
||||
The first private service explicitly used `LC_ALL=C`. The second inherited the
|
||||
user-service locale without a `LANG` or `LC_ALL` override, matching the
|
||||
production service configuration, and repeated actual reads, a 150% write and
|
||||
reopen. Zero alone was not accepted as success: upstream returns zero on
|
||||
command failure. Every write returned true, both backend channel reads agreed,
|
||||
and independent native Pulse readbacks confirmed nonzero changes.
|
||||
|
||||
## Bounded muted writes and restoration
|
||||
|
||||
The default sink was already muted at native channel values 0 / 0. Before each
|
||||
plugin write, a private device-side helper captured the exact sink identity,
|
||||
channel values and mute state; persisted recovery state; and started a bounded
|
||||
60-second systemd watchdog. It then required a successful mute readback before
|
||||
allowing the plugin call. The relay allowed only `get_volume_state` and
|
||||
`set_volume_state`, known channels and values from 0 through 150.
|
||||
|
||||
After the call, independent `pactl --format=json list sinks` and both plugin
|
||||
reads checked the result. In `finally`, the helper restored exact native
|
||||
channel values while muted, checked them, restored the original mute state,
|
||||
and verified the entire snapshot before removing recovery state and stopping
|
||||
its watchdog. No default route, playback, existing stream or power setting was
|
||||
changed. Automatic watchdog expiry was armed but not deliberately exercised;
|
||||
explicit restoration passed after all seven writes.
|
||||
|
||||
Both private Decky services/bases were removed and their locks released.
|
||||
Fresh-lock cleanup audits found their Decky and guard units unloaded/inactive
|
||||
with PID 0, paths absent, and the exact original sink/mute restored. Battery
|
||||
stayed 100 → 100; normal managed Decky and headset-panel services stayed
|
||||
inactive. Decky startup reinjects its frontend and can restart Steam CEF.
|
||||
|
||||
## Catalog and verification limits
|
||||
|
||||
The canonical store catalog and generated table report **4 works / 48 partial /
|
||||
53 broken / 11 N/A**. A fresh full official-store fetch at 06:10:58 UTC found
|
||||
all 116 IDs, versions and digests unchanged. Historical fake-Frame checks cover
|
||||
this exact archive's install/receipt/removal with backend execution stubbed;
|
||||
that test was not repeated or relabelled as real-backend evidence.
|
||||
|
||||
Audible gain, sound quality, physical speaker output, Bluetooth sinks,
|
||||
headset-controller input and worn VR remain unverified. The store row includes
|
||||
these limits. This change records new real-backend evidence and updates its
|
||||
compatibility classification; it changes no shipped audio implementation.
|
||||
|
||||
The full server suite completed 1,626 tests with no failures (16 skips); all
|
||||
18 site tests passed. Generated-document consistency and `git diff --check`
|
||||
also passed. No implementation change followed those tests.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Read-only Framey activity gate, 26 October 2026
|
||||
|
||||
At 11:12 UTC, a read-only probe acquired `/tmp/frame-test.lock` by atomic
|
||||
`mkdir` with a random owner token before reading the Frame. SteamVR
|
||||
returned two `vrcmd --stats` rows: performance metrics first and the status
|
||||
row second. The status row reported activity level **1**. The original
|
||||
`framey-worn-check.py` looked only at row zero and reported unknown activity;
|
||||
`framey-vr-check.py` used the same row-zero assumption for its wake gate. The
|
||||
fixed gates select the unique status row and fail closed if it is missing,
|
||||
duplicated or malformed.
|
||||
|
||||
The first locked read found 90% battery, proximity raw 222.25, no active
|
||||
managed headset panel service and no managed Decky plugin. A subsequent locked
|
||||
read found activity 1 and proximity raw 223.625. No reliable wearer
|
||||
confirmation was available. No wake, service install, settings write or input
|
||||
was attempted.
|
||||
|
||||
While activity was 1, a separate owned-lock read captured four seconds from
|
||||
`/dev/video99` into a private 640×360 H.264 MP4. It decoded successfully and
|
||||
contained active video imagery. The inspected sample did **not** establish
|
||||
room passthrough, Frame Control panel visibility, ProtonDB Badges UI or
|
||||
controller interaction. The private recording and decoded frame were removed
|
||||
after inspection; no room or media images are committed. Each read released
|
||||
only its owner-token lock. The hourly worn-check schedule remains enabled.
|
||||
|
||||
The local focused safety suite covers a metrics row before the status row,
|
||||
awake activity, ambiguous/missing status and foreign-lock refusal. A genuine
|
||||
worn visual and physical-controller check remains outstanding.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Official Web Browser 1.4.3 frontend follow-up
|
||||
|
||||
The exact official Decky store archive for ID 59 is 91,859 bytes and matched
|
||||
SHA-256 `1192864fddc5d72550346b8dff02c21d71f0c40a06ac41c4e67bb6220d267f9d`.
|
||||
Its legacy bundle calls `deckyFrontendLib.getReactRoot` when attempting to patch
|
||||
Steam's main menu and search bar. The prior full-catalog audit recorded
|
||||
`getReactRoot is not a function` and initializer failure.
|
||||
|
||||
Framey's isolated iframe has no Steam React root. The host now exposes
|
||||
`getReactRoot()` with a **null** result, consistent with its existing inert
|
||||
`findInReactTree()` lookup. It never returns a fabricated Steam tree or permits
|
||||
patching the user's Steam UI. The focused Node host test checks this contract.
|
||||
|
||||
A focused local Chromium run used the verified archive through
|
||||
`scripts/framey-audit-browser.py`, with every backend method, fetch and
|
||||
WebSocket denied. The exact frontend reported `ready=true` with no initializer
|
||||
error after 2.5 seconds, and again during a longer ten-second observation.
|
||||
It attempted `set_main_tab` and `load`; the audit denied both. Console output
|
||||
reported that no Steam window or main-menu root was available. The panel did
|
||||
not show a usable browser view in the audited state.
|
||||
|
||||
Static inspection of this archive's published Python backend shows
|
||||
`set_main_tab()` requesting a Steam GamepadUI CDP tab, then returning a key and
|
||||
port for its own WebSocket server. That Steam tab and browser injection path
|
||||
are outside Framey's isolated panel. No real plugin backend was executed, no
|
||||
browser tab or navigation was verified, and no real Frame or worn-VR check was
|
||||
performed for this change. The catalog retains **broken** for this digest; its
|
||||
reason and focused observation now distinguish successful initialization from
|
||||
the unresolved core browser dependency. The full 116-plugin browser baseline
|
||||
still refers to its earlier host commit; this is a one-plugin follow-up.
|
||||
|
||||
## Unworn Framey regression gate
|
||||
|
||||
The exact code commit `d5e62f643d462b7070f27c2c47b11b4aa9982094` ran
|
||||
`scripts/framey-vr-check.py --exercise` under its own shared-lock owner at
|
||||
07:26 UTC. Preflight found SteamVR activity 3 (standby), proximity 6.25 and
|
||||
battery 100%. The real Decky sample setting saved `enabled=true`, survived a
|
||||
fresh iframe, and the quick action read the saved value. A trusted synthetic
|
||||
OpenVR pointer, slider value 50, CDP text entry after focus and changed stereo
|
||||
compositor hashes passed. The mirror capture SHA-256 was
|
||||
`4f0d977fab7c144a3490b12ebefddacf3126db585057eb022006ffdf844a7498`.
|
||||
The command exited 0 with `passed=true`; original power settings (`false` and
|
||||
`5.000000`) were read back, battery ended at 99%, the recovery timer was
|
||||
removed, cleanup errors were empty and only its own lock was released. The
|
||||
private full report is `/tmp/framey-web-browser-vr-d5e62f6/result.json` on the
|
||||
test host. This verifies the sample-panel regression path, not the official
|
||||
Web Browser plugin or a worn headset. Synthetic native scroll still did not
|
||||
move.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Interactive Framey worn-check proximity gate, 26 October 2026
|
||||
|
||||
The interactive `scripts/framey-worn-check.py` already required an explicit
|
||||
operator wear confirmation and a unique `/tmp/frame-test.lock` owner before
|
||||
reading SteamVR activity. It did not read proximity. This change makes its
|
||||
locked preflight read the raw proximity sensor and stop before its interaction
|
||||
window when the reading is missing or invalid. It checks activity, proximity
|
||||
and battery again before input. The same owned-lock cleanup applies on failure.
|
||||
|
||||
The focused local gate suite passes 9/9 tests, including missing and nonfinite
|
||||
proximity, proximity disappearing between reads, metrics preceding the SteamVR status row,
|
||||
standby, low battery, no operator confirmation and a foreign lock. No headset
|
||||
input was sent for this change. A separate owner-locked read-only Frame check
|
||||
found SteamVR activity 3 (standby), proximity raw 3.0625 and battery 93%; it
|
||||
released its lock without a worn test.
|
||||
|
||||
The raw proximity value is not calibrated as proof of wear. The operator's
|
||||
in-person confirmation remains required; awake activity, proximity and trusted
|
||||
synthetic input alone do not prove wearer-visible pixels or physical controller
|
||||
usability. The hourly worn-check schedule remains enabled.
|
||||
@@ -0,0 +1,288 @@
|
||||
{
|
||||
"release": "v0.4.39",
|
||||
"tagCommit": "fd0b0a946f11d92fd1cc480cd9d1a83be795bf71",
|
||||
"job": "1vbt29ptkw",
|
||||
"date": "2026-10-26",
|
||||
"host": {
|
||||
"model": "MacBookAir9,1",
|
||||
"cpu": "Intel Core i3-1000NG4",
|
||||
"arch": "x86_64",
|
||||
"macOS": "15.7.7",
|
||||
"build": "24G720"
|
||||
},
|
||||
"artifacts": {
|
||||
"Frame-Control-mac-x64.dmg": {
|
||||
"bytes": 169230398,
|
||||
"sha256": "f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8",
|
||||
"matchesSHA256SUMS": true
|
||||
},
|
||||
"Frame-Control-mac-x64.zip": {
|
||||
"bytes": 169141014,
|
||||
"sha256": "bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7",
|
||||
"matchesSHA256SUMS": true
|
||||
}
|
||||
},
|
||||
"native": {
|
||||
"title": "Apps \u00b7 Frame Control",
|
||||
"bridge": true,
|
||||
"preferencesBefore": {},
|
||||
"views": [
|
||||
{
|
||||
"view": "apps",
|
||||
"title": "Apps \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Apps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "games",
|
||||
"title": "Games \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Games"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "tools",
|
||||
"title": "Tools \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "settings",
|
||||
"title": "Settings \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "privacy",
|
||||
"title": "Privacy & updates \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Privacy & updates"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "capture",
|
||||
"title": "Capture \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Capture & Live"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "decky",
|
||||
"title": "Decky Loader \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Decky Loader"
|
||||
]
|
||||
}
|
||||
],
|
||||
"decky": {
|
||||
"state": "116 plugins in the official store. Select a plugin to install it.",
|
||||
"rows": 40,
|
||||
"search": "ProtonDB Badges \u00b7 1.3.4 Display tappable ProtonDB badges on your game pages\n\npartial: 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. Verified: static archive, local browser, fake-Frame, real Frame SSH.\n\nInstall",
|
||||
"installDisabled": [
|
||||
{
|
||||
"text": "Install",
|
||||
"disabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"preferenceWritten": "0",
|
||||
"preferencesAfter": {
|
||||
"mvSeparate": "0"
|
||||
},
|
||||
"update": {
|
||||
"status": "available",
|
||||
"current": "0.4.39",
|
||||
"error": null,
|
||||
"canInstall": true,
|
||||
"latest": "0.4.40"
|
||||
},
|
||||
"obs": {
|
||||
"before": {
|
||||
"state": "idle",
|
||||
"alias": "",
|
||||
"port": 15099,
|
||||
"error": "",
|
||||
"supported": true
|
||||
},
|
||||
"start": {
|
||||
"state": "starting",
|
||||
"alias": "frame",
|
||||
"port": 15139,
|
||||
"error": "",
|
||||
"supported": true
|
||||
},
|
||||
"stop": {
|
||||
"state": "stopping",
|
||||
"alias": "frame",
|
||||
"port": 15139,
|
||||
"error": "",
|
||||
"supported": true
|
||||
},
|
||||
"after": {
|
||||
"state": "idle",
|
||||
"alias": "frame",
|
||||
"port": 15139,
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
"preferencesAfterRestart": {
|
||||
"mvSeparate": "0"
|
||||
}
|
||||
},
|
||||
"loginItem": {
|
||||
"enabledBeforeQuit": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"nativeQuit": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\nPRESS 0\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"restartPid": 50393,
|
||||
"enabledAfterRestart": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"beforeRestore": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"restoreOff": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\nPRESS 0\n",
|
||||
"finalMenu": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"restoredOriginalOff": true
|
||||
},
|
||||
"macHelper": {
|
||||
"permissions": {
|
||||
"accessibility": false,
|
||||
"screen": false
|
||||
},
|
||||
"displays": {
|
||||
"displays": [
|
||||
{
|
||||
"h": 900,
|
||||
"id": 69734464,
|
||||
"main": true,
|
||||
"name": "Built-in Retina Display",
|
||||
"src": "display:69734464",
|
||||
"w": 1440
|
||||
}
|
||||
]
|
||||
},
|
||||
"windows": {
|
||||
"screen": false,
|
||||
"count": 3,
|
||||
"frameControlCount": 2
|
||||
}
|
||||
},
|
||||
"headset": {
|
||||
"timestamp": "2026-10-26T04:56:52.808203+00:00",
|
||||
"route": "Mac SSH -> pinned lxso2 -> trusted frame SSH",
|
||||
"owner": "intel-v0439-1vbt29ptkw-20261026",
|
||||
"payloadBytes": 54,
|
||||
"coordinatorLock": "locked\n",
|
||||
"headset": {
|
||||
"os": {
|
||||
"VERSION_ID": "0.5.6",
|
||||
"BUILD_ID": "20261022.6153909",
|
||||
"VARIANT_ID": "vr"
|
||||
},
|
||||
"battery": [
|
||||
{
|
||||
"percent": 99,
|
||||
"status": "Not charging"
|
||||
}
|
||||
],
|
||||
"homeStorage": {
|
||||
"total": 996984774656,
|
||||
"free": 769012412416
|
||||
},
|
||||
"screenshotJpegs": 7,
|
||||
"transfer": {
|
||||
"path": "/tmp/intel-v0439-1vbt29ptkw-20261026-vtynzyky/intel-mac-check.txt",
|
||||
"bytes": 54,
|
||||
"sha256": "82029b7e8b519bac65e43a6e90386a06da9988b23330aa7c48fea0abdfac0a8b",
|
||||
"deleted": true
|
||||
}
|
||||
},
|
||||
"headsetLockReleased": true,
|
||||
"coordinatorLockRelease": "released\n"
|
||||
},
|
||||
"gatekeeper": {
|
||||
"downloadMethod": "gh release download",
|
||||
"downloadQuarantinePresent": false,
|
||||
"controlledSyntheticQuarantine": "inconclusive",
|
||||
"cleanQuarantinedOpen": "NSPOSIXErrorDomain Code=16 Resource busy",
|
||||
"cleanSpctl": "timeout after 15 seconds",
|
||||
"rightClickOpenVerified": false,
|
||||
"openAnywayVerified": false,
|
||||
"appSpecificXattrFallbackUsed": true,
|
||||
"globalGatekeeperChanged": false
|
||||
},
|
||||
"headsetCleanupReadback": {
|
||||
"headset": {
|
||||
"testFoldersAbsent": true,
|
||||
"lockAbsent": false,
|
||||
"subsequentLockOwner": "frame-smoke-92bb2294f1d9cd4b7084",
|
||||
"subsequentLockLeftUntouched": true
|
||||
},
|
||||
"coordinatorLockAbsent": true
|
||||
},
|
||||
"cleanup": {
|
||||
"debugPortClosed": true,
|
||||
"originalPreferencesRestored": true,
|
||||
"ownedAppRemaining": [],
|
||||
"desk": {
|
||||
"foreground_apps": [
|
||||
"Finder",
|
||||
"Messages",
|
||||
"cmux"
|
||||
],
|
||||
"display_on": false,
|
||||
"notifications_found": 0,
|
||||
"attention": []
|
||||
},
|
||||
"dmgejected": true,
|
||||
"installedSignatureValidAfterRuntime": true,
|
||||
"finalInstalledSignature": {
|
||||
"removedOnlyGeneratedBytecodeFiles": 36,
|
||||
"finalSignatureExit": 0,
|
||||
"finalSignatureStderr": ""
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"sourceBase": "741735aa98e615fb51bb1a5adb665c9d12e3fd2a",
|
||||
"initialMacFullSuite": {
|
||||
"python": "system 3.9",
|
||||
"tests": 1621,
|
||||
"failures": 6,
|
||||
"skipped": 20,
|
||||
"seconds": 818.353
|
||||
},
|
||||
"affectedMacModulesAfterFix": {
|
||||
"python": "official bundled 3.12",
|
||||
"tests": 87,
|
||||
"skipped": 2,
|
||||
"seconds": 37.605,
|
||||
"result": "OK"
|
||||
},
|
||||
"focusedAppTests": {
|
||||
"tests": 14,
|
||||
"passed": 14
|
||||
},
|
||||
"websiteTests": {
|
||||
"tests": 18,
|
||||
"passed": 18
|
||||
},
|
||||
"testFixes": [
|
||||
"Skip local Frame recording shell fixtures on Bash 3.2, whose read timeout status differs from Frame Bash 5",
|
||||
"Require actual XCTest.framework for standalone XCTest contracts",
|
||||
"Separate OBS fixture interpreter startup from stream-stall deadlines and synchronize signal-handler readiness",
|
||||
"Leave margin between panel fixture delays and individual X tool timeouts"
|
||||
],
|
||||
"linuxFullSuiteAfterFix": {
|
||||
"tests": 1621,
|
||||
"skipped": 16,
|
||||
"seconds": 170.313,
|
||||
"result": "OK",
|
||||
"isolation": "temporary archived checkout on lxso2"
|
||||
},
|
||||
"testedFileSHA256": {
|
||||
"tests/test_clips.py": "00ba70866cda2b6cacaa0dd9bd19e9f2a9532a035cef66c9d8e2bd0021b1f489",
|
||||
"tests/test_ios_contracts.py": "f1cc22e54873617e381025233c6b422245e6943ba7432331fc2d01e7a9ae4fc1",
|
||||
"tests/test_obs_headset_source.py": "62462cbadbfcf67d4c200edf54ee45697b6f8d6547cd4c538f0344d71cf70241",
|
||||
"tests/test_touch.py": "c34c646d134e57130c4fb43cd3d53e6c859369f774f9b7fd1dd8c3e718b72b6f"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# Intel Mac acceptance test: official v0.4.39, 26 October 2026
|
||||
|
||||
Tested on the physical **MacBookAir9,1, Intel Core i3-1000NG4 1.10 GHz,
|
||||
8 GB RAM, macOS 15.7.7 (24G720), x86_64**. Job `1vbt29ptkw` repeated the
|
||||
[earlier release test](mac-v0439-2026-10-26.md) and added ZIP verification,
|
||||
native menu actions and Open at Login persistence. The app under test was
|
||||
the official [v0.4.39 release](https://github.com/saphid/frame-control/releases/tag/v0.4.39),
|
||||
tag commit `fd0b0a946f11d92fd1cc480cd9d1a83be795bf71`.
|
||||
It was not rebuilt or patched. Current source validation is reported separately.
|
||||
|
||||
## Download and clean DMG installation
|
||||
|
||||
Downloaded both Intel assets and `SHA256SUMS` from that exact tag with
|
||||
`gh release download`. Independently calculated SHA-256 values matched the
|
||||
same-release manifest:
|
||||
|
||||
| Asset | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `Frame-Control-mac-x64.dmg` | 169,230,398 | `f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8` |
|
||||
| `Frame-Control-mac-x64.zip` | 169,141,014 | `bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7` |
|
||||
|
||||
Mounted the DMG read-only with `hdiutil attach -readonly -nobrowse` and
|
||||
unpacked the ZIP separately. For each app, `file` reported
|
||||
`Mach-O 64-bit executable x86_64` and `lipo -info` reported:
|
||||
|
||||
```text
|
||||
Non-fat file: …/Contents/MacOS/Frame Control is architecture: x86_64
|
||||
```
|
||||
|
||||
The DMG's bundled Python and `frame-mac-view` are also x86_64; `adb` is
|
||||
universal with x86_64 and arm64 slices. Both fresh bundles passed
|
||||
`codesign --verify --deep --strict --verbose=2`. Signing is ad hoc, with no
|
||||
TeamIdentifier. This verifies the resource seal, not Apple notarization.
|
||||
|
||||
Preserved the existing `/Applications/Frame Control.app` in the task's
|
||||
temporary directory. An initial `ditto` overlay retained that installation's
|
||||
extra Python bytecode caches and failed signature verification. Moved that
|
||||
copy aside and copied the DMG app into an empty destination. The clean copy
|
||||
passed `codesign --verify --strict`; the release bundles themselves passed.
|
||||
The final installed app is the clean DMG copy.
|
||||
|
||||
## Gatekeeper: tested fallback, incomplete first-open coverage
|
||||
|
||||
The CLI downloads had **no `com.apple.quarantine` attribute**. This Air had
|
||||
already launched the same app in the earlier job, so it is not a pristine
|
||||
first-install trust environment. A controlled probe added only this app's
|
||||
quarantine attribute (`0083;<timestamp>;FrameControlIntelReleaseTest;`).
|
||||
That is synthetic metadata, not a Safari or Chrome download.
|
||||
|
||||
The overlaid copy's first `open` returned 0 and later produced a translocated
|
||||
app process. After making the clean copy, a quarantined `open` returned:
|
||||
|
||||
```text
|
||||
The application cannot be opened, error=Error Domain=NSPOSIXErrorDomain Code=16 "Resource busy"
|
||||
```
|
||||
|
||||
Assessment of the clean copy with `spctl --assess --type execute -vv` did
|
||||
not finish within 15 seconds. No readable Gatekeeper warning was captured;
|
||||
the controlled probe is **inconclusive**. Do not interpret `open` returning
|
||||
0 as proof of passing Gatekeeper. Native screen capture was unavailable.
|
||||
|
||||
The exact app-specific fallback used for the verified clean copy was:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
|
||||
The installed app then launched successfully. No global Gatekeeper setting
|
||||
was changed. For a normal user, verify the Intel download, mount the DMG,
|
||||
drag the app to Applications and open it. If macOS blocks it, Apple's
|
||||
documented route is **System Settings → Privacy & Security → Open Anyway →
|
||||
Open**, after the initial attempt. **Right-click Open and Open Anyway were
|
||||
not exercised in this run**; neither is certified here. The Terminal command
|
||||
above is the fallback actually exercised, limited to this trusted app.
|
||||
|
||||
## Official native app checks
|
||||
|
||||
Controlled runs disabled telemetry and used a task-local server data
|
||||
directory. A temporary loopback CDP port inspected the actual Electron
|
||||
renderer and its real `window.frameApp` bridge. This was not a web-only
|
||||
substitute. Native menu actions used Apple's Accessibility API through an
|
||||
existing authorized SSH session; no Accessibility grant was added. Direct
|
||||
System Events/AppleScript calls timed out. The API route is why this run can
|
||||
verify native menu actions that the earlier evidence left untested.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Launch | **Pass.** Native window, real IPC bridge and bundled Python server loaded. Version remained 0.4.39 |
|
||||
| Offline UI | **Pass.** Apps, Games, Tools, Settings, Privacy & updates, Capture & Live and Decky rendered their expected headings |
|
||||
| Decky store | **Pass.** Official store returned 116 plugins, with 40 initial rows. Search displayed ProtonDB Badges 1.3.4 and its compatibility text. Install stayed disabled; no loader or plugin was installed |
|
||||
| Settings save | **Pass.** Native preferences initially `{}`; `mvSeparate="0"` saved, read back and survived a real app restart. Restored the original absence of the preferences file afterward |
|
||||
| Update check | **Pass.** Native IPC returned `available`, current 0.4.39, latest 0.4.40, `error=null`, `canInstall=true`. The actual Check for Updates menu action also succeeded. No update installed |
|
||||
| OBS Start/Stop | **Partial.** Native status supported/idle → Start on port 15139 returned starting → Stop returned stopping → idle. This checks local helper lifecycle; no OBS receiver or headset stream was exercised |
|
||||
| Open at Login | **Pass for toggle and persistence.** Native menu began unchecked. AXPress returned success, then the checkmark appeared. Native Quit/relaunch preserved it. Toggled off and independently read the unchecked menu back. Actual logout/login execution remains untested |
|
||||
| Show Server Log | **Partial.** Native menu AXPress succeeded and launched Console. The log contains startup with the installed bundled Python/server paths. The viewer's displayed document was not independently read back |
|
||||
| Intel Mac helper | **Pass for read-only commands.** `permissions`, `displays` and `windows` returned JSON. One 1440×900 built-in display was listed; screen/accessibility permissions were false. No capture or permission request was initiated by these commands |
|
||||
|
||||
[Native searched Decky store screenshot](intel-mac-v0.4.39-2026-10-26.png).
|
||||
[Structured observations](intel-mac-v0.4.39-2026-10-26.json).
|
||||
|
||||
## Headset: read-only checks and one temporary transfer
|
||||
|
||||
`frame.local` resolved, but its LAN port 22 returned **No route to host**.
|
||||
The Tailscale endpoint answered TCP 22, but direct SSH and the app refused
|
||||
the Air's stale/missing host trust. No pin was replaced to bypass that
|
||||
failure. Used the already-pinned `saphid@lxso2` coordinator relay and its
|
||||
trusted `frame` SSH connection instead.
|
||||
|
||||
Atomically acquired `/tmp/frame-test.lock` on the coordinator and headset
|
||||
with owner `intel-v0439-1vbt29ptkw-20261026`. Under those locks:
|
||||
|
||||
- Read SteamOS **0.5.6**, variant `vr`, build **20261022.6153909**.
|
||||
- Read battery **99%, Not charging**.
|
||||
- Read home storage: **996,984,774,656 bytes total**, **769,012,412,416 free**.
|
||||
- Listed **7** JPEGs matching Steam's
|
||||
`userdata/*/760/remote/*/screenshots/*.jpg`; no screenshots were copied.
|
||||
- Sent a **54-byte** payload originating on this Mac through the relay into
|
||||
an exclusively created `/tmp/intel-v0439-1vbt29ptkw-20261026-…/` folder.
|
||||
Its readback SHA-256 matched
|
||||
`82029b7e8b519bac65e43a6e90386a06da9988b23330aa7c48fea0abdfac0a8b`.
|
||||
Deleted the exact file and its temporary folder.
|
||||
- Released only matching owned locks. Separate readback confirmed the
|
||||
headset lock was absent immediately after release. A later independent check
|
||||
found the transfer folder absent and coordinator lock absent, while the
|
||||
headset lock had been acquired by another job (`frame-smoke-92bb2294f1d9cd4b7084`);
|
||||
that different owner was left untouched.
|
||||
|
||||
This verifies a relay transfer, **not the native app's direct file-transfer
|
||||
path**. No existing headset content, installs, services, settings, power
|
||||
state or SteamVR state was changed. No wipe or reboot was attempted.
|
||||
|
||||
## Bugs, validation and remaining limits
|
||||
|
||||
No new Intel-specific product bug was isolated in these runtime checks.
|
||||
The earlier v0.4.39 shutdown race already has a merged fix in
|
||||
[PR #284](https://github.com/saphid/frame-control/pull/284), shipped in
|
||||
v0.4.40. This run did not install or validate the v0.4.40 Intel app.
|
||||
|
||||
The initial full source suite on system Python 3.9 ran **1,621 tests in
|
||||
818.353 seconds**, with **six failed assertions and 20 skips**. These were
|
||||
source-test issues, separate from the released native checks:
|
||||
|
||||
- The recording fixture's time limit ended with `q`, because macOS Bash 3.2
|
||||
reports read timeout as status 1, the same as EOF. Frame Bash 5 reports a
|
||||
value above 128. Local recording-shell tests now explicitly require Bash 4+.
|
||||
- The Foundation XCTest test found `swiftc` but failed `import XCTest`:
|
||||
the active Command Line Tools installation has no XCTest framework.
|
||||
It now checks for the actual framework and skips when unavailable.
|
||||
- Two OBS tests (three assertions, including subtests) started 0.15/0.3-second
|
||||
watchdog clocks before the fixture's Python interpreter and signal handler
|
||||
were ready. Fixture startup is now synchronized or separately budgeted;
|
||||
the short stream-stall checks remain. Production watchdog limits did not change.
|
||||
- The panel timeout fixture's 4.6-second sleeps were too close to each tool's
|
||||
five-second timeout once process startup was included, causing the wrong
|
||||
failure path. Three-second sleeps still exhaust the 12-second overall
|
||||
deadline before a panel is complete, with margin for process startup.
|
||||
|
||||
All four affected modules then passed on this physical Air with the official
|
||||
app's bundled Python 3.12: **87 tests, two explicit prerequisite skips,
|
||||
37.605 seconds**. Focused native app tests passed **14/14** and website tests
|
||||
**18/18**. An isolated Linux checkout of base `741735a` plus the exact four
|
||||
test-file changes passed the **full 1,621-test suite, 16 skips, 170.313 seconds**,
|
||||
including recording-shell fixtures under modern Bash. No full Mac suite pass
|
||||
after these corrections is claimed.
|
||||
|
||||
Final native cleanup confirmed the app processes and loopback debug port were
|
||||
closed, original preferences restored, Open at Login off, the DMG ejected and
|
||||
its installed signature still valid. External test subprocesses had generated
|
||||
36 Python bytecode files inside the bundle; removed only those absent from
|
||||
the verified clean release before the final signature check. The native app
|
||||
uses `-B` itself. The installed desk cleanup tool reported
|
||||
only **Finder, Messages and cmux** in front, **zero notifications**, no items
|
||||
needing attention and the **display asleep**. The official app remains installed;
|
||||
previous copies and raw test logs are retained in `/tmp/frame-control-v0439-intel`.
|
||||
The desk hold is released at the end of the task.
|
||||
|
||||
Actual login startup, ordinary browser quarantine/first-open override,
|
||||
direct app/headset transfer, real OBS audio/video, Mac screen capture,
|
||||
headset VR interaction, wearer comfort and controller input remain untested.
|
||||
No social post was made. PR #113 was left untouched; merging and releasing
|
||||
remain with the lxso2 coordinator.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 560 KiB |
@@ -0,0 +1,23 @@
|
||||
# v0.4.41 tagged release, 26 October 2026
|
||||
|
||||
The annotated `v0.4.41` tag dereferences to main merge commit `b5d50a8d896f790a94baf4e937db914eab475ed9` from PR #297. The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40271174791) passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs. The platform jobs launched the built Linux AppImage, checked the Windows installation and bundled PC Python libraries, and checked Mac bundle architectures. These CI results do not establish a physical Windows or Mac launch.
|
||||
|
||||
## Real Frame, exact tag
|
||||
|
||||
From an exact tag checkout, `scripts/frame-smoke.sh` acquired its unique owner in `/tmp/frame-test.lock` before reading the headset. Preflight found SteamVR activity 3 (standby) and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, all **13/13 reserved title steps passed**; the command exited 0, including test title and process cleanup and release of its owned lock. ARM64 and Proton test executables launched. The x86_64 launch step recorded that optional Steam Linux Runtime 4 was **not installed**, so that executable did not actually launch. The private detailed result is `tests/smoke/results/2026-10-26T045140Z-20261022.6153909.json` in the exact-tag checkout.
|
||||
|
||||
No person wore the headset. This title smoke does not verify active passthrough pixels, wearer-visible panels, physical controller input, official Decky plugin panels, audio output, comfort or VR keyboard use. The separate [unworn Framey sample exercise](framey-scroll-probe-2026-10-26.md) established live sample setting persistence, stereo compositor changes and trusted synthetic OpenVR input; it did not establish a worn result. The hourly worn check remains enabled.
|
||||
|
||||
## Downloaded installer and checksum checks
|
||||
|
||||
The draft held ten installers plus `SHA256SUMS`, each with a GitHub SHA-256 digest. `scripts/publish-release.sh --dry-run v0.4.41` checked the exact tag and all ten installer digests, regenerated `SHA256SUMS` from those digests and matched its own GitHub digest. A fresh downloaded checksum list independently matched `e7555956aeb9ec3a464081aaafe1ed6762f65a17c04edaa6ea45286f10927109`.
|
||||
|
||||
A fresh Linux x86_64 AppImage download matched `5d1f50dc8527faf299f2058c7b5ed19057ec7c81c41449f6ccd44d68e6b4d212`. `scripts/check-appimage-launch.py` launched that exact file under isolated Xvfb: the bundled server answered HTTP 200 with `FrameControl/1`, then stopped. The first local attempt failed at Chromium sandbox startup inside the command sandbox; rerunning the same downloaded file with normal host permissions passed. The release workflow's independent Linux launch also passed.
|
||||
|
||||
Fresh Mac ZIP downloads matched GitHub digests: arm64 `84615ec8a0c62481a9f33bae1815d55ec902db9aa9d4bd767fcf9380c3d44d7a`, x64 `2800b749cda98d15207950eca4a6bd205d35f30551f16efd7720b41347edc7ca`. Both contain bundle version 0.4.41; the app executable Mach-O CPU types are arm64 and x86_64 respectively. The physical Mac was unreachable over SSH, so neither tagged ZIP was launched there. The physical Intel Air was also not tested with this tag.
|
||||
|
||||
## Publication and updater readback
|
||||
|
||||
The final dry run passed with the release notes and hardware limits in the draft. `scripts/publish-release.sh v0.4.41` published [v0.4.41](https://github.com/saphid/frame-control/releases/tag/v0.4.41) at 2026-10-26 05:01:47 UTC. GitHub's latest-release API read back `draft=false`, tag `v0.4.41`, ten installers, `SHA256SUMS` and `update.json`. A fresh public latest-download of `update.json` parsed as version 0.4.41 with the stable release page, ten SHA-256 digests and the unworn-test limits in its notes. The release contains the tagged `b5d50a8` commit; no post-tag changes are implied.
|
||||
|
||||
The exact-version official Decky catalog in this release is **3 works / 48 partial / 54 broken / 11 N/A**. Volume Mixer and Mixer are works only for the tested per-app silent-stream volume scope on real ARM64 Decky; [audio evidence](framey-audio-arm64-2026-10-26.md) describes the independent Pulse readbacks. Volume Boost remains partial. Audible playback, Bluetooth, other mixer controls and official-plugin worn VR remain unverified.
|
||||
@@ -0,0 +1,9 @@
|
||||
# v0.4.42 candidate gate, 26 October 2026
|
||||
|
||||
This branch bumps the app and lockfile to 0.4.42. It includes the [v0.4.41 release evidence](v0441-release-2026-10-26.md), the Framey panel lifecycle change in PR #299, the truthful optional-runtime smoke result in PR #300, the physical Intel Mac acceptance and host-test fixes in PR #303 (`fe1da43171883dc31161ff607c6b5a6ddf75f6ef`), and the receipt-bound API guard in PR #301 (`ad57662d40d3bbf6c850dd80ddc275280d389c86`). All 11 hosted checks passed on PR #301's exact head before its normal merge. The combined release candidate's own headset gates passed; exact-head CI and installer previews remain pending.
|
||||
|
||||
The exact v0.4.42 candidate at `27af46dd20d7af30dde4c3f954d7b57b94843594` ran `scripts/frame-smoke.sh` under its unique shared lock at 05:40 UTC. Locked preflight found standby activity 3, proximity 6.25 and battery 99%. It recorded **12 passed / 1 skipped / 0 failed** on Frame OS 0.5.6 build `20261022.6153909`; ARM64 and Proton test executables launched. The x86_64 launch was skipped because Steam Linux Runtime 4 was not installed. Cleanup passed and the command exited 0. The private detailed result is `tests/smoke/results/2026-10-26T054014Z-20261022.6153909.json` in the candidate checkout. The prior PR #300 result is documented [separately](frame-smoke-runtime-skip-2026-10-26.md).
|
||||
|
||||
The exact candidate then ran `scripts/framey-vr-check.py --exercise` under a fresh owned lock at 05:41 UTC. Preflight again found activity 3 and battery 99%. The real Decky sample setting saved `enabled=true` and survived a fresh iframe; the quick action read the same value. A trusted synthetic OpenVR pointer reached the panel; the sample slider reached 50 and CDP text entry after OpenVR focus reached `framey-ci`. Stereo compositor hashes changed from `38265343e13f5915869c9622ab1ceda7c31ab2e9b050f44564cfb9b6dc9f6e6d` to `64cc4c6f865bb8b2f756632c922d17dee12af40d480171586f2e9a51797b83e6`; the mirror capture hash was `165289dca9ad2bd895eb544f5be5d5fa2db7d2f635a927ac68a0c74267f69ee2`. The exercise exited 0 with `passed=true`, battery 99% afterward, original power values restored (`false` / `5.000000`), recovery timer removed, no cleanup errors and its own lock released. Its private full result is `/tmp/framey-v0442-exercise/result.json` on the test host. Synthetic scroll events left `scrollTop=0` and produced no wheel events. No person wore the Frame; physical controller interaction, official plugin VR panels, optical readability, VR keyboard and comfort remain unverified. The earlier [receipt-guard exercise](framey-receipt-live-2026-10-26.md) is separate evidence.
|
||||
|
||||
The release tag and published installers do not exist at this candidate stage. Publication remains gated on this combined head's exact-head CI, then tagged installer checksums, package checks and public updater readback. The 116 exact-version Decky catalog remains **3 works / 48 partial / 54 broken / 11 N/A**; the two additional works claims are restricted to tested per-app silent-stream volume control. The official v0.4.39 Intel installer ran on a physical MacBook Air, but no tagged v0.4.42 Mac launch or real OBS audio/video check is claimed.
|
||||
@@ -0,0 +1,62 @@
|
||||
# v0.4.42 tagged release, 26 October 2026
|
||||
|
||||
The annotated `v0.4.42` tag dereferences to main merge commit
|
||||
`98909cb54374a7bd7ec1f665f50eda20fa196f32` from PR #302. The app and
|
||||
lockfile both report 0.4.42. The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40276685171)
|
||||
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs.
|
||||
The Linux job launched its built AppImage under isolated Xvfb. CI does not prove
|
||||
a physical Windows or Mac launch.
|
||||
|
||||
## Real Frame, exact tag
|
||||
|
||||
From an exact tag checkout, `scripts/frame-smoke.sh` acquired its own unique
|
||||
owner in `/tmp/frame-test.lock` before reading the headset. Preflight found
|
||||
SteamVR activity 3 (standby), proximity 6.25 and battery 100%. On Frame OS 0.5.6
|
||||
build `20261022.6153909`, it recorded **12 passed / 1 skipped / 0 failed** and
|
||||
exited 0. ARM64 and Proton test executables launched. The x86_64 title was
|
||||
installed and removed, but its launch was skipped because optional Steam Linux
|
||||
Runtime 4 was absent. Test title/process cleanup passed and only the run's lock
|
||||
was released. The private result is
|
||||
`tests/smoke/results/2026-10-26T055940Z-20261022.6153909.json` in the exact tag
|
||||
checkout.
|
||||
|
||||
The separate [unworn Framey candidate exercise](v0442-candidate-2026-10-26.md)
|
||||
passed real sample setting save/reopen, synthetic OpenVR pointer delivery and
|
||||
stereo compositor change, with power restored and cleanup complete. It was run
|
||||
on the release branch's code before the evidence-only final commit. No person
|
||||
wore the Frame. Neither run verifies official plugin VR panels, physical
|
||||
controller usability, native scrolling, VR keyboard or optical comfort. The
|
||||
hourly worn check remains enabled.
|
||||
|
||||
## Installer, checksum and updater checks
|
||||
|
||||
The draft held ten installers plus `SHA256SUMS`, each with a GitHub SHA-256
|
||||
digest. `scripts/publish-release.sh --dry-run v0.4.42` checked the exact remote
|
||||
tag and every installer digest, regenerated the checksum list from those
|
||||
metadata digests, and matched the uploaded `SHA256SUMS` digest. A fresh
|
||||
checksum-list download independently matched
|
||||
`e5c85f5a46d789dccafae5259bd00b9b048dd10ca4f2a6cdc52d1f432a6db989`.
|
||||
|
||||
A fresh Linux x86_64 AppImage download matched
|
||||
`16fc9fbe448bccbe59aa4206bc171c6bfc741973d2b4da0a79652a5c11d837b3`.
|
||||
`scripts/check-appimage-launch.py` launched those exact bytes under isolated
|
||||
Xvfb: the bundled server answered HTTP 200 with `FrameControl/1` and then
|
||||
stopped. Fresh Mac ZIP downloads matched GitHub digests: arm64
|
||||
`394d6088e732a19289b7b92366dd247f14f7293a345d57ed5c4e9c83a50abad3`
|
||||
and x64 `66d4606e38210d02af59dc87e4d1216282ad9e968056d7096f247fcc51e72395`.
|
||||
Both bundles report version 0.4.42; their main Mach-O CPU types are arm64 and
|
||||
x86_64 respectively. Neither tagged Mac ZIP was launched on a physical Mac.
|
||||
|
||||
`scripts/publish-release.sh v0.4.42` published
|
||||
[v0.4.42](https://github.com/saphid/frame-control/releases/tag/v0.4.42) at
|
||||
2026-10-26 06:02:20 UTC. GitHub's latest-release API read back `draft=false`,
|
||||
`prerelease=false`, the expected tag, ten installers, `SHA256SUMS` and
|
||||
`update.json`. A fresh public latest-download of `update.json` parsed as
|
||||
version 0.4.42 with the stable release page and ten SHA-256 installer digests.
|
||||
The tag contains #299, #300, #301 and #303 through #302's release merge.
|
||||
#304 merged after the tag and is not in this release.
|
||||
|
||||
The exact-version official Decky table for v0.4.42 is **3 works / 48 partial /
|
||||
54 broken / 11 N/A**. The two additional works labels are restricted to tested
|
||||
per-app silent-stream volume control. Audible playback and official-plugin
|
||||
worn VR remain unverified.
|
||||
@@ -0,0 +1,7 @@
|
||||
# v0.4.43 candidate Frame gate, 26 October 2026
|
||||
|
||||
The combined code head `232b253e7a9400aa9829f9fea6a5d2011dd2a8f3` (PR #309 after merging main with PR #308) ran `scripts/frame-smoke.sh` under its own `/tmp/frame-test.lock` owner at 07:57 UTC. Its locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, it recorded **12 passed / 1 skipped / 0 failed**. The ARM64 and Proton executables launched; x86_64 launch was skipped because Steam Linux Runtime 4 is absent. It removed its test titles, exited 0 and released its lock. The private detailed result is `/tmp/frame-smoke-pr309/2026-10-26T075752Z-20261022.6153909.json` on the test host. A fresh, separately owned read-only lock then found activity 3 and battery 99%, and was released. PR #310 subsequently changed only the Windows wireless CI smoke test; the release branch merged that test change and bumped the app version, with no further headset runtime code change after this Frame smoke.
|
||||
|
||||
PR #308's panel code commit `d5e62f643d462b7070f27c2c47b11b4aa9982094` previously passed the temporary Framey sample `scripts/framey-vr-check.py --exercise` machine check, with cleanup and power restoration; see [Web Browser follow-up](framey-web-browser-2026-10-26.md). That test used synthetic OpenVR input and a repository sample, not the official Web Browser plugin or a person wearing the headset. No wake or VR input exercise ran on this combined code head because wearer confirmation was absent. The current smoke made no worn visual, physical-controller, audible-output or official-plugin VR claim.
|
||||
|
||||
This is candidate evidence, not a published-release result. Publication still needs all exact-head PR and release checks, ten tagged installers with matching `SHA256SUMS`, package validation, the publisher dry run, and public updater readback. The exact-version official Decky catalog in this candidate is **4 works / 49 partial / 52 broken / 11 N/A**. Volume Boost's works claim covers tested muted sink controls; Junk-Store remains partial, and Web Browser remains broken.
|
||||
@@ -0,0 +1,79 @@
|
||||
# v0.4.43 tagged release, 26 October 2026
|
||||
|
||||
The annotated `v0.4.43` tag dereferences to main merge commit
|
||||
`b4e632b218eed0761db488061e3fe6c503b06a0a` from PR #311. Both the app
|
||||
and lockfile report 0.4.43. The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40292211650)
|
||||
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs.
|
||||
The [tagged main checks](https://github.com/saphid/frame-control/actions/runs/40292128084)
|
||||
passed all seven jobs, including iOS and fake-Frame end-to-end tests. The Linux
|
||||
builder launched its AppImage under isolated Xvfb; that does not prove a physical
|
||||
display, FUSE mount or headset connection.
|
||||
|
||||
## Frame and package gates
|
||||
|
||||
The [combined-head candidate](v0443-candidate-2026-10-26.md) passed the real
|
||||
Frame smoke before tagging: 12 passed, one optional x86 launch skipped, zero
|
||||
failed. The only later changes before the tag were the Windows wireless CI
|
||||
test and version metadata. Its locked preflight found SteamVR activity 3
|
||||
(standby), proximity 6.25 and battery 99%.
|
||||
|
||||
The draft had ten installers plus `SHA256SUMS`. Each installer had a GitHub
|
||||
SHA-256 digest. A fresh downloaded checksum list contained exactly those ten
|
||||
names and digests; its own downloaded bytes matched GitHub's digest
|
||||
`57f211d8bbf76db8aa96cc4a5ae31410ed7e3075ce2e8c0c17394feb9426d01c`.
|
||||
`scripts/publish-release.sh --dry-run v0.4.43` passed the exact remote tag,
|
||||
installer and checksum gates after the notes were attached.
|
||||
|
||||
Freshly downloaded packages passed these independent checks:
|
||||
|
||||
| Package | Result |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | SHA-256 `d5e6c2272a14a2aa77aa932db43f3ce887e09eb78c87bf2de410832b5df4827d`; launched under isolated Xvfb, bundled server returned HTTP 200 with `FrameControl/1`, and stopped on exit. |
|
||||
| Mac arm64 ZIP | SHA-256 `c6c4a9c74644c3479d1af112ec60d4c2770201fc366f21bca23a4e2fcc86194a`; ZIP CRC passed, bundle version 0.4.43, main Mach-O CPU arm64. |
|
||||
| Mac x64 ZIP | SHA-256 `58dfc88bb71f1733add7477f9cd888417e4827e608fe910017837ba466a72a40`; ZIP CRC passed, bundle version 0.4.43, main Mach-O CPU x86_64. |
|
||||
| Windows x64 Setup | SHA-256 `8c17522b0e96d54f53949f4df56a3e1ec1fe2da8df595482a358a3d014b6f1a9`; PE/Nullsoft installer format. The tagged Windows CI also installed and checked its packaged files and Python libraries. |
|
||||
| Linux arm64 DEB | SHA-256 `c00f141b6b63344c629bbd659a366a74705c52061d83572ae235a75ac6618df0`; package version 0.4.43 and architecture arm64. |
|
||||
|
||||
Tagged Mac and Windows packages were not launched on physical machines in this
|
||||
release gate. Earlier Intel Mac acceptance covers an older version, not these
|
||||
tagged bytes.
|
||||
|
||||
## Publication and later main
|
||||
|
||||
`scripts/publish-release.sh v0.4.43` published
|
||||
[v0.4.43](https://github.com/saphid/frame-control/releases/tag/v0.4.43) at
|
||||
2026-10-26 08:39:09 UTC. GitHub's latest-release API returned `draft=false`,
|
||||
`prerelease=false`, the expected tag, ten installers, `SHA256SUMS` and
|
||||
`update.json`. A fresh public latest-download of `update.json` parsed as
|
||||
version 0.4.43 with the stable release page, ten hashed assets and the worn-VR
|
||||
limits in its notes. The release catalog is **4 works / 49 partial / 52 broken /
|
||||
11 N/A**. Volume Boost's works label covers tested muted sink controls only.
|
||||
|
||||
PRs #312 and #313 merged **after** the tag and are absent from v0.4.43. On their
|
||||
combined main commit `c50f96510fdb42a2aee227436a04c0cfe8817919`, a second
|
||||
locked `scripts/frame-smoke.sh` run at 08:37 UTC found activity 3, proximity 6.25
|
||||
and battery 99%, then passed 12 checks with the same optional x86 launch skip
|
||||
and zero failures. Test title and process cleanup passed; the command exited 0
|
||||
and released only its owned lock. The private result is
|
||||
`tests/smoke/results/2026-10-26T083719Z-20261022.6153909.json` in the test
|
||||
checkout. This post-tag smoke does not test the new Framey scroll buttons or
|
||||
Junk-Store's deployed headset panel.
|
||||
|
||||
The guarded `scripts/framey-vr-check.py --exercise` then ran on the same code
|
||||
commit, using the existing direct `frame` SSH alias and its own shared-lock owner.
|
||||
It began at 08:45 UTC in SteamVR standby (activity 3, proximity 6.3125,
|
||||
battery 99%). The temporary sample panel passed setting save/reopen, quick
|
||||
action, slider and text checks. Synthetic native OpenVR clicks on the visible
|
||||
**Scroll plugin down/up** buttons moved the 160-pixel test region 0 → 120 → 0;
|
||||
the compositor screenshot hash changed. Native synthetic wheel attempts still
|
||||
produced no wheel event or movement. The private report is
|
||||
`/tmp/framey-scroll-postmerge-direct-20261026/result.json` on the test host.
|
||||
The command exited 0, removed its test services and power watchdog, read back
|
||||
the original `false` / `5` power settings, found battery 99%, and released its
|
||||
owned lock with no cleanup errors. It did not exercise an official plugin or a
|
||||
physical controller.
|
||||
|
||||
No wearer confirmation was available, so no worn check, wake or input exercise
|
||||
ran. Physical controller use, optical readability, official-plugin VR panels,
|
||||
audible output and VR keyboard remain unverified. The hourly worn-check schedule
|
||||
stays enabled.
|
||||
@@ -0,0 +1,56 @@
|
||||
# v0.4.44 candidate gate, 26 October 2026
|
||||
|
||||
This candidate starts from main commit
|
||||
`c50f96510fdb42a2aee227436a04c0cfe8817919`, which contains PR #312's
|
||||
Framey scroll buttons and PR #313's read-only installed-app metadata projection.
|
||||
It also carries the documentation-only v0.4.43 release evidence from PR #314.
|
||||
The app and lockfile are bumped to 0.4.44. No compatibility label changes:
|
||||
the exact-version official Decky catalog remains **4 works / 49 partial /
|
||||
52 broken / 11 N/A**. Junk-Store's About and Dependencies pages now render in
|
||||
the local Chromium test with its actual ARM64 backend; the plugin remains partial.
|
||||
|
||||
## Real Frame checks on the combined code
|
||||
|
||||
At 08:37 UTC, `scripts/frame-smoke.sh` ran on that main commit under its own
|
||||
`/tmp/frame-test.lock` owner. Locked preflight found SteamVR activity 3
|
||||
(standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build
|
||||
`20261022.6153909`, ARM64 and Proton executables launched; the optional x86_64
|
||||
launch was skipped because Steam Linux Runtime 4 is absent. The result was
|
||||
**12 passed / 1 skipped / 0 failed**. It removed its test titles, exited 0 and
|
||||
released its owned lock. The private result is
|
||||
`tests/smoke/results/2026-10-26T083719Z-20261022.6153909.json` on the test
|
||||
host.
|
||||
|
||||
At 08:45 UTC, `scripts/framey-vr-check.py --exercise` ran the same code under
|
||||
a separate unique shared-lock owner, beginning in standby (activity 3,
|
||||
proximity 6.3125, battery 99%). Synthetic native OpenVR clicks on the new
|
||||
**Scroll plugin down/up** buttons moved a temporary sample region 0 → 120 → 0.
|
||||
The sample setting save/reopen, quick action, slider, text and compositor-change
|
||||
checks passed. Native synthetic wheel attempts still produced no event or
|
||||
movement. Cleanup removed test services and its power watchdog, restored the
|
||||
original `false` / `5` power values, read battery 99%, and released its lock
|
||||
with no errors. The private report is
|
||||
`/tmp/framey-scroll-postmerge-direct-20261026/result.json` on the test host.
|
||||
This is an unworn sample panel check. It does not prove physical controller
|
||||
scrolling, optical readability or an official-plugin panel in VR.
|
||||
|
||||
## Publication boundary
|
||||
|
||||
The version-bumped release branch also ran `scripts/frame-smoke.sh` after its
|
||||
normal merge of PR #314's documentation. At 08:59 UTC, its locked preflight
|
||||
found activity 3, proximity 6.3125 and battery 97%. The result was again
|
||||
**12 passed / 1 skipped / 0 failed** on the same Frame OS build; ARM64 and
|
||||
Proton launches passed, while the optional x86 launch was skipped for the
|
||||
missing Steam Linux Runtime 4. Owned test title/process cleanup passed, the
|
||||
command exited 0, and its lock was released. The private result is
|
||||
`tests/smoke/results/2026-10-26T085951Z-20261022.6153909.json` on the test
|
||||
host. This evidence-only paragraph is the sole change after that smoke.
|
||||
|
||||
PR #313's own actual ARM64 backend/library test is documented in
|
||||
[installed-context evidence](framey-installed-context-2026-10-26.md). Its
|
||||
dependency installation, login, game operations, RSS and optional websocket
|
||||
remain unverified. The candidate still needs exact-head CI, tagged builds,
|
||||
ten installer digests with `SHA256SUMS`, downloaded package checks, publisher
|
||||
dry run and public updater readback before publication. A worn check requires
|
||||
activity, proximity and reliable wearer confirmation; none was available, so
|
||||
the hourly schedule stays enabled.
|
||||
@@ -0,0 +1,58 @@
|
||||
# v0.4.44 tagged release, 26 October 2026
|
||||
|
||||
The annotated `v0.4.44` tag dereferences to main merge commit
|
||||
`37e25e62ace388ce032f47d08398c26ea8540fdb` from PR #315. The app and
|
||||
lockfile both report 0.4.44. [Post-merge main checks](https://github.com/saphid/frame-control/actions/runs/40298400256)
|
||||
passed all seven jobs, including iOS and fake Frame end-to-end tests. The
|
||||
[tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40299460805)
|
||||
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum
|
||||
jobs. The tagged Linux build ran the AppImage under isolated Xvfb; this does
|
||||
not verify a physical display or headset.
|
||||
|
||||
## Frame and package gates
|
||||
|
||||
The [candidate evidence](v0444-candidate-2026-10-26.md) records a shared-lock
|
||||
real Frame smoke on the version-bumped candidate: **12 passed / 1 optional x86
|
||||
launch skipped / 0 failed**, with owned cleanup and lock release. The x86
|
||||
launch was skipped because Steam Linux Runtime 4 is absent. The candidate
|
||||
also records an unworn sample panel exercise: synthetic OpenVR clicks moved a
|
||||
temporary scroll region down and back, while a synthetic native wheel did
|
||||
not scroll. No physical controller or wearer-visible result follows from it.
|
||||
The final candidate edit was evidence only, and the merge commit added no
|
||||
runtime changes.
|
||||
|
||||
The draft contained ten installers and `SHA256SUMS`. Every installer had a
|
||||
GitHub SHA-256 digest. A freshly downloaded `SHA256SUMS` listed exactly those
|
||||
ten names and matching digests; its downloaded bytes matched GitHub's digest
|
||||
`072f8084d6f9f145f4a58457d10f54390b50d11288bece42f6d801a356a69b1c`.
|
||||
After the release notes were attached, `scripts/publish-release.sh --dry-run
|
||||
v0.4.44` passed the remote tag, ten installer and checksum gates.
|
||||
|
||||
Freshly downloaded packages passed these independent checks:
|
||||
|
||||
| Package | Result |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | SHA-256 `d1cb5c3b590a44a9c61cf1d38d546cf89a835c73bece839c639f0d2be70ff139`; launched under isolated Xvfb, bundled server returned HTTP 200 with `FrameControl/1`, and stopped on exit. The first local attempt was blocked by the sandbox; the same downloaded bytes passed outside it. |
|
||||
| Mac arm64 ZIP | SHA-256 `5a25dfea9b3c645dae7acaf660b7127e9a46a03173a69d02ef4c64b0b39a5934`; ZIP CRC valid, bundle version 0.4.44, main Mach-O CPU arm64. |
|
||||
| Mac x64 ZIP | SHA-256 `fde1c48eef4e3b4be12a862d32c1154afd1a93fe1f27a32f0799365205655021`; ZIP CRC valid, bundle version 0.4.44, main Mach-O CPU x86_64. |
|
||||
| Windows x64 Setup | SHA-256 `e8cae0a8d0f8b76a41ca109f5ec4bf9947ca31531a8b93ebb8e326e453602a16`; PE/Nullsoft installer format. The tagged Windows CI also checked its packaged files and Python libraries. |
|
||||
| Linux arm64 DEB | SHA-256 `40f1704af89b5b824bc28e93328c516306aaefb2e0f441c1866197fc953eb21f`; package version 0.4.44 and architecture arm64. |
|
||||
|
||||
The tagged Mac and Windows packages were not launched on physical machines
|
||||
in this release gate. Earlier Intel Mac acceptance covers older bytes.
|
||||
|
||||
## Publication and limits
|
||||
|
||||
`scripts/publish-release.sh v0.4.44` published
|
||||
[v0.4.44](https://github.com/saphid/frame-control/releases/tag/v0.4.44) at
|
||||
2026-10-26 09:38:50 UTC. GitHub's latest-release API returned `draft=false`,
|
||||
`prerelease=false`, the expected tag, ten installers, `SHA256SUMS` and
|
||||
`update.json`. A fresh public latest-download of `update.json` reported
|
||||
version 0.4.44, the stable release page and ten hashed assets. Its notes
|
||||
retain the worn-VR limits. The exact-version official Decky catalog remains
|
||||
**4 works / 49 partial / 52 broken / 11 N/A** out of 116; Junk-Store remains
|
||||
partial.
|
||||
|
||||
No reliable wearer confirmation was available. Official-plugin VR rendering,
|
||||
physical controller use, optical readability, audible output and VR keyboard
|
||||
remain unverified. The hourly shared-lock worn-check schedule stays enabled.
|
||||
@@ -0,0 +1,84 @@
|
||||
# v0.4.45 candidate gate, 26 October 2026
|
||||
|
||||
The candidate starts from main commit
|
||||
`f88e2ed209167a836282a969a71df4c897e658a8`, after PR #316 bound
|
||||
Framey chunk URLs to installed store receipts and PR #317 fixed shared Decky
|
||||
activation and confirmation controls. PR #319 also added a bounded retry to
|
||||
the fake Frame test image after Valve's ARM64 package server repeatedly
|
||||
truncated downloads. The app and lockfile are bumped to 0.4.45. The official
|
||||
116-plugin catalog remains **4 works / 49 partial / 52 broken / 11 N/A**.
|
||||
AlarMe remains partial: its silent timer, preset and stopwatch paths were
|
||||
exercised with the official ARM64 backend, while sound, reminders, sleep
|
||||
controls, closed-panel notifications and worn VR were not verified.
|
||||
|
||||
## Locked real Frame smoke
|
||||
|
||||
At 10:26 UTC, `scripts/frame-smoke.sh` ran on that combined main commit after
|
||||
atomically acquiring its own `/tmp/frame-test.lock`. Read-only preflight found
|
||||
SteamVR activity 3 (standby), proximity 6.3125 and battery 95%. On Frame OS
|
||||
0.5.6 build `20261022.6153909`, ARM64 and Proton test titles installed,
|
||||
launched and were removed. The optional x86_64 title installed and was
|
||||
removed, but its launch was skipped because Steam Linux Runtime 4 is absent.
|
||||
The result was **12 passed / 1 skipped / 0 failed**. Owned process and title
|
||||
cleanup passed, the command exited 0 and released its lock. The private
|
||||
detailed report is
|
||||
`tests/smoke/results/2026-10-26T102653Z-20261022.6153909.json` in the
|
||||
test checkout.
|
||||
|
||||
## Unworn Framey panel exercise
|
||||
|
||||
At 10:29 UTC, `scripts/framey-vr-check.py --exercise` ran the same combined
|
||||
main commit under a separate random shared-lock owner. It began in standby
|
||||
with proximity 6.3125 and battery 95%. The temporary managed panel, Decky
|
||||
service and repository sample passed the shipped host's setting save and
|
||||
fresh-iframe reopen, quick action, slider and text checks. A trusted
|
||||
synthetic OpenVR pointer reached the panel. The scroll buttons moved a
|
||||
temporary test region **0 → 120 → 0**; synthetic native wheel input still
|
||||
produced no wheel event or movement. The stereo compositor image changed and
|
||||
the `/dev/video99` mirror snapshot contained varied, nonblack pixels. This
|
||||
single mirror image does not establish moving passthrough content or what a
|
||||
wearer sees. The direct overlay image read returned error 24 and zero size,
|
||||
so it is not used as texture evidence.
|
||||
|
||||
The private report and room-bearing images are in the test host's temporary
|
||||
output folder. Reported hashes are stereo before
|
||||
`84fe777535870c54cb381bbee3223684d1e0e4f2e6626e6d74ee902a4eec9c1b`,
|
||||
stereo after
|
||||
`4666d2b0908657489b8cc21520afc3deed4afa26a4c555c782298992b72abdd1`,
|
||||
and mirror
|
||||
`0769bbc45bd37266cc9ad80154cbd8a44ae3e1577cfa0d419e6af012afbf0fb4`.
|
||||
No raw image is committed.
|
||||
|
||||
The exercise exited 0 with no cleanup errors, removed its temporary services
|
||||
and power recovery timer, restored and read back the original
|
||||
`power.pauseCompositorOnStandby=false` and
|
||||
`power.turnOffScreensTimeout=5`, found battery 95% and released only its lock.
|
||||
Its immediate post-run activity was 0 after the temporary wake. A separate
|
||||
locked read-only check at 10:31 UTC found activity **3 standby**, proximity
|
||||
6.3125, battery 95%, no cleanup errors, and released its lock.
|
||||
|
||||
The sample is not an official plugin. This exercise does not test the new
|
||||
receipt-bound chunk path on real hardware or show AlarMe inside VR. Backend
|
||||
registration, synthetic pointer delivery and changing compositor pixels do
|
||||
not prove physical-controller or wearer-visible usability. No wearer
|
||||
confirmation was available, so `scripts/framey-worn-check.py` was not run;
|
||||
the hourly worn-headset schedule remains enabled.
|
||||
|
||||
## Publication boundary
|
||||
|
||||
The version-bumped candidate commit `59ea093` also ran
|
||||
`scripts/frame-smoke.sh` under its own shared-lock owner at 10:34 UTC. Its
|
||||
locked preflight again found activity 3 standby, proximity 6.3125 and
|
||||
battery 95%. The result was **12 passed / 1 optional x86 launch skipped /
|
||||
0 failed** on the same Frame OS build. Owned title/process cleanup passed,
|
||||
the command exited 0 and released its lock. The private result is
|
||||
`tests/smoke/results/2026-10-26T103419Z-20261022.6153909.json` in the test
|
||||
checkout. This evidence-only paragraph is the sole change after that smoke.
|
||||
|
||||
This is candidate evidence, not a published-release result. Exact-head PR
|
||||
and post-merge CI, tagged installers, all ten installer digests with
|
||||
`SHA256SUMS`, independent package checks, publisher dry run and public
|
||||
`update.json` readback remain required before publication. Physical Mac and
|
||||
Windows launch of the eventual tagged bytes, audible alarms, official-plugin
|
||||
VR panels, optical readability, physical controllers, haptics and the VR
|
||||
keyboard remain unverified.
|
||||
@@ -0,0 +1,61 @@
|
||||
# v0.4.45 tagged release, 26 October 2026
|
||||
|
||||
The annotated `v0.4.45` tag dereferences to main merge commit
|
||||
`212f0af35c86eb651d5d23bfe0b1e6bf50c528b8` from PR #320. The app and
|
||||
lockfile both report 0.4.45. [Post-merge main checks](https://github.com/saphid/frame-control/actions/runs/40309949732)
|
||||
passed all seven jobs, including iOS and fake Frame end-to-end tests. The
|
||||
[tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40310897979)
|
||||
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum
|
||||
jobs. The tagged Linux builder launched its AppImage under isolated Xvfb;
|
||||
that does not verify a physical display, FUSE mount or headset connection.
|
||||
|
||||
## Frame and package gates
|
||||
|
||||
The [candidate evidence](v0445-candidate-2026-10-26.md) records two
|
||||
shared-lock real Frame smokes: one on the combined main code and one after
|
||||
the 0.4.45 version bump. Each finished **12 passed / 1 optional x86 launch
|
||||
skipped / 0 failed**, with owned cleanup and lock release. The optional
|
||||
x86_64 launch was skipped because Steam Linux Runtime 4 is absent. The
|
||||
candidate also records the unworn sample Framey exercise on the combined
|
||||
code. Synthetic OpenVR input, setting persistence and scroll-button
|
||||
movement passed; native synthetic wheel scrolling did not. The only change
|
||||
after the version-bumped smoke was its evidence paragraph.
|
||||
|
||||
The draft had ten installers plus `SHA256SUMS`, each with a GitHub SHA-256
|
||||
digest. A fresh downloaded checksum list contained exactly those ten names
|
||||
and matching digests; its bytes matched GitHub's digest
|
||||
`d1d84f03c09881417610337cc48f0025535c8b0d227d55147f1f2b99592625fb`.
|
||||
`scripts/publish-release.sh --dry-run v0.4.45` passed the exact remote tag,
|
||||
installer and checksum gates after the release notes were attached.
|
||||
|
||||
Freshly downloaded packages passed these independent checks:
|
||||
|
||||
| Package | Result |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | SHA-256 `478ea6535979f049930351d5c0abba14e3bcc4bcb5e695ee7ee945a5fd5bf72f`; launched under isolated Xvfb, bundled server returned HTTP 200 with `FrameControl/1`, and stopped on exit. |
|
||||
| Mac arm64 ZIP | SHA-256 `0204b445bc35596170d85f7a1e05e793f1c1b247f3129fadad6f8c92730cf3e2`; ZIP CRC valid, bundle version 0.4.45, main Mach-O CPU arm64. |
|
||||
| Mac x64 ZIP | SHA-256 `88ded8bc492501f467a9c51156661f3a25b0dc6347660caf0d2e230bda0f984e`; ZIP CRC valid, bundle version 0.4.45, main Mach-O CPU x86_64. |
|
||||
| Windows x64 Setup | SHA-256 `c512d322b2022a233ed26aaa03e038b9a735a2df654ec7a3ebefc1ba86b04334`; PE/Nullsoft installer format. The tagged Windows CI also checked its packaged files and Python libraries. |
|
||||
| Linux arm64 DEB | SHA-256 `d08cc96dd18b1a602bd0af33a8e6d1f380d94195aec0f83b2898098956aeedc3`; package version 0.4.45 and architecture arm64. |
|
||||
|
||||
Tagged Mac and Windows packages were not launched on physical machines in
|
||||
this release gate. Earlier Intel Mac acceptance covers older bytes.
|
||||
|
||||
## Publication and limits
|
||||
|
||||
`scripts/publish-release.sh v0.4.45` published
|
||||
[v0.4.45](https://github.com/saphid/frame-control/releases/tag/v0.4.45) at
|
||||
2026-10-26 11:02:08 UTC. GitHub's latest-release API returned
|
||||
`draft=false`, `prerelease=false`, the expected tag, ten installers,
|
||||
`SHA256SUMS` and `update.json`. A fresh public latest-download of
|
||||
`update.json` reported version 0.4.45, the stable release page, ten hashed
|
||||
assets and the worn-VR limits in its notes.
|
||||
|
||||
The official exact-version Decky catalog remains **4 works / 49 partial /
|
||||
52 broken / 11 N/A** out of 116. AlarMe's tested silent timer, preset and
|
||||
stopwatch workflows do not establish its audio, reminders, sleep controls,
|
||||
closed-panel notifications or VR panel. The real Frame exercise used the
|
||||
repository sample, not an official plugin. No wearer confirmation was
|
||||
available. Physical controller use, optical readability, official-plugin
|
||||
VR rendering, audible output and the VR keyboard remain unverified. The
|
||||
hourly shared-lock worn-check schedule stays enabled.
|
||||
@@ -0,0 +1,44 @@
|
||||
# v0.4.46 candidate gate, 26 October 2026
|
||||
|
||||
This candidate includes merged Free Loader PR #325, the Decky user-tool PATH
|
||||
fix (#323), and the interactive worn-check proximity gate (#324).
|
||||
The official 116-plugin catalog is **4 works / 50 partial / 51 broken /
|
||||
11 N/A**. Free Loader is partial for its verified package, local frontend
|
||||
and isolated settings/empty-deals scope. No live feed or worn-VR claim follows.
|
||||
|
||||
## Locked real Frame smoke before the version bump
|
||||
|
||||
At 12:12 UTC, source commit `21b85ba99dfd742a56e2c20259f56f35d83590fc`
|
||||
ran `scripts/frame-smoke.sh` on the real Frame under a unique owner of
|
||||
`/tmp/frame-test.lock`. Locked preflight found SteamVR activity **3**
|
||||
(standby), proximity raw **3.0** and battery **100%** on Frame OS 0.5.6 build
|
||||
`20261022.6153909`. Test-owned ARM64 and Windows/Proton titles installed,
|
||||
launched and were removed. The optional x86_64 title installed and was
|
||||
removed; launch was **skipped** because Steam Linux Runtime 4 is absent.
|
||||
The result was **12 passed / 1 skipped / 0 failed**. Cleanup passed and the
|
||||
command exited 0, which includes release of its owned lock. The detailed
|
||||
result remains private at
|
||||
`tests/smoke/results/2026-10-26T121228Z-20261022.6153909.json` in the
|
||||
candidate checkout.
|
||||
|
||||
This smoke checks release plumbing and temporary titles, not an installed
|
||||
official Decky plugin. No wearer was confirmed. The interactive worn-check
|
||||
script was not run, and no headset wake or controller input was sent. Free
|
||||
Loader's actual ARM64 Decky backend, live feeds, panel visibility to a wearer,
|
||||
physical controllers, keyboard and optical comfort remain unverified. The
|
||||
hourly worn-headset check stays enabled.
|
||||
|
||||
## Publication boundary
|
||||
|
||||
At 12:17 UTC, exact version-bumped candidate commit
|
||||
`9aff6adcec71852ce9bedf0ae37364bd32bb6627` ran the same locked
|
||||
`scripts/frame-smoke.sh`. Preflight again found activity **3** standby,
|
||||
proximity raw **3.0** and battery **100%**. The result was **12 passed /
|
||||
1 optional x86 launch skipped / 0 failed**. Owned title/process cleanup
|
||||
passed and the command exited 0 after releasing its lock. Its private result
|
||||
is `tests/smoke/results/2026-10-26T121721Z-20261022.6153909.json` in the
|
||||
candidate checkout. This paragraph is the only change after that smoke.
|
||||
|
||||
Green release-PR and post-merge CI, tagged installer build, all ten installer
|
||||
digests with `SHA256SUMS`, independent package checks, publisher dry run and
|
||||
public `update.json` readback remain required before publication.
|
||||
@@ -0,0 +1,58 @@
|
||||
# v0.4.46 tagged release, 26 October 2026
|
||||
|
||||
[PR #326](https://github.com/saphid/frame-control/pull/326) passed every required
|
||||
check on exact head `fc978a2afdfa47e60477fcb0bb573e7b6181fa89` and merged
|
||||
at 12:31:06 UTC. Main became `f84a4da9b1ae938d15b658af17903c0e10a56e94`.
|
||||
All seven jobs in the [post-merge main run](https://github.com/saphid/frame-control/actions/runs/40323149126)
|
||||
passed, including iOS and fake-Frame e2e. The app and lockfile both say
|
||||
`0.4.46`. Annotated tag `v0.4.46` dereferences to that main commit. The
|
||||
[tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40324176903)
|
||||
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs.
|
||||
|
||||
## Frame and package gates
|
||||
|
||||
The [candidate evidence](v0446-candidate-2026-10-26.md) records a locked
|
||||
exact-version real Frame smoke: **12 passed / 1 optional x86 launch skipped /
|
||||
0 failed**, with owned cleanup and lock release. The x86 launch needs Steam
|
||||
Linux Runtime 4, which was absent. A separate 12:25 UTC read-only check acquired
|
||||
and released its own `/tmp/frame-test.lock`; SteamVR activity was **3**
|
||||
(standby), proximity raw **3.0**, and battery **100%**. No wearer was confirmed,
|
||||
so no wake, controller input or interactive worn-check script ran.
|
||||
|
||||
The draft contained exactly ten installers plus `SHA256SUMS`, each with a
|
||||
GitHub SHA-256 digest. A fresh downloaded checksum file matched the ten digest
|
||||
lines regenerated from GitHub metadata byte for byte; its digest was
|
||||
`365e7f6d9549ff993162964920a6560480069ed04ce1eca5551696ce7bbc4ae8`.
|
||||
`scripts/publish-release.sh --dry-run v0.4.46` passed the exact tag, installer,
|
||||
checksum and update-manifest gates after release notes were attached.
|
||||
|
||||
Independent downloads passed these checks:
|
||||
|
||||
| Package | Result |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | SHA-256 `65f66f1bbb5908d49c179307950f93b643c2b8da8ac4da48a778c5c9bac5d41b`; launched under isolated Xvfb, bundled server returned HTTP 200 with `FrameControl/1`, then stopped. |
|
||||
| Mac arm64 ZIP | SHA-256 `9af88f75db7936472590022ca03a03da9de4f91de7b35c5d4d130b6dc78ab4b5`; ZIP CRC valid, bundle version 0.4.46, main Mach-O CPU arm64. |
|
||||
| Mac x64 ZIP | SHA-256 `28d9ac00c9c918a7cb9f2caac54505faa8a953285bb40f82e02d9233b775d37a`; ZIP CRC valid, bundle version 0.4.46, main Mach-O CPU x86_64. |
|
||||
| Windows x64 Setup | SHA-256 `68e303dcabb2c82ba5b9a5a58bace2ca8154f9645f1da2214ca4c6dbb05f157d`; PE/Nullsoft installer format. Tagged Windows CI checked packaged files and Python libraries. |
|
||||
| Linux arm64 DEB | SHA-256 `da9b00bd44ca7a91c542df26d8c5711ad564d618add34f89aef946785c0bd538`; package version 0.4.46 and architecture arm64. |
|
||||
|
||||
Tagged Mac and Windows packages were not launched on physical machines during
|
||||
this release gate. Earlier physical Intel Mac acceptance covers older bytes.
|
||||
|
||||
## Publication and limits
|
||||
|
||||
`scripts/publish-release.sh v0.4.46` published
|
||||
[v0.4.46](https://github.com/saphid/frame-control/releases/tag/v0.4.46) at
|
||||
12:48:03 UTC. GitHub's latest-release API returned the expected tag,
|
||||
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS` and
|
||||
`update.json`. A fresh public latest-download of `update.json` reported
|
||||
version 0.4.46, the stable release page, ten hashed installers and the
|
||||
worn-VR limit in its notes.
|
||||
|
||||
The exact-version official catalog is **4 works / 50 partial / 51 broken /
|
||||
11 N/A** out of 116. Free Loader is partial for package, local frontend and
|
||||
isolated settings/empty-deals evidence. Its actual ARM64 Decky load, live
|
||||
feeds and notifications remain unverified. The real Frame was in standby;
|
||||
official-plugin wearer visibility, physical controller usability, keyboard
|
||||
and optical comfort remain unverified. The hourly shared-lock worn check stays
|
||||
enabled.
|
||||
@@ -63,8 +63,11 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
|
||||
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
|
||||
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
|
||||
| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Plugins may still use ordinary CSS overflow containers. | Parent/iframe message tests; physical controller scroll remains unverified |
|
||||
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
|
||||
| `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 |
|
||||
| `SteamUIStore` / `DFL.Router.RunningApps`, `MainRunningApp` | Bounded detached Steam CEF reads while the panel is open; refresh about every two seconds; no MobX observation. Failed reads clear stale context. | Locked real Frame empty-list reads; nonempty local Chromium fixtures. Real running-game selection unverified. |
|
||||
| `GameSessions.RegisterForAppLifetimeNotifications` | Actual Steam callbacks, bounded queue and disposable `.unregister()` handle; no events inferred from snapshot changes; lease cleanup after lost panels | Real Frame registration/poll/close; launch/exit, retry, expiry and duplicate tests use explicit fixtures. Real game transitions unverified. |
|
||||
| Other `SteamClient` subscriptions | Explicit inert registration methods return disposable handles; no fabricated power/input/game-action events. Mutating Steam/power methods are unavailable. | Host contract tests; safe fallbacks only |
|
||||
| `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 when content also exists; settings-only plugins have no empty Panel 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; real ARM64 Notebook save/reopen across backend restart and Simple Timer settings/recents persistence via local Chromium. No worn-VR claim. |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Official Decky store compatibility with Framey
|
||||
|
||||
Snapshot: 2026-10-25T23:19:48.854504+00:00; 116 current visible official plugins from https://plugins.deckbrew.xyz/plugins.
|
||||
Snapshot: 2026-10-26T06:10:58.537029+00:00; 116 current visible official plugins from https://plugins.deckbrew.xyz/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: 3 · Partial: 48 · Broken: 54 · N/A: 11.**
|
||||
**Works: 4 · Partial: 52 · Broken: 49 · N/A: 11.**
|
||||
|
||||
- **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 only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
|
||||
@@ -25,9 +25,11 @@ python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives
|
||||
|
||||
Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.
|
||||
|
||||
A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.
|
||||
|
||||
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. 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. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. Volume Boost stays partial: default-sink reads matched, but real sink writes were denied. Audible output, Bluetooth playback and worn VR remain unverified. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. Junk-Store loading/error views render, but its real backend cannot execute the extracted startup script, so it stays broken. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. TabMaster/Junk-Store ran in actual isolated amd64 Decky; no ARM64 or worn-VR result is claimed. Beyond the documented checks, 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. A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, install folders and system information, plus IsThereAnyDeal's country dropdown in local Chromium. The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. 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. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional Line truncated
|
||||
|
||||
## Every current plugin
|
||||
|
||||
@@ -40,11 +42,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 | 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 |
|
||||
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied. | 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. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel. | 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 | 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 |
|
||||
| 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. Running-game context is now supplied; per-game selection was attempted with a synthetic game and backend calls denied. Real per-game selection, background artwork and worn-VR input remain 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 |
|
||||
@@ -56,27 +58,27 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 35: MoonDeck 1.13.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: moondeck/python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so. Frontend initializes; native CPython wheels and executables in the published archive are x86_64. | static archive, local browser, fake-Frame |
|
||||
| 36: SteamGridDB 1.8.1 | 1 / 2 | broken | Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel. | static archive, local browser, fake-Frame |
|
||||
| 37: Discord Status 1.5.0 | 1 / 2 | broken | Detached app overviews are now available, but RunningApps/lifetime observations and Steam artwork helpers still lack a live mapping. Discord activity integration remains unavailable/unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 37: Discord Status 1.5.0 | 1 / 2 | partial | Disconnected panel renders; running-activity construction accepts a synthetic game in local Chromium. Exact official is_connected returned false in real Decky 3.3.0 ARM64. Empty live running-list reads and lifetime registration/cleanup passed. Real game transitions, Discord shortcut detection/connection/activity, local custom artwork and worn VR remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | works | Per-app stream discovery and slider/keyboard volume changes pass in local Chromium through real Decky 3.3.0 ARM64 on the Frame audio session, with independent Pulse readback of a silent test stream. Bluetooth playback, audible output and worn-VR controls unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||
| 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 | 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 |
|
||||
| 43: Tailscale Control 0.1.3 | legacy / legacy | partial | Settings render locally after the SuspensefulImage fallback. On the real Frame, exact official read-only backend status calls returned live state, exit-node and device entries when ~/.local/bin was in PATH; a transient user unit found the CLI with the proposed managed-service PATH. Live Framey Decky RPC, connection/exit-node changes and worn VR remain unverified; the bundled localhost QR asset is unavailable. No network settings were changed. | static archive, local browser, fake-Frame, real Frame read-only backend |
|
||||
| 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 | partial | Panel opens using the real Frame current-user/library snapshot. Account setup and empty save-list reads pass through actual Decky 3.3.0 amd64 in isolation. ARM64 backend, supported-save discovery, automatic backups and restore remain unverified; no backup/restore ran. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 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 |
|
||||
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'type'). | static archive, local browser, fake-Frame |
|
||||
| 53: Shotty 0.1.0 | legacy / legacy | broken | Library snapshot fixes the frontend allApps crash. Actual published Decky 3.3.0 amd64 backend fails importing click.get_app_dir (ModuleNotFoundError); the archive does not bundle click. Screenshot aggregation/rescue was not run. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | partial | An inert local component lets the exact frontend initialize and render its Settings view in opaque Chromium. Backend get_setting and plugin network were denied in the audit; Steam game-page theme injection has no verified target in Framey. Music search, playback, downloads, settings persistence and worn VR remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 53: Shotty 0.1.0 | legacy / legacy | partial | The frontend starts with the read-only Frame library snapshot. Frame Control adds a digest-scoped click.get_app_dir compatibility module to the managed install; the exact backend then imports and copies a synthetic Steam screenshot into a temporary Pictures folder in isolated Python. Actual Decky 3.3.0 ARM64 loading, live screenshots/rescue, in-VR panel and physical controller use remain unverified. | static archive, local browser, fake-Frame, real Frame SSH, isolated Python backend |
|
||||
| 56: MangoPeel 0.0.7 | 1 / 2 | partial | Initializer succeeds; gamescope/MangoHud actions and backend are unverified. | static archive, local browser, fake-Frame |
|
||||
| 58: Volume Boost 0.0.7 | legacy / legacy | partial | Real Decky 3.3.0 ARM64 default-sink reads match independent Frame pactl channel volumes; controls render locally. Sliders/numeric writes passed on an isolated amd64 virtual sink only. Real default-sink writes, boost above 100%, audible output and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||
| 59: Web Browser 1.4.3 | legacy / legacy | broken | Frontend audit failed: deckyFrontendLib.getReactRoot is not a function. | static archive, local browser, fake-Frame |
|
||||
| 58: Volume Boost 0.0.7 | legacy / legacy | works | Default-sink controls pass in local Chromium through real Decky 3.3.0 ARM64: linked slider/keyboard and numeric writes, separate channels, re-link and reopen, with independent Pulse readback including 125%/150% while muted. Exact original volume/mute restored. Audible output, Bluetooth sinks and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||
| 59: Web Browser 1.4.3 | legacy / legacy | broken | The exact frontend now initializes with a null Steam React-root lookup. The browser itself still requires Steam GamepadUI CDP injection and a backend WebSocket unavailable in Framey; no working browser tab was verified. | static archive, local browser, fake-Frame, focused local browser |
|
||||
| 60: DeckyFileServer 1.2.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend. | static archive, local browser, fake-Frame |
|
||||
| 61: TabMaster 2.15.1 | 1 / 2 | partial | Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified. | static archive, local browser, fake-Frame |
|
||||
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
|
||||
| 63: Free Loader 1.6.0 | 1 / 2 | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
|
||||
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend |
|
||||
| 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 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. ARM64 backend, live MobX/running-game observations and automatic recording remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 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 |
|
||||
@@ -91,7 +93,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 80: MagicBlack 2.0.2 | 1 / 2 | N/A | Not supported on Frame: Targets a global Steam Deck screen-black overlay; the isolated panel cannot black out the Frame compositor. | static archive, local browser, fake-Frame |
|
||||
| 81: IsThereAnyDeal for Deck 1.1.1 | legacy / legacy | partial | Country settings render and select the real Frame Steam country through the read-only bridge. Settings writes, deal lookups and Steam page integration remain unverified; plugin backend calls were denied. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 82: MagicPods 2.0.21 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: MagicPodsDecky/bin/magicpodscore. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 84: Junk-Store 1.1.13 | legacy / legacy | broken | Local CSS lookup fixes the loading view crash. Actual Decky 3.3.0 amd64 startup/static InitActions return a script execution/JSON error; the published scripts are not executable after extraction. Store install/login/game launch and ARM64 remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 84: Junk-Store 1.1.13 | legacy / legacy | partial | Official Decky 3.3.0 ARM64 static menu/backend calls pass; local Chromium Games/Epic navigation, About/Dependencies tabs, Back and reopen render with real Frame library metadata. Legendary is absent; dependency install, login, game operations, RSS, optional websocket and worn VR remain unverified. Existing installs need update/reinstall for script permissions. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||
| 85: Decky-Undervolt 1.0.8 | 1 / 2 | N/A | Not supported on Frame: RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. | static archive, local browser, fake-Frame |
|
||||
| 87: ScreenSaver 1.1.2 | legacy / legacy | partial | Initializer succeeds; global screen overlay and idle/suspend integration have no verified VR target. | static archive, local browser, fake-Frame |
|
||||
| 88: steamdeck-input-disabler 1.0.2 | legacy / legacy | N/A | Not supported on Frame: Targets the built-in Steam Deck USB gamepad; Frame has different controllers. | static archive, local browser, fake-Frame |
|
||||
@@ -129,7 +131,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 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 |
|
||||
| 124: AlarMe 1.6.3 | 1 / 2 | partial | Silent labeled timer create/ticks/pause/cancel, preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with the official Decky 3.3.0 ARM64 backend. Audio, alarms/reminders, suspend inhibition, Steam overlays and worn VR remain unverified; notifications require the panel to stay open. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 125: Decky Localsend 0.4.5 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core. | static archive, local browser, fake-Frame |
|
||||
| 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
|
||||
| 127: Decky Translator 0.9.0 | 1 / 2 | partial | Initializer succeeds; x86_64 download and hidraw/controller assumptions need an ARM64 audit. OCR/translation not exercised. | static archive, local browser, fake-Frame |
|
||||
@@ -140,7 +142,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 132: SuggestMe 1.5.3 | 1 / 2 | partial | Initializer succeeds; recommendation backend and Steam library data unverified. | static archive, local browser, fake-Frame |
|
||||
| 133: Non-Steam Badges 0.1.0 | 1 / 2 | partial | Initializer succeeds; Non-Steam game badges have no supported Steam page injection target. | static archive, local browser, fake-Frame |
|
||||
| 134: Deck Progress Tracker 1.3.6 | 1 / 2 | partial | Initializer succeeds; Steam game/library statistics and page patches unverified. | static archive, local browser, fake-Frame |
|
||||
| 135: Deck Shelves 2.0.0 | 1 / 1 | partial | The exact chunked frontend now initializes and renders Deck Shelves settings in an opaque local Chromium panel. Backend calls were denied; Home-screen shelf injection has no verified Steam UI target in Framey. Settings writes, real backend behavior and worn VR are unverified. | static archive, local browser, fake-Frame |
|
||||
| 135: Deck Shelves 2.0.0 | 1 / 1 | partial | The exact chunked frontend renders settings in opaque local Chromium. The official Python backend saved a shelf and returned it after reopen with a stub Decky module and temporary settings directory. Actual Decky RPC, real Frame settings, Steam Home shelf injection and worn VR remain unverified. | static archive, local browser, fake-Frame, isolated Python backend |
|
||||
| 136: Decky-Clipper 1.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: Decky-Clipper/bin/gstreamer-1.0/libgstproxy.so. | static archive, local browser, fake-Frame |
|
||||
| 137: Decky Proton Launch 0.9.0 | 1 / 2 | partial | Initializer succeeds; Proton wrapper installation and launch changes were not run. | static archive, local browser, fake-Frame |
|
||||
| 138: Docky 1.4.13 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
@@ -252,7 +254,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
|
||||
| Reroll | Apps.RegisterForGameActionUserRequest, GameSessions.RegisterForAppLifetimeNotifications, Settings.GetCurrentLanguage | ButtonItem, DialogButton, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, SliderField, libraryAssetImageClasses, mainMenuAppRunningClasses, quickAccessMenuClasses, showModal | SteamUIStore, QAM/global tabs, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| DeckPass | Apps.AddShortcut, Apps.RunGame, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Input.ControllerKeyboardSendText | Button, ButtonItem, DialogButton, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, TextField, findSP, showModal, sleep, staticClasses | Router, appDetailsStore, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | flatpak |
|
||||
| Steam Status | none detected | ButtonItem, ConfirmModal, DialogButton, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, showModal, staticClasses | toaster, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| AlarMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SidebarNavigation, SliderField, TextField, ToggleField, findModuleChild, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| AlarMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SidebarNavigation, SliderField, TextField, ToggleField, findModuleChild, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | Timers/stopwatch use Python. Optional sleep inhibition opens /dev/uinput; sound and auto-suspend are unverified. Steam game overlays have no target in the isolated panel. |
|
||||
| Decky Localsend | none detected | ButtonItem, DialogBody, DialogButton, DialogButtonPrimary, DialogFooter, DialogHeader, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, TextField, ToggleField, showModal, staticClasses | Router, routerHook, toaster, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Launch Options | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogButtonPrimary, DialogControlsSection, DialogControlsSectionHeader, DialogFooter, DialogHeader, Dropdown, Field, Focusable, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanel, SidebarNavigation, SteamSpinner, Tabs, TextField, Toggle, ToggleField, afterPatch, createReactTreePatcher, fakeRenderComponent, findInReactTree, findInTree, findModule, findModuleByExport, showModal, staticClasses, useParams | SteamUIStore, routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | flatpak; gamescope |
|
||||
| Decky Translator | User.RegisterForPrepareForSystemSuspendProgress | ButtonItem, DialogButton, Dropdown, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, findModuleChild, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | /dev/hidraw; /sys/class/hidraw/hidraw{i}; /sys/class/hidraw/hidraw{i}/device/uevent; x86_64 |
|
||||
|
||||
+50
-8
@@ -16,6 +16,10 @@ plugins → Open official store**. Search the catalogue, confirm an install, and
|
||||
choose **Open panel** from the installed plugin list. The panel contains the
|
||||
plugin's own controls and settings. Update and Remove affect only plugin
|
||||
folders that Frame Control's store manager installed.
|
||||
Use **Scroll plugin up** and **Scroll plugin down** above an open panel when
|
||||
wheel scrolling is unavailable. Point at a scrollable part of the plugin first
|
||||
to move that section; otherwise the buttons move the panel document. Each
|
||||
press moves about three quarters of the visible section and stops at its edge.
|
||||
Removing a plugin keeps its Decky settings. Removing the Frame Control headset
|
||||
panel or Decky backend keeps plugin folders until each is removed separately.
|
||||
|
||||
@@ -82,7 +86,11 @@ record the resulting TabMaster documentation support and remaining blockers.
|
||||
The host currently supplies sections, rows, buttons, toggles, text fields,
|
||||
sliders, grouped dropdowns,
|
||||
scroll containers, a modal, locale information, local panel routes and toasts,
|
||||
and backend calls for the plugin that owns the panel. Steam library/store page
|
||||
and backend calls for the plugin that owns the panel. The headset's scroll
|
||||
buttons send only a direction to the isolated iframe. They target the last
|
||||
scrollable region reached by a trusted pointer move and fall back to the iframe
|
||||
document when that region disappears. A result message reports whether content
|
||||
moved; this bridge has no backend or Steam privilege. Steam library/store page
|
||||
patches, global Steam UI components, file pickers, and privileged root methods
|
||||
have no VR target. A plugin that depends on them can render only part of its UI
|
||||
or show an incompatibility message. A Decky plugin's Python backend still runs as
|
||||
@@ -115,9 +123,9 @@ Its cleanup actions were not tested; they may remove local game saves.
|
||||
|
||||
Framey also supplies local tabs/sidebar pages, spinners, dialog/menu wrappers
|
||||
and progress bars. Its explicit SteamClient subscription fallbacks return
|
||||
disposable handles without emitting fabricated events. There is no attached
|
||||
running game in `DFL.Router`, and power, app launch and hardware mutation methods
|
||||
are not supplied. Plugins that need live Steam stores still require integration
|
||||
disposable handles without emitting fabricated events. Running-game reads and
|
||||
lifetime notifications use the scoped bridge described below; power, app launch
|
||||
and hardware mutation methods are not supplied. Other live Steam stores require integration
|
||||
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.
|
||||
@@ -125,13 +133,40 @@ 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
|
||||
reject with `Not found`. The suspend store has no methods. Optional Steam app/account globals
|
||||
are undefined until a successful library read. 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.
|
||||
MobX game observers for automatic tracking.
|
||||
|
||||
Panels referencing running-game APIs now receive a detached, read-only
|
||||
`SteamUIStore.RunningApps` / `MainRunningApp` snapshot, also exposed through
|
||||
`DFL.Router`. The authenticated headset-only `/api/framey/running` endpoint
|
||||
checks installed store ownership; the parent checks the active panel session.
|
||||
Poll and close requests also carry the panel's pinned SHA-256 receipt. A missing
|
||||
or changed installed receipt is rejected before any Steam CEF request; a rejected
|
||||
stale close relies on the existing finite subscription lease for cleanup.
|
||||
Fixed bundled code runs in Steam CEF. It copies at most 64 running app overviews, a main app and
|
||||
public Steam CDN capsule URLs; it never copies local artwork paths, launch
|
||||
commands or Steam objects. The host refreshes about every two seconds without
|
||||
overlapping requests. Failure clears stale context and sets
|
||||
`SteamUIStore.FrameyRunningAvailable` to false.
|
||||
|
||||
`GameSessions.RegisterForAppLifetimeNotifications` relays actual Steam
|
||||
`{unAppID, bRunning}` callbacks through a bounded 64-event queue. Snapshot
|
||||
changes never fabricate launch/exit callbacks. Handles support `.unregister()`;
|
||||
panel close/replacement removes the CEF subscription, with a 15-second lease
|
||||
(checked every five seconds) if cleanup cannot reach Steam. At most four panel
|
||||
sessions are retained. These are open-panel observations, not closed-panel
|
||||
automation or a MobX implementation. Queue overflow can lose old callbacks.
|
||||
`appStore` capsule helpers expose only projected public URLs for running apps;
|
||||
unavailable or local custom artwork returns an empty value.
|
||||
|
||||
[Running-context evidence](evidence/framey-running-context-2026-10-26.md) separates
|
||||
real Frame empty-list/subscription/cleanup reads, the exact Discord Status
|
||||
ARM64 backend connection check, and synthetic nonempty/transition fixtures.
|
||||
Real game transitions, Discord activity and worn-VR operation remain unverified.
|
||||
|
||||
Three parameterless SteamClient reads use Steam's actual CEF context:
|
||||
`User.GetIPCountry`, `InstallFolder.GetInstallFolders` and `System.GetSystemInfo`.
|
||||
@@ -182,6 +217,13 @@ frontend cannot call the changing backend. Open panel is unavailable while a
|
||||
store job is pending. A later installed-status refresh also closes an open panel
|
||||
if Decky stopped, its store receipt changed, or the status read failed. Reopen
|
||||
the plugin after a successful refresh to use its current frontend.
|
||||
Chunked frontends load modules through URLs bound to the installed store
|
||||
receipt. If a desktop store update replaces the plugin during panel startup,
|
||||
old module requests fail and the panel must be reopened; Framey does not mix
|
||||
chunks from two installed versions.
|
||||
The headset also includes the panel's store SHA-256 receipt with each backend
|
||||
call, Steam read, and event poll. It rejects a request if the installed receipt
|
||||
changed before the next status refresh; reopen the panel to use the new version.
|
||||
|
||||
## For plugin authors
|
||||
|
||||
|
||||
@@ -59,6 +59,10 @@ pointer over it, then attempts OpenVR smooth and discrete scroll events. It
|
||||
reports region geometry, trusted pointer events, wheel events and `scrollTop`.
|
||||
No wheel event or offset change means this synthetic injection path remains
|
||||
unverified; it does not establish whether a physical controller can scroll.
|
||||
The exercise then clicks the visible **Scroll plugin down/up** buttons through
|
||||
the same native OpenVR pointer transport and requires the test region to move
|
||||
down and return to the top. This proves the button path only; it does not
|
||||
identify a physical controller or repair native wheel delivery.
|
||||
Temporary controls exercise the shipped
|
||||
`DFL.SliderField` and `DFL.TextField`; their values remain UI-only. Text uses
|
||||
CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input,
|
||||
@@ -108,8 +112,10 @@ the same-plugin navigation regression without a headset.
|
||||
interaction window, use the physical controller to open the sample and
|
||||
toggle **Example setting**. Confirm the laser aims where expected and the
|
||||
script records a fresh input/panel event and visible stereo output. The flag
|
||||
is the operator’s statement; sensor activity and trusted pointers can also
|
||||
be produced synthetically and cannot identify controller hardware.
|
||||
is the operator’s statement. The script requires valid locked SteamVR activity
|
||||
and proximity readings before input; proximity alone cannot establish wear.
|
||||
Sensor activity and trusted pointers can also be produced synthetically and
|
||||
cannot identify controller hardware.
|
||||
3. **1:15–2:15:** Use the controller to open Settings, return to Panel, invoke
|
||||
**Read saved setting**, close/reopen the plugin, and confirm the value
|
||||
remains. Try controller scroll, a slider and a harmless text field in an
|
||||
|
||||
+16
-12
@@ -176,19 +176,21 @@ switch while SSH is down:
|
||||
|
||||
## Headset smoke test
|
||||
|
||||
**Documented shared-device procedure:** before a test installs, launches or
|
||||
stops an application, acquire `ssh frame 'mkdir /tmp/frame-test.lock'`. If it
|
||||
fails, leave that lock alone and continue offline work. Only the thread that
|
||||
acquired it releases it with `ssh frame 'rmdir /tmp/frame-test.lock'`, after
|
||||
cleanup. Keep each device session to a few minutes.
|
||||
**Documented shared-device procedure:** before any headset read or a test that
|
||||
installs, launches or stops an application, atomically create
|
||||
`/tmp/frame-test.lock` on the Frame and write a unique token to its `owner`
|
||||
file. If creation fails, leave that lock alone and continue offline work. Only
|
||||
the owner that verifies its token may remove its `owner` file and lock directory
|
||||
after cleanup. Keep each device session to a few minutes.
|
||||
|
||||
Check battery capacity and charging state under `/sys/class/power_supply`
|
||||
before and after; keep capacity above 20%. Stop only processes started by the
|
||||
test, remove temporary installs and profiles, and restore the prior dashboard
|
||||
state. Leave Steam and SteamVR running. Do not reboot or change global settings.
|
||||
Record the build, actual interaction results, cleanup and any unworn-headset
|
||||
limits alongside screenshots or logs. These are caller responsibilities; the
|
||||
smoke script below does not acquire this shared lock itself.
|
||||
limits alongside screenshots or logs. The smoke script below acquires and
|
||||
releases its own tokenized shared lock; other device tests follow this
|
||||
procedure themselves.
|
||||
|
||||
[Packaged Desktop Capture evidence, 2026-10-24](evidence/desktop-capture-packaged-2026-10-24.md)
|
||||
records v0.4.17 GUI button captures of a real mapped nested desktop on SteamOS
|
||||
@@ -220,17 +222,19 @@ removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||
an x86-64 `.exe` that exits at once). A launch passes only with fresh evidence:
|
||||
the ARM64 program running, the `.exe` started (its process or Steam's log),
|
||||
and the x86-64 program running or Steam logging that its runtime isn't
|
||||
installed, which is what the Frame does today. Steam's log lines about each
|
||||
title are kept.
|
||||
and the x86-64 program running. If Steam logs that the optional x86-64 runtime
|
||||
is not installed, that launch is **skipped**, with the reason and log saved in
|
||||
the result. It does not prove that the x86-64 title ran. Steam's log lines about
|
||||
each title are kept.
|
||||
|
||||
Everything it installs is removed again, also after a failure: the titles and
|
||||
their Steam shortcuts, a paired key, and `~/devkit-utils` if it wasn't there
|
||||
before (if it was, it stays, synced to this checkout as Frame Control always
|
||||
does). A cleanup that fails counts as a failed step. Results go to
|
||||
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
|
||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||
isn't reachable.
|
||||
screen. The summary counts passed, skipped and failed steps separately. It
|
||||
exits 0 when no step failed (including a skipped optional runtime), 1 if a step
|
||||
failed, or 2 if the headset isn't reachable.
|
||||
|
||||
Smoke process cleanup runs before and after title removal and during the
|
||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||
|
||||
@@ -11,6 +11,7 @@ import io
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
@@ -27,8 +28,45 @@ for(const key of ['error','warn']){const original=console[key];console[key]=(...
|
||||
report(key,args.map(String).join(' '));original(...args);};}
|
||||
window.fetch=()=>Promise.reject(Error('Audit blocks plugin network'));
|
||||
window.WebSocket=class{constructor(){throw Error('Audit blocks WebSocket');}};
|
||||
addEventListener('message', event=>{
|
||||
if(event.source!==parent||event.data?.type!=='audit:inspect')return;
|
||||
parent.postMessage({type:'audit:snapshot',text:(document.getElementById('framey-root')?.innerText||'').slice(0,2000)},'*');
|
||||
});
|
||||
</script>'''
|
||||
|
||||
|
||||
def render_panel(record, data):
|
||||
if hashlib.sha256(data).hexdigest() != record['sha256']:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
bundle = archive.read(record['folder']+'/dist/index.js')
|
||||
# The audit supplies only digest-checked archive bytes, not an installed
|
||||
# receipt. Stub the receipt-bound lookup while using the shipped renderer.
|
||||
with patch.object(framey_panel, '_owned_dist', return_value=(None, bundle, record['sha256'])):
|
||||
return framey_panel.panel(record['id']).replace(
|
||||
b'<script id="framey-bundle"', PRELUDE.encode()+b'<script id="framey-bundle"')
|
||||
|
||||
|
||||
def read_module_asset(path, records, cache):
|
||||
"""Serve the same receipt-bound module URL shape as the installed panel."""
|
||||
prefix = '/framey/module/'
|
||||
if not path.startswith(prefix):
|
||||
raise ValueError('Invalid module path')
|
||||
parts = path[len(prefix):].split('/', 2)
|
||||
if len(parts) != 3 or not re.fullmatch(r'[1-9][0-9]*', parts[0]):
|
||||
raise ValueError('Invalid plugin ID')
|
||||
plugin_id, receipt, name = parts
|
||||
if not framey_panel.RECEIPT.fullmatch(receipt) or not framey_panel.MODULE_PATH.fullmatch(name):
|
||||
raise ValueError('Invalid module receipt or path')
|
||||
record = records[int(plugin_id)]
|
||||
if receipt != record['sha256']:
|
||||
raise ValueError('Plugin receipt changed')
|
||||
data = (cache/(receipt+'.zip')).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != receipt:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
return archive.read(record['folder']+'/dist/'+name)
|
||||
|
||||
PAGE = '''<!doctype html><meta charset=utf-8><title>Framey catalog frontend audit</title>
|
||||
<h1>Framey catalog frontend audit</h1>
|
||||
<p>Exact archived frontend code runs in an opaque iframe. All backend calls and network requests are denied.
|
||||
@@ -36,15 +74,18 @@ Ready means initializer completion, not useful plugin behavior. No plugin backen
|
||||
<button id=run>Run catalog</button> <button id=download>Download observations</button><p id=progress></p>
|
||||
<iframe id=panel sandbox="allow-scripts" style="width:1100px;height:740px"></iframe>
|
||||
<script>
|
||||
const plugins=CATALOG_DATA, librarySnapshot=LIBRARY_DATA;
|
||||
const plugins=CATALOG_DATA, librarySnapshot=LIBRARY_DATA, runningSnapshot=RUNNING_DATA;
|
||||
window.results=[];let active=null,running=false;
|
||||
addEventListener('message',event=>{
|
||||
if(event.source!==panel.contentWindow||!active)return;
|
||||
const data=event.data;
|
||||
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==='audit:snapshot')active.rootText=String(data.text).slice(0,2000);
|
||||
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:running')event.source.postMessage({type:'framey:reply',id:data.id,
|
||||
...(runningSnapshot?{result:runningSnapshot}:{error:'Read-only compatibility audit: live running context blocked'})},'*');
|
||||
if(data.type==='framey:steam')event.source.postMessage({type:'framey:reply',id:data.id,
|
||||
...(data.method==='GetLibrarySnapshot'&&librarySnapshot?
|
||||
{result:librarySnapshot}:{error:'Read-only compatibility audit: live Steam API blocked'})},'*');
|
||||
@@ -53,13 +94,19 @@ addEventListener('message',event=>{
|
||||
event.source.postMessage({type:'framey:reply',id:data.id,error:'Read-only compatibility audit: backend method blocked'},'*');}
|
||||
if(data.type==='framey:cache:init')event.source.postMessage({type:'framey:cache:seed',session:data.session,entries:{}},'*');
|
||||
});
|
||||
window.runAudit=async(ids)=>{
|
||||
window.runAudit=async(ids,settleMs=1200)=>{
|
||||
if(!Number.isInteger(settleMs)||settleMs<1200||settleMs>10000)throw Error('Invalid audit settle time');
|
||||
if(running)throw Error('Audit already running');running=true;run.disabled=true;
|
||||
try{for(const plugin of plugins.filter(plugin=>!ids||ids.includes(plugin.id))){
|
||||
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],
|
||||
library:librarySnapshot?'snapshot replay':'unavailable'};
|
||||
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],rootText:'',
|
||||
library:librarySnapshot?'snapshot replay':'unavailable',running:runningSnapshot?'snapshot replay':'unavailable'};
|
||||
progress.textContent=plugin.id+' '+plugin.name;panel.src='/panel/'+plugin.id;
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));results.push(active);active=null;
|
||||
await new Promise(resolve=>setTimeout(resolve,settleMs));
|
||||
// Detached Steam snapshots may contain personal library data. Keep text
|
||||
// observations out of reports that replay either snapshot.
|
||||
if(!librarySnapshot&&!runningSnapshot)panel.contentWindow.postMessage({type:'audit:inspect'},'*');
|
||||
await new Promise(resolve=>setTimeout(resolve,80));
|
||||
results.push(active);active=null;
|
||||
panel.src='about:blank';await new Promise(resolve=>setTimeout(resolve,30));
|
||||
}}finally{running=false;run.disabled=false;progress.textContent='Done: '+results.length;}
|
||||
return results;
|
||||
@@ -77,6 +124,8 @@ def main():
|
||||
parser.add_argument('--port', type=int, default=18765)
|
||||
parser.add_argument('--library-snapshot', type=Path,
|
||||
help='Optional detached Steam library JSON; replay only, not live Steam/backend evidence')
|
||||
parser.add_argument('--running-snapshot', type=Path,
|
||||
help='Optional detached running-app JSON; replay only, not live Steam/backend evidence')
|
||||
args = parser.parse_args()
|
||||
plugins = json.loads(args.catalog.read_text())['plugins']
|
||||
records = {p['id']: p for p in plugins}
|
||||
@@ -86,6 +135,12 @@ def main():
|
||||
if len(raw) > 2*1024*1024:
|
||||
parser.error('library snapshot exceeds 2 MiB')
|
||||
library = json.loads(raw)
|
||||
running = None
|
||||
if args.running_snapshot:
|
||||
raw = args.running_snapshot.read_bytes()
|
||||
if len(raw) > 256*1024:
|
||||
parser.error('running snapshot exceeds 256 KiB')
|
||||
running = json.loads(raw)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
@@ -99,32 +154,19 @@ def main():
|
||||
# Only names/IDs are needed by the UI; never embed raw executable source.
|
||||
catalog = json.dumps([{'id': p['id'], 'name': p['name']} for p in plugins]).replace('<', '\\u003c')
|
||||
body = PAGE.replace('CATALOG_DATA', catalog).replace(
|
||||
'LIBRARY_DATA', json.dumps(library).replace('<', '\\u003c')).encode()
|
||||
'LIBRARY_DATA', json.dumps(library).replace('<', '\\u003c')).replace(
|
||||
'RUNNING_DATA', json.dumps(running).replace('<', '\\u003c')).encode()
|
||||
elif path.startswith('/panel/'):
|
||||
try:
|
||||
record = records[int(path.split('/')[-1])]
|
||||
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != record['sha256']:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
bundle = archive.read(record['folder']+'/dist/index.js')
|
||||
with patch.object(framey_panel, 'local_bundle', return_value=bundle):
|
||||
body = framey_panel.panel(record['id']).replace(
|
||||
b'<script id="framey-bundle"', PRELUDE.encode()+b'<script id="framey-bundle"')
|
||||
body = render_panel(record, data)
|
||||
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
|
||||
self.send_error(404, 'Verified frontend archive unavailable')
|
||||
return
|
||||
elif path.startswith('/framey/module/'):
|
||||
try:
|
||||
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
|
||||
if not framey_panel.MODULE_PATH.fullmatch(name):
|
||||
raise ValueError('Invalid module path')
|
||||
record = records[int(plugin_id)]
|
||||
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != record['sha256']:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
body = archive.read(record['folder']+'/dist/'+name)
|
||||
body = read_module_asset(path, records, args.cache)
|
||||
content_type = 'text/javascript; charset=utf-8'
|
||||
module_asset = True
|
||||
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
|
||||
|
||||
@@ -40,6 +40,7 @@ def render(data):
|
||||
'python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives',
|
||||
'```', '',
|
||||
'Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.', '',
|
||||
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.', '',
|
||||
'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. '
|
||||
@@ -54,7 +55,8 @@ def render(data):
|
||||
'A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer '
|
||||
'through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, '
|
||||
'independent server readbacks and Mixer filter persistence across a runtime restart passed. '
|
||||
'An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. Volume Boost stays partial: default-sink reads matched, but real sink writes were denied. Audible output, Bluetooth playback and worn VR remain unverified. '
|
||||
'An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. '
|
||||
'A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. '
|
||||
'A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs '
|
||||
'and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot '
|
||||
'to render Deck Settings game selection, Steamback\'s empty save panel and PlayTime statistics/settings. '
|
||||
@@ -63,7 +65,7 @@ def render(data):
|
||||
'A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 '
|
||||
'through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. '
|
||||
'Its backend was denied; Steam Home shelves and worn VR remain unverified. '
|
||||
'A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. Junk-Store loading/error views render, but its real backend cannot execute the extracted startup script, so it stays broken. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. TabMaster/Junk-Store ran in actual isolated amd64 Decky; no ARM64 or worn-VR result is claimed. Beyond the documented checks, official backends remain unexecuted. '
|
||||
'A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional websocket and worn VR are unverified. Beyond the documented checks, 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 '
|
||||
@@ -71,6 +73,10 @@ def render(data):
|
||||
'A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, '
|
||||
'install folders and system information, plus IsThereAnyDeal\'s country dropdown in local Chromium. '
|
||||
'The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. '
|
||||
'A [Web Browser frontend follow-up](evidence/framey-web-browser-2026-10-26.md) verified its exact archive now initializes with an inert null Steam React-root lookup. Its GamepadUI CDP/browser backend path remains unavailable, so it stays broken. '
|
||||
'A [dialog and activation follow-up](evidence/framey-dialog-controls-2026-10-26.md) fixes DFL Focusable pointer/keyboard activation and confirmation descriptions, cancel/disabled actions, scoped close callbacks and nested dialogs. '
|
||||
'AlarMe silent timer ticks/pause/cancel, private preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with its official Decky 3.3.0 ARM64 backend. '
|
||||
'AlarMe stays partial: audio, alarms/reminders, sleep inhibition, Steam overlays and worn VR are unverified; notifications require the panel to remain open. '
|
||||
'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 '
|
||||
|
||||
@@ -90,6 +90,15 @@ def settings(state):
|
||||
return pause, timeout
|
||||
|
||||
|
||||
def steamvr_activity(rows):
|
||||
"""Read the unique status row; metrics can precede it in --stats output."""
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
levels = [row.get('activity_level') for row in rows
|
||||
if isinstance(row, dict) and row.get('operation') == 'status']
|
||||
return levels[0] if len(levels) == 1 and type(levels[0]) is int else None
|
||||
|
||||
|
||||
def diagnostic_source():
|
||||
return (ROOT / 'scripts/framey_openvr.py').read_text() + '''
|
||||
import json
|
||||
@@ -183,9 +192,7 @@ def main(argv=None):
|
||||
if not before['battery'] or min(before['battery']) < 20:
|
||||
raise RuntimeError('Battery unknown or below 20%')
|
||||
if args.wake or args.exercise:
|
||||
stats = before.get('stats')
|
||||
if (not isinstance(stats, list) or not stats or
|
||||
not isinstance(stats[0], dict) or stats[0].get('activity_level') != 3):
|
||||
if steamvr_activity(before.get('stats')) != 3:
|
||||
raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched')
|
||||
if args.wake or args.exercise:
|
||||
# Independent systemd timer restores power even if the controller is
|
||||
@@ -210,7 +217,7 @@ def main(argv=None):
|
||||
files = {name: base64.b64encode((ROOT/'examples/framey-settings'/name).read_bytes()).decode()
|
||||
for name in ('plugin.json', 'main.py', 'dist/index.js')}
|
||||
source = '''
|
||||
import base64,json,pathlib
|
||||
import base64,hashlib,json,pathlib
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky'
|
||||
folder=%r
|
||||
p=base/'plugins'/folder
|
||||
@@ -219,7 +226,10 @@ p.mkdir(parents=True); (p/'dist').mkdir()
|
||||
for name,data in %r.items(): (p/name).write_bytes(base64.b64decode(data))
|
||||
manifest=json.loads((p/'plugin.json').read_text());manifest['name']='Framey VR Check '+folder
|
||||
(p/'plugin.json').write_text(json.dumps(manifest))
|
||||
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check'}))
|
||||
digest=hashlib.sha256()
|
||||
for name in ('plugin.json','main.py','dist/index.js'):
|
||||
digest.update(name.encode()+b'\\0'+(p/name).read_bytes())
|
||||
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check','sha256':digest.hexdigest()}))
|
||||
print(json.dumps({'created':True}))
|
||||
''' % (folder, files)
|
||||
remote.python(source)
|
||||
|
||||
@@ -36,16 +36,25 @@ def remote(command, stdin=None, timeout=15):
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import json, pathlib, subprocess
|
||||
import json, math, pathlib, subprocess
|
||||
power=[]
|
||||
for item in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
|
||||
try:
|
||||
if (item.parent/'type').read_text().strip() == 'Battery':
|
||||
power.append({'name':item.parent.name,'percent':int(item.read_text().strip())})
|
||||
except (OSError,ValueError): pass
|
||||
proximity=[]
|
||||
for item in pathlib.Path('/sys/bus/iio/devices').glob('iio:device*/in_proximity_raw'):
|
||||
try:
|
||||
value=float(item.read_text().strip())
|
||||
if math.isfinite(value) and value >= 0: proximity.append(value)
|
||||
except (OSError,ValueError): pass
|
||||
stats=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--stats'],capture_output=True,text=True,timeout=5)
|
||||
try: activity=json.loads(stats.stdout)[0]['activity_level'] if stats.returncode == 0 else None
|
||||
except (ValueError,KeyError,IndexError,TypeError): activity=None
|
||||
try:
|
||||
rows=json.loads(stats.stdout) if stats.returncode == 0 else None
|
||||
levels=[row.get('activity_level') for row in rows if isinstance(row,dict) and row.get('operation')=='status'] if isinstance(rows,list) else []
|
||||
activity=levels[0] if len(levels)==1 and type(levels[0]) is int else None
|
||||
except (ValueError,TypeError): activity=None
|
||||
panels=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--overlays'],capture_output=True,text=True,timeout=5)
|
||||
panel=next((line.strip() for line in panels.stdout.splitlines() if 'valve.steam.desktopgame.2001947813' in line),None)
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
@@ -59,7 +68,7 @@ if base.is_dir() and not base.is_symlink():
|
||||
if isinstance(p.get('storeId'),int): plugins.append({'id':p['storeId'],'name':manifest['name']})
|
||||
except (OSError,ValueError,KeyError): pass
|
||||
service=subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],capture_output=True,text=True)
|
||||
print(json.dumps({'battery':power,'activity':activity,'panel':panel,
|
||||
print(json.dumps({'battery':power,'proximity':proximity,'activity':activity,'panel':panel,
|
||||
'panelService':service.returncode==0,'plugins':plugins}))
|
||||
'''
|
||||
|
||||
@@ -117,11 +126,15 @@ def main(worn_confirmed=False):
|
||||
raise RuntimeError('Battery capacity unavailable or below 20%; no device session started')
|
||||
if before['activity'] is None or before['activity'] >= 3:
|
||||
raise RuntimeError('SteamVR is in standby; no worn-headset check started')
|
||||
if not before.get('proximity'):
|
||||
raise RuntimeError('Proximity reading unavailable; no worn-headset check started')
|
||||
if not before['panelService'] or not before['plugins']:
|
||||
raise RuntimeError('Install and open the Frame Control panel and a Framey plugin before this check')
|
||||
after_lock = probe()
|
||||
if after_lock['activity'] >= 3 or min(x['percent'] for x in after_lock['battery']) < 20:
|
||||
raise RuntimeError('Activity or battery changed after acquiring the lock')
|
||||
if (not isinstance(after_lock.get('activity'), int) or after_lock['activity'] >= 3 or
|
||||
not after_lock.get('proximity') or not after_lock.get('battery') or
|
||||
min(x['percent'] for x in after_lock['battery']) < 20):
|
||||
raise RuntimeError('Activity, proximity or battery changed after acquiring the lock')
|
||||
state = json.dumps({'nonce':token,'started':time.time()})
|
||||
code = "import pathlib; p=pathlib.Path.home()/'.local/share/frame-control/headset/worn-check.json'; p.write_text(" + repr(state) + ")"
|
||||
remote('python3 -c ' + shlex.quote(code))
|
||||
|
||||
@@ -71,10 +71,26 @@ def exercise(profile, plugin_id, folder):
|
||||
if not hit:
|
||||
raise RuntimeError('Native click hit another element: ' + str(panel.eval('({tag:__vrLastTarget?.tagName,text:__vrLastTarget?.textContent?.slice(0,100)})')))
|
||||
|
||||
def click_parent(selector):
|
||||
parent.eval(f'document.querySelector({json.dumps(selector)}).scrollIntoView({{block:"center"}})')
|
||||
time.sleep(.3)
|
||||
pos = parent.eval(f'''(()=>{{let r=document.querySelector({json.dumps(selector)}).getBoundingClientRect();
|
||||
return {{x:r.x+r.width/2,y:r.y+r.height/2,w:innerWidth,h:innerHeight}}}})()''')
|
||||
sx, sy = vr.snapshot()['input']['scale']
|
||||
x, y = pos['x'] * sx / pos['w'], sy - pos['y'] * sy / pos['h']
|
||||
count = parent.eval('window.__vrParentClickCount')
|
||||
vr.post_mouse(300, x, y)
|
||||
vr.post_mouse(301, x, y, 1)
|
||||
vr.post_mouse(302, x, y, 1)
|
||||
wait(lambda:parent.eval('window.__vrParentClickCount'), lambda n:n > count, seconds=3)
|
||||
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
|
||||
raise RuntimeError('Native click missed parent control ' + selector)
|
||||
|
||||
try:
|
||||
parent.eval(f'(async()=>{{await refreshDecky();openFrameyPanel({plugin_id},"Framey VR Check");return true}})()')
|
||||
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
|
||||
panel = connect_panel()
|
||||
parent.eval('window.__vrParentClickCount=0;addEventListener("pointerup",e=>{window.__vrParentClickCount++;window.__vrParentLastTarget=e.target;});true')
|
||||
initial = wait(text, lambda s:'Saved setting: off' in s or 'Could not read setting:' in s)
|
||||
if 'Could not read setting:' in initial: raise RuntimeError(initial)
|
||||
result['initialText'] = initial
|
||||
@@ -149,6 +165,13 @@ def exercise(profile, plugin_id, folder):
|
||||
result['scrollAfter'] = result['scrollAttempts'][-1]['offset']
|
||||
result['scrollEvents'] = panel.eval('__vrWheelEvents')
|
||||
result['nativeScrollMoved'] = result['scrollAfter'] != result['scrollBefore']
|
||||
click_parent('#frameyScrollDown')
|
||||
result['buttonScrollAfterDown'] = wait(
|
||||
lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n > 0)
|
||||
click_parent('#frameyScrollUp')
|
||||
result['buttonScrollAfterUp'] = wait(
|
||||
lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n == 0)
|
||||
result['buttonScrollMoved'] = result['buttonScrollAfterDown'] > 0 and result['buttonScrollAfterUp'] == 0
|
||||
panel.eval('document.getElementById("vr-scroll-probe").remove()')
|
||||
panel.eval('document.getElementById("vr-controls").remove()')
|
||||
# Scroll saved status into the visible iframe for compositor evidence.
|
||||
@@ -156,7 +179,7 @@ def exercise(profile, plugin_id, folder):
|
||||
result['finalOverlay'] = vr.snapshot()
|
||||
result['passed'] = (result['trustedOverlayPointer'] and result['diskSetting'] == {'enabled':True}
|
||||
and result['reopenedChecked'] and result['sliderValue'] != '10'
|
||||
and result['textValue'] == 'framey-ci')
|
||||
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved'])
|
||||
return result
|
||||
except Exception as exc:
|
||||
result['error'] = str(exc)
|
||||
|
||||
@@ -112,25 +112,28 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
|
||||
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
|
||||
receipt = HASHES[0]
|
||||
def framey_call(body):
|
||||
return ok('POST', '/api/framey/method', dict(body, receipt=receipt))
|
||||
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'get_setting','args':[]},
|
||||
headers={'X-Frame-UI':''})
|
||||
self.assertEqual(status, 403)
|
||||
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'x;alert(1)','args':[]})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
|
||||
self.assertTrue(ok('POST', '/api/framey/method',
|
||||
self.assertTrue(framey_call(
|
||||
{'id':999,'method':'set_setting','args':['theme','light']})['result'])
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}],
|
||||
'legacy':True})['result'], {'success':True,'result':'light'})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}],
|
||||
'legacy':True})['result'], {'success':True,'result':True})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved')
|
||||
|
||||
StoreFixture.release = 1
|
||||
@@ -138,7 +141,12 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
{'action':'update','id':999,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
status, stale_reply, _ = api('POST', '/api/framey/method',
|
||||
{'id':999,'receipt':receipt,'method':'get_setting','args':['theme','dark']})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn('changed', str(stale_reply))
|
||||
receipt = HASHES[1]
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':999,'acknowledged':True}))
|
||||
@@ -151,7 +159,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'install','id':999,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
stale = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':999,'sha256':HASHES[0],
|
||||
@@ -184,6 +192,10 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertEqual(
|
||||
ssh(f'sha256sum {installed}/dist/{chunk}').split()[0],
|
||||
hashlib.sha256(expected_chunk).hexdigest())
|
||||
if plugin['id'] == 53:
|
||||
self.assertEqual(ssh(f'test -f {installed}/py_modules/click.py && echo yes').strip(), 'yes')
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['shotty-click-app-dir'])
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':plugin['id'],'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
|
||||
@@ -10,8 +10,13 @@ ARG BASE=archlinux:base
|
||||
FROM ${BASE}
|
||||
|
||||
RUN (pacman-key --init && pacman-key --populate) >/dev/null 2>&1; \
|
||||
pacman -Syu --noconfirm --needed openssh rsync python nodejs curl iproute2 procps-ng util-linux \
|
||||
&& pacman -Scc --noconfirm
|
||||
attempts=0; \
|
||||
until pacman -Syu --noconfirm --needed openssh rsync python nodejs curl iproute2 procps-ng util-linux; do \
|
||||
attempts=$((attempts + 1)); \
|
||||
[ "$attempts" -lt 5 ] || exit 1; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
# The Frame's user is steamos (docs/ssh.md). Its password stands in for the
|
||||
# Developer Mode password.
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -17,6 +17,10 @@ PLUGINS = (
|
||||
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
|
||||
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
|
||||
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
|
||||
{'id': 53, 'name': 'Shotty', 'version': '0.1.0', 'api_version': 'legacy',
|
||||
'folder': 'Shotty', 'sha256': 'ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe'},
|
||||
{'id': 63, 'name': 'Free Loader', 'version': '1.6.0', 'api_version': 1,
|
||||
'folder': 'free-loader', 'sha256': '05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a'},
|
||||
{'id': 135, 'name': 'Deck Shelves', 'version': '2.0.0', 'api_version': 1,
|
||||
'folder': 'Deck-Shelves', 'sha256': 'b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91'},
|
||||
)
|
||||
|
||||
@@ -3,10 +3,11 @@ import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI; window.__library=setLibrarySnapshot;\n})();');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI; window.__library=setLibrarySnapshot; window.__running=setRunningSnapshot; window.__refreshRunning=refreshRunning;\n})();');
|
||||
assert.notEqual(testSource,source);
|
||||
const states=[];
|
||||
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
cloneElement:(element,props)=>({...element,props:{...element.props,...props}}),
|
||||
useState:value=>[value,next=>states.push(next)], useRef:value=>({current:value}),
|
||||
useEffect(){},isValidElement:value=>!!value?.type};
|
||||
class Element {constructor(){this.children=[];this.textContent='';this.attributes={};}
|
||||
@@ -21,9 +22,17 @@ 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,DOMException,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
navigator:{language:'en'}, parent,DOMException,getComputedStyle:node=>({overflowY:node.overflowY||'visible'}),
|
||||
addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(testSource,context);
|
||||
// No Steam React tree exists inside the isolated Framey iframe.
|
||||
assert.equal(window.DFL.getReactRoot({}),null);
|
||||
assert.equal(window.DFL.findInReactTree(window.DFL.getReactRoot(null),()=>true),null);
|
||||
const inertSteamComponent=window.DFL.fakeRenderComponent(undefined).type;
|
||||
assert.equal(inertSteamComponent.name,'MissingSteamComponent');
|
||||
assert.equal(window.DFL.fakeRenderComponent(undefined).type,inertSteamComponent);
|
||||
assert.equal(typeof window.DFL.afterPatch(inertSteamComponent.prototype,'render',()=>{}).unpatch,'function');
|
||||
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
|
||||
const jsx=window.SP_JSX.jsx('p',{id:'message',children:'Saved'},'message-key');
|
||||
assert.equal(jsx.type,'p');
|
||||
@@ -254,6 +263,7 @@ 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.equal(window.SteamUIStore.FrameyRunningAvailable,false);
|
||||
assert.ok(Object.isFrozen(window.SteamUIStore));
|
||||
assert.ok(Object.isFrozen(window.SuspendResumeStore));
|
||||
assert.equal(window.SuspendResumeStore.InitiateSleep,undefined);
|
||||
@@ -261,6 +271,17 @@ for(const name of ['App','appStore','appInfoStore','collectionStore'])assert.equ
|
||||
const snapshot={apps:[[10,'Game Ten','ten',1,'10',180,60,1234],
|
||||
[20,'Shortcut','shortcut',1073741824,'85899345920',0,0,0]],gameIds:[10]};
|
||||
window.__library(snapshot);
|
||||
assert.equal(window.appStore.GetAppOverviewByAppID(10).local_per_client_data,undefined);
|
||||
snapshot.apps[0].push(true);snapshot.apps[1].push(null);
|
||||
window.__library(snapshot);
|
||||
assert.equal(window.appStore.GetAppOverviewByAppID(10).local_per_client_data.installed,true);
|
||||
assert.deepEqual(Object.keys(window.appStore.GetAppOverviewByAppID(10).local_per_client_data),['installed']);
|
||||
assert.equal(window.appStore.GetAppOverviewByAppID(20).local_per_client_data.installed,undefined);
|
||||
window.appStore.GetAppOverviewByAppID(10).local_per_client_data.installed=false;
|
||||
assert.equal(snapshot.apps[0][8],true); // Detached from the wire data.
|
||||
window.__library({...snapshot,apps:[snapshot.apps[0].slice(0,8).concat(false),snapshot.apps[1]]});
|
||||
assert.equal(window.appStore.GetAppOverviewByAppID(10).local_per_client_data.installed,false);
|
||||
window.__library(snapshot);
|
||||
assert.equal(window.appStore.allApps.length,2);
|
||||
assert.equal(window.appStore.GetAppOverviewByAppID('10').display_name,'Game Ten');
|
||||
assert.equal(window.appStore.GetAppOverviewByGameID('85899345920').appid,20);
|
||||
@@ -279,10 +300,52 @@ for(const invalid of [null,{apps:[],gameIds:[10]},
|
||||
{...snapshot,apps:[snapshot.apps[0],snapshot.apps[0]]},
|
||||
{...snapshot,apps:[[10,'Name','sort',1,'undefined',0,0,0]]},
|
||||
{...snapshot,apps:[[10,'Name','sort',1,'10',-1,0,0]]},
|
||||
...['true',1,{},undefined].map(installed=>({...snapshot,apps:[[10,'Name','sort',1,'10',0,0,0,installed]]})),
|
||||
{apps:Array(10001).fill(snapshot.apps[0]),gameIds:[]}]){
|
||||
assert.throws(()=>window.__library(invalid),/Invalid Steam/);
|
||||
assert.equal(window.appStore.allApps[0].display_name,'Plugin-local patch');
|
||||
}
|
||||
const running={artwork:[[10,'https://shared.steamstatic.com/steam/apps/10/library.jpg',[]]],apps:[snapshot.apps[0].slice(0,8)],runningIds:[10],mainAppId:10,events:[],cursor:0,lifetimeAvailable:true};
|
||||
const lifetimes=[];
|
||||
const lifetimeHandle=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(event=>{
|
||||
lifetimes.push({...event,running:Array.from(window.DFL.Router.RunningApps,app=>app.appid)});
|
||||
});
|
||||
window.__running(running);await Promise.resolve();
|
||||
assert.equal(lifetimes.length,0); // Initial snapshots are not launch events.
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,true);
|
||||
assert.equal(window.DFL.Router.MainRunningApp.display_name,'Game Ten');
|
||||
assert.equal(window.appStore.GetAppOverviewByAppID(10).local_per_client_data.installed,true);
|
||||
assert.equal(window.appStore.GetVerticalCapsuleURLForApp({appid:10}),'https://shared.steamstatic.com/steam/apps/10/library.jpg');
|
||||
assert.equal(window.appStore.GetCustomVerticalCapsuleURLs({appid:10}).length,0);
|
||||
assert.equal(window.DFL.Router.RunningApps,window.SteamUIStore.RunningApps);
|
||||
window.DFL.Router.MainRunningApp.display_name='Detached';
|
||||
assert.equal(running.apps[0][1],'Game Ten');
|
||||
const launch={...running,events:[{sequence:1,unAppID:10,bRunning:true}],cursor:1};
|
||||
window.__running(launch);await Promise.resolve();
|
||||
assert.equal(lifetimes.length,1);assert.deepEqual(lifetimes[0].running,[10]);
|
||||
window.__running(launch);await Promise.resolve();assert.equal(lifetimes.length,1);
|
||||
for(const invalid of [null,{...running,runningIds:[99]}, {...running,mainAppId:99},
|
||||
{...running,runningIds:[10,10]}, {...running,artwork:[[10,'file:///private',[]]]}, {...running,cursor:-1},
|
||||
{...running,events:[{sequence:1,unAppID:10,bRunning:'yes'}],cursor:1}]){
|
||||
assert.throws(()=>window.__running(invalid),/Invalid Steam/);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,1);
|
||||
}
|
||||
const exited={...running,artwork:[],apps:[],runningIds:[],mainAppId:null,
|
||||
events:[{sequence:2,unAppID:10,bRunning:false}],cursor:2};
|
||||
window.__running(exited);await Promise.resolve();
|
||||
assert.deepEqual(lifetimes[1].running,[]);assert.equal(lifetimes[1].bRunning,false);
|
||||
lifetimeHandle.unregister();lifetimeHandle();
|
||||
window.__running({...launch,cursor:3,events:[{sequence:3,unAppID:10,bRunning:true}]});
|
||||
await Promise.resolve();assert.equal(lifetimes.length,2);
|
||||
// A failed live read clears stale running context without inventing an exit.
|
||||
const refresh=window.__refreshRunning();
|
||||
const runningRequest=messages.at(-1);assert.equal(runningRequest.type,'framey:running');
|
||||
listeners.find(l=>l.type==='message').handler({source:parent,data:{type:'framey:reply',id:runningRequest.id,error:'offline'}});
|
||||
await refresh;
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,false);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,0);
|
||||
assert.equal(window.DFL.Router.MainRunningApp,undefined);
|
||||
assert.equal(lifetimes.length,2);
|
||||
for(const register of [window.SteamClient.System.UI.RegisterForSystemKeyEvents,
|
||||
window.SteamClient.Apps.RegisterForGameActionUserRequest]){
|
||||
const dispose=register(()=>eventCount++);assert.equal(dispose.unregister,dispose);dispose();
|
||||
@@ -299,7 +362,86 @@ await assert.rejects(window.SteamClient.Storage.SetObject('undefined',undefined)
|
||||
assert.equal(window.indexedDB,undefined); // localForage must use bounded plugin storage.
|
||||
const unpatch=window.DFL.beforePatch({},'method',()=>{});
|
||||
assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch();
|
||||
assert.equal(typeof window.DFL.showModal({type:'p'}).Close,'function');
|
||||
// Official AlarMe uses Focusable onActivate for pause, save and delete, without onClick.
|
||||
let activated=0, stopped=0;
|
||||
const activatable=window.DFL.Focusable({onActivate:()=>activated++});
|
||||
assert.equal(activatable.props.role,'button');assert.equal(activatable.props.tabIndex,0);
|
||||
activatable.props.onClick({stopPropagation:()=>stopped++});
|
||||
const ownTarget={};
|
||||
for(const key of ['Enter',' '])activatable.props.onKeyDown({key,target:ownTarget,currentTarget:ownTarget,
|
||||
preventDefault(){},stopPropagation:()=>stopped++});
|
||||
activatable.props.onKeyDown({key:'Enter',target:{},currentTarget:ownTarget,preventDefault(){}});
|
||||
assert.equal(activated,3);assert.equal(stopped,3);
|
||||
const inertFocusable=window.DFL.Focusable({onActivate:()=>activated++,disabled:true});
|
||||
inertFocusable.props.onClick({});
|
||||
inertFocusable.props.onKeyDown({key:'Enter',target:ownTarget,currentTarget:ownTarget});
|
||||
assert.equal(activated,3);assert.equal(inertFocusable.props['aria-disabled'],true);
|
||||
let pointerCalls=0;
|
||||
window.DFL.Focusable({onClick:()=>pointerCalls++,onActivate:()=>activated++}).props.onClick({stopPropagation(){}});
|
||||
assert.equal(pointerCalls,1);assert.equal(activated,3);
|
||||
// Dialog actions must preserve descriptions, cancellation and the dialog they belong to.
|
||||
const modalRoot=roots.get(elements.get('framey-modal'));
|
||||
let confirmed=0, cancelled=0, dismissed=0;
|
||||
const modalHandle=window.DFL.showModal(React.createElement(window.DFL.ConfirmModal,{
|
||||
strTitle:'Remove preset?',strDescription:'Only this private preset will be removed.',
|
||||
strOKButtonText:'Remove',strCancelButtonText:'Keep',
|
||||
onOK:()=>confirmed++,onCancel:()=>cancelled++,closeModal:()=>dismissed++}));
|
||||
let modal=modalRoot.value.at(-1).children.at(-1);
|
||||
let confirm=modal.type(modal.props);
|
||||
assert.equal(nodes(confirm,'div')[0].children[0],'Only this private preset will be removed.');
|
||||
assert.deepEqual(nodes(confirm,'button').map(button=>button.children[0]),['Remove','Keep']);
|
||||
nodes(confirm,'button')[1].props.onClick();
|
||||
await modalHandle.ClosedPromise;
|
||||
assert.equal(confirmed,0);assert.equal(cancelled,1);assert.equal(dismissed,1);
|
||||
assert.equal(modalRoot.value,null);
|
||||
modalHandle.Close();assert.equal(dismissed,1);
|
||||
// Opening a confirmation from another dialog preserves its state; stale Close handles
|
||||
// cannot dismiss the newer dialog. Programmatic Close does not call plugin callbacks.
|
||||
const parentModal=window.DFL.showModal(React.createElement('p',{closeModal:()=>dismissed++}));
|
||||
const childModal=window.DFL.showModal(React.createElement(window.DFL.ConfirmModal,{
|
||||
strTitle:'Confirm',bOKDisabled:true,bCancelDisabled:true,bMiddleDisabled:true,
|
||||
onOK:()=>confirmed++,onCancel:()=>cancelled++,onMiddleButton:()=>confirmed++}));
|
||||
assert.equal(modalRoot.value.length,2);assert.equal(modalRoot.value[0].props.hidden,true);
|
||||
modal=modalRoot.value.at(-1).children.at(-1);confirm=modal.type(modal.props);
|
||||
for(const button of nodes(confirm,'button')){assert.equal(button.props.disabled,true);button.props.onClick();}
|
||||
assert.equal(confirmed,0);assert.equal(cancelled,1);
|
||||
let prevented=0;
|
||||
listeners.find(listener=>listener.type==='keydown').handler({key:'Escape',preventDefault:()=>prevented++});
|
||||
assert.equal(prevented,1);assert.equal(modalRoot.value.length,2);
|
||||
parentModal.Close();parentModal.Close();await parentModal.ClosedPromise;
|
||||
assert.equal(modalRoot.value.length,1);assert.equal(dismissed,1);
|
||||
childModal.Close();await childModal.ClosedPromise;assert.equal(modalRoot.value,null);
|
||||
let replacement;
|
||||
const first=window.DFL.showModal(React.createElement(window.DFL.ConfirmModal,{
|
||||
onOK:()=>{replacement=window.DFL.showModal(React.createElement('p',null,'Next dialog'));}}));
|
||||
modal=modalRoot.value.at(-1).children.at(-1);
|
||||
nodes(modal.type(modal.props),'button')[0].props.onClick();
|
||||
await first.ClosedPromise;assert.equal(modalRoot.value.length,1);
|
||||
replacement.Update(React.createElement('p',null,'Updated'));
|
||||
assert.equal(modalRoot.value[0].children.at(-1).children[0],'Updated');
|
||||
replacement.Close();await replacement.ClosedPromise;
|
||||
const alert=window.DFL.ConfirmModal({bAlertDialog:true,onCancel:()=>cancelled++});
|
||||
assert.equal(nodes(alert,'button').length,1);
|
||||
const escapeModal=window.DFL.showModal(React.createElement('p',{onCancel:()=>cancelled++}));
|
||||
listeners.find(listener=>listener.type==='keydown').handler({key:'Escape',preventDefault(){}});
|
||||
await escapeModal.ClosedPromise;assert.equal(cancelled,2);assert.equal(modalRoot.value,null);
|
||||
// Updated confirmations retain scoped dismissal and use the replacement's callback.
|
||||
for (const actionIndex of [0,1]) for (const withCallback of [false,true]) {
|
||||
let oldCloses=0, updatedCloses=0, updatedActions=0;
|
||||
const updated=window.DFL.showModal(React.createElement('p',{closeModal:()=>oldCloses++}));
|
||||
updated.Update(React.createElement(window.DFL.ConfirmModal,{
|
||||
strTitle:'Updated confirmation',onOK:()=>updatedActions++,onCancel:()=>updatedActions++,
|
||||
...(withCallback ? {closeModal:()=>updatedCloses++} : {})}));
|
||||
const updatedElement=modalRoot.value.at(-1).children.at(-1);
|
||||
nodes(updatedElement.type(updatedElement.props),'button')[actionIndex].props.onClick();
|
||||
assert.equal(modalRoot.value,null,'Updated OK/Cancel must close its own dialog');
|
||||
await updated.ClosedPromise;
|
||||
assert.equal(updatedActions,1);assert.equal(updatedCloses,Number(withCallback));assert.equal(oldCloses,0);
|
||||
updated.Close();updatedElement.props.closeModal();
|
||||
updated.Update(React.createElement('p',null,'Already closed'));
|
||||
assert.equal(updatedCloses,Number(withCallback));assert.equal(modalRoot.value,null);
|
||||
}
|
||||
|
||||
let selectedMenuItem=0;
|
||||
window.DFL.MenuItem({onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
window.DFL.MenuItem({disabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
@@ -330,6 +472,35 @@ 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;
|
||||
// The headset's two scroll buttons target the last trusted pointer's nested
|
||||
// scroll region, with a document fallback after that region is removed.
|
||||
const documentScroll={scrollTop:0,clientHeight:500,scrollHeight:900,isConnected:true,overflowY:'auto'};
|
||||
const nestedScroll={scrollTop:0,clientHeight:160,scrollHeight:716,isConnected:true,
|
||||
overflowY:'scroll',parentElement:documentScroll};
|
||||
const child={scrollTop:0,clientHeight:0,scrollHeight:0,parentElement:nestedScroll};
|
||||
context.document.scrollingElement=documentScroll;
|
||||
const pointerMove=listeners.find(x=>x.type==='pointermove').handler;
|
||||
pointerMove({isTrusted:true,target:child});
|
||||
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}});
|
||||
assert.equal(nestedScroll.scrollTop,120);
|
||||
assert.equal(messages.at(-1).moved,true);
|
||||
pointerMove({isTrusted:false,target:documentScroll});
|
||||
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'up'}});
|
||||
assert.equal(nestedScroll.scrollTop,0,'Untrusted movement cannot retarget scroll');
|
||||
nestedScroll.scrollTop=550;
|
||||
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}});
|
||||
assert.equal(nestedScroll.scrollTop,556,'Scroll stops at the content end');
|
||||
assert.equal(messages.at(-1).moved,true);
|
||||
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}});
|
||||
assert.equal(messages.at(-1).moved,false);
|
||||
const scrollReplies=messages.length;
|
||||
onMessage({source:parent,data:{type:'framey:scroll',session:'stale',direction:'up'}});
|
||||
onMessage({source:{},data:{type:'framey:scroll',session:'',direction:'up'}});
|
||||
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'sideways'}});
|
||||
assert.equal(messages.length,scrollReplies);
|
||||
nestedScroll.isConnected=false;
|
||||
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}});
|
||||
assert.equal(documentScroll.scrollTop,375);
|
||||
const eventValues=[];
|
||||
const callback=(...args)=>eventValues.push(args);
|
||||
assert.equal(api.addEventListener('timer:tick',callback),callback);
|
||||
@@ -359,3 +530,13 @@ assert.throws(()=>api.addEventListener('bad event',callback),/Invalid Decky even
|
||||
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');
|
||||
const finalRefresh=window.__refreshRunning();
|
||||
assert.equal(window.__refreshRunning(),finalRefresh,'Running reads must not overlap');
|
||||
const finalRequest=messages.at(-1);
|
||||
listeners.find(x=>x.type==='pagehide').handler();
|
||||
assert.equal(messages.at(-1).type,'framey:running:close');
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:finalRequest.id,result:launch}});
|
||||
await finalRefresh;
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,false,'Closed panels ignore late snapshots');
|
||||
const messageCount=messages.length;
|
||||
await window.__refreshRunning();assert.equal(messages.length,messageCount);
|
||||
@@ -15,7 +15,7 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
|
||||
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={running:true,plugins:[{storeId:96,sha256:"a",version:"1"}]};openFrameyPanel(96,"Timer")',context);
|
||||
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};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});
|
||||
@@ -28,6 +28,7 @@ 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.receipt,'a'.repeat(64));
|
||||
assert.equal(requests[0].body.session,session);
|
||||
// WindowProxy survives navigation: stale same-plugin replies need a session check.
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
@@ -35,6 +36,7 @@ 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);
|
||||
assert.equal(requests[1].body.receipt,'a'.repeat(64));
|
||||
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:[]});
|
||||
@@ -59,6 +61,7 @@ await send(steamRead,{});
|
||||
assert.equal(requests.length,initialCount);
|
||||
const read=send(steamRead);
|
||||
assert.equal(requests.at(-1).path,'/api/framey/steam');assert.equal(requests.at(-1).body.id,96);
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
|
||||
await read;assert.equal(replies.at(-1).result,'AU');
|
||||
const staleRead=send({...steamRead,id:11});
|
||||
@@ -66,3 +69,45 @@ vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
const priorReplies=replies.length;
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
|
||||
await staleRead;assert.equal(replies.length,priorReplies);
|
||||
// Running reads are tied to the current iframe and session, with no method path.
|
||||
session=vm.runInContext('frameySession',context);
|
||||
const running={type:'framey:running',id:20,session,args:[0]};
|
||||
const beforeRunning=requests.length;
|
||||
for(const change of [{session:'old'},{args:[-1]},{args:[true]},{args:[0,'extra']}])
|
||||
await send({...running,...change});
|
||||
await send(running,{});assert.equal(requests.length,beforeRunning);
|
||||
const pendingRunning=send(running);
|
||||
assert.equal(requests.at(-1).path,'/api/framey/running');
|
||||
assert.equal(requests.at(-1).body.active,true);
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
const oldRunning=requests.at(-1);
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
assert.equal(requests.at(-1).body.active,false);
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
assert.equal(requests.at(-1).body.session,session);
|
||||
const beforeStaleReply=replies.length;
|
||||
oldRunning.resolve({ok:true,json:async()=>({result:{apps:[],events:[],cursor:0}})});
|
||||
await pendingRunning;assert.equal(replies.length,beforeStaleReply);
|
||||
session=vm.runInContext('frameySession',context);
|
||||
const finalRunning=send({...running,id:21,session});
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:{apps:[],events:[],cursor:0}})});
|
||||
await finalRunning;
|
||||
await send({type:'framey:running:close',session});
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
assert.equal(requests.at(-1).body.active,false);
|
||||
|
||||
const scrollSession=vm.runInContext('frameySession',context);
|
||||
el('frameyScrollDown').onclick();
|
||||
assert.equal(replies.at(-1).type,'framey:scroll');
|
||||
assert.equal(replies.at(-1).session,scrollSession);
|
||||
assert.equal(replies.at(-1).direction,'down');
|
||||
await send({type:'framey:scroll:result',session:'old',direction:'down',moved:true});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Loading plugin panel…');
|
||||
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:true});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Scrolled plugin down');
|
||||
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
|
||||
el('frameyClose').onclick();
|
||||
const replyCount=replies.length;
|
||||
el('frameyScrollUp').onclick();
|
||||
assert.equal(replies.length,replyCount);
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
// Real Frame getter source and unregister-handle shape were probed over SSH.
|
||||
// Nonempty rows and launch/exit notifications here are explicit test fixtures.
|
||||
const source=readFileSync(new URL('../../ui/framey-running.js',import.meta.url),'utf8');
|
||||
const listeners=new Set(),timers=new Map();let now=1000,nextTimer=0;
|
||||
const api={RegisterForAppLifetimeNotifications(listener){
|
||||
listeners.add(listener);return {unregister:()=>listeners.delete(listener)};
|
||||
}};
|
||||
const app={appid:10,display_name:'Test game',sort_as:'test',app_type:1,gameid:'10',
|
||||
minutes_playtime_forever:30,minutes_playtime_last_two_weeks:5,rt_last_time_played:123,
|
||||
private_path:'PRIVATE',owner:'PRIVATE'};
|
||||
const window={SteamClient:{GameSessions:api},SteamUIStore:{RunningApps:[app],MainRunningApp:app}};
|
||||
window.appStore={GetVerticalCapsuleURLForApp:()=> 'https://shared.steamstatic.com/steam/apps/10/library.jpg?t=123',
|
||||
GetCustomVerticalCapsuleURLs:()=>['file:///PRIVATE/path','https://example.com/PRIVATE','https://shared.steamstatic.com/steam/apps/10/custom.jpg']};
|
||||
const context=vm.createContext({window,URL,Date:{now:()=>now},
|
||||
setInterval:fn=>{timers.set(++nextTimer,fn);return nextTimer;},clearInterval:id=>timers.delete(id)});
|
||||
const poll=(session='one',pluginId=13,active=true,cursor=0)=>
|
||||
JSON.parse(JSON.stringify(vm.runInContext(source,context)({session,pluginId,active,cursor})));
|
||||
const emit=event=>{for(const listener of listeners)listener(event);};
|
||||
let data=poll();
|
||||
assert.deepEqual(data.apps,[[10,'Test game','test',1,'10',30,5,123]]);
|
||||
assert.deepEqual(data.artwork,[[10,'https://shared.steamstatic.com/steam/apps/10/library.jpg',['https://shared.steamstatic.com/steam/apps/10/custom.jpg']]]);
|
||||
assert.deepEqual(data.runningIds,[10]);assert.equal(data.mainAppId,10);
|
||||
assert.equal(data.lifetimeAvailable,true);assert.deepEqual(data.events,[]);
|
||||
assert.equal(listeners.size,1);
|
||||
// A nonempty snapshot does not invent a game launch notification.
|
||||
assert.equal(data.cursor,0);assert.deepEqual(poll().events,[]);
|
||||
emit({unAppID:10,bRunning:true,private_token:'PRIVATE'});
|
||||
assert.deepEqual(poll().events,[{sequence:1,unAppID:10,bRunning:true}]);
|
||||
assert.deepEqual(poll('one',13,true,1).events,[]);
|
||||
assert.equal(poll().events.length,1); // Retry a dropped HTTP response.
|
||||
assert.equal(listeners.size,1); // Polls never duplicate registration.
|
||||
poll('two',37);assert.equal(listeners.size,2);
|
||||
assert.throws(()=>poll('one',37),/another plugin/);
|
||||
for(const event of [{unAppID:'10',bRunning:true},{unAppID:10,bRunning:1},
|
||||
{unAppID:NaN,bRunning:true},{unAppID:-1,bRunning:false}])emit(event);
|
||||
assert.equal(poll().cursor,1);
|
||||
for(let n=0;n<100;n++)emit({unAppID:10,bRunning:!!(n%2)});
|
||||
assert.equal(poll().events.length,64);
|
||||
assert.equal(poll().cursor,101);
|
||||
window.SteamUIStore.RunningApps=[];window.SteamUIStore.MainRunningApp=undefined;
|
||||
assert.deepEqual(poll().runningIds,[]);assert.equal(poll().mainAppId,null);
|
||||
assert.equal(poll().cursor,101); // Reading a closed game invents no exit event.
|
||||
assert.equal(poll('one',13,false),null);assert.equal(listeners.size,1);
|
||||
now+=16000;for(const timer of [...timers.values()])timer();
|
||||
assert.equal(listeners.size,0);assert.equal(timers.size,0);
|
||||
poll();assert.equal(listeners.size,1);
|
||||
window.SteamClient.GameSessions={...api};poll();assert.equal(listeners.size,1);
|
||||
poll('one',13,false);assert.equal(listeners.size,0);
|
||||
window.SteamUIStore.RunningApps=Array(65).fill(app);
|
||||
assert.throws(()=>poll(),/Too many/);assert.equal(listeners.size,0);
|
||||
window.SteamUIStore=undefined;
|
||||
assert.throws(()=>poll(),/unavailable/);assert.equal(listeners.size,0);
|
||||
+26
-10
@@ -27,7 +27,8 @@ rechecked around deletion; shared Proton services are never stopped by name.
|
||||
|
||||
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
||||
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
|
||||
Exit status: 0 all passed, 1 a step failed, 2 the headset isn't reachable.
|
||||
Exit status: 0 no step failed (optional runtime may skip a launch), 1 a step
|
||||
failed, 2 the headset isn't reachable.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@@ -125,21 +126,32 @@ class Smoke:
|
||||
self.installed = set()
|
||||
|
||||
def step(self, name, fn, *args):
|
||||
"""Run one step; record ok/failed, its detail and how long it took."""
|
||||
"""Run one step; record passed/skipped/failed and how long it took."""
|
||||
t0 = time.monotonic()
|
||||
rec = {'step': name}
|
||||
try:
|
||||
detail = fn(*args)
|
||||
rec.update(ok=True, detail=detail)
|
||||
except Skipped as e:
|
||||
rec.update(ok=False, skipped=True, reason=str(e), detail=e.detail)
|
||||
except Exception as e: # a failed step is a result, not the end of the run
|
||||
rec.update(ok=False, error=f'{type(e).__name__}: {e}', trace=traceback.format_exc(limit=3))
|
||||
rec['seconds'] = round(time.monotonic() - t0, 1)
|
||||
self.steps.append(rec)
|
||||
print(f" {'ok ' if rec['ok'] else 'FAIL'} {name} ({rec['seconds']} s)"
|
||||
+ ('' if rec['ok'] else f": {rec['error']}"), flush=True)
|
||||
label = 'ok ' if rec['ok'] else 'SKIP' if rec.get('skipped') else 'FAIL'
|
||||
message = '' if rec['ok'] else f": {rec.get('reason') or rec['error']}"
|
||||
print(f" {label} {name} ({rec['seconds']} s){message}", flush=True)
|
||||
return rec
|
||||
|
||||
|
||||
class Skipped(Exception):
|
||||
"""A prerequisite was unavailable; preserve its evidence without claiming a launch."""
|
||||
|
||||
def __init__(self, reason, detail):
|
||||
super().__init__(reason)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def ssh(cmd, timeout=60):
|
||||
return frame_android.ssh(cmd, timeout=timeout)
|
||||
|
||||
@@ -194,8 +206,8 @@ def steam_log_lines(*patterns):
|
||||
|
||||
# What a launch must show, per kind. The x86-64 runtime isn't installed on the
|
||||
# Frame, so Steam acknowledges that launch and logs "... is not installed"
|
||||
# instead (docs/sideloading.md, 2026-09-26): recorded, not a failure.
|
||||
EXPECTED = {'arm64': ('running',), 'x86_64': ('running', 'runtime missing'), 'exe': ('running', 'started')}
|
||||
# instead (docs/sideloading.md, 2026-09-26): record a skip, not a launch pass.
|
||||
EXPECTED = {'arm64': ('running',), 'x86_64': ('running',), 'exe': ('running', 'started')}
|
||||
|
||||
|
||||
def title_cycle(smoke, kind, folder):
|
||||
@@ -229,10 +241,12 @@ def title_cycle(smoke, kind, folder):
|
||||
outcome, new = 'runtime missing', new + missing
|
||||
elif any('started devkit game' in line or 'chdir' in line for line in new):
|
||||
outcome = 'started'
|
||||
if outcome in EXPECTED[kind]: # else keep looking: the log can come before the process
|
||||
if outcome in EXPECTED[kind] or (kind == 'x86_64' and outcome == 'runtime missing'):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
detail = {'outcome': outcome, 'processes': procs, 'steam_log': new[-12:]}
|
||||
if kind == 'x86_64' and outcome == 'runtime missing':
|
||||
raise Skipped('Steam Linux Runtime 4 is not installed; x86_64 title did not launch', detail)
|
||||
if outcome not in EXPECTED[kind]:
|
||||
raise AssertionError(f"Steam acknowledged the launch, but {outcome} (expected "
|
||||
f"{' or '.join(EXPECTED[kind])}): {json.dumps(detail)[:400]}")
|
||||
@@ -396,14 +410,16 @@ def run_smoke(args, before):
|
||||
'rm -rf ~/devkit-utils ~/.devkit-utils.frame-control && echo removed')
|
||||
subprocess.run(['rm', '-rf', folder])
|
||||
passed = sum(s['ok'] for s in smoke.steps)
|
||||
skipped = sum(s.get('skipped', False) for s in smoke.steps)
|
||||
failed = len(smoke.steps) - passed - skipped
|
||||
RESULTS.mkdir(exist_ok=True)
|
||||
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
|
||||
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'preflight': before,
|
||||
'os_release': release, 'paired': args.pair,
|
||||
'passed': passed, 'failed': len(smoke.steps) - passed,
|
||||
'passed': passed, 'skipped': skipped, 'failed': failed,
|
||||
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
|
||||
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out}')
|
||||
return 0 if passed == len(smoke.steps) else 1
|
||||
print(f'==> {passed} passed, {skipped} skipped, {failed} failed on build {build}; results in {out}')
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -440,6 +440,15 @@ sys.exit(255 if stop else 0)
|
||||
class Recording(unittest.TestCase):
|
||||
"""The Recorder with the real record/finish/cleanup shell, run locally under bash."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# The Frame's Bash 5 returns >128 for a timed-out read, distinct from EOF.
|
||||
# macOS Bash 3.2 returns 1 for both, so it would falsely simulate Stop.
|
||||
result = subprocess.run(["bash", "-c", "(( BASH_VERSINFO[0] >= 4 ))"],
|
||||
capture_output=True, timeout=5)
|
||||
if result.returncode:
|
||||
raise unittest.SkipTest("Recording shell fixtures need Bash 4+ timeout semantics; macOS ships Bash 3.2")
|
||||
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
|
||||
@@ -76,17 +76,22 @@ class DeckyServiceScript(unittest.TestCase):
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
|
||||
receipt = self.base / '.frame-control/receipt.json'
|
||||
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
|
||||
self.assertTrue((Path(self.tmp.name) / '.config/systemd/user/frame-control-decky.service').is_file())
|
||||
service = Path(self.tmp.name) / '.config/systemd/user/frame-control-decky.service'
|
||||
first_unit = service.read_text()
|
||||
self.assertIn('Environment=PATH='+str(Path(self.tmp.name) / '.local/bin')+
|
||||
':/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n', first_unit)
|
||||
|
||||
second = arm_binary(b'B')
|
||||
result = self.apply('update', second, version='v3.3.1', fail=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
|
||||
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
|
||||
self.assertEqual(service.read_text(), first_unit)
|
||||
|
||||
result = self.apply('update', second, version='v3.3.1')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), second)
|
||||
self.assertIn('Environment=PATH=', service.read_text())
|
||||
result = self.apply('remove')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((self.base / 'PluginLoader').exists())
|
||||
|
||||
@@ -30,6 +30,21 @@ def archive(flags=(), extra=None):
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def duplicate_archive(second=b'#!/bin/sh\nexit 0\n', second_mode=0o100755):
|
||||
stream = io.BytesIO()
|
||||
with warnings.catch_warnings(), zipfile.ZipFile(stream, 'w') as bundle:
|
||||
warnings.simplefilter('ignore', UserWarning)
|
||||
bundle.writestr('Example/plugin.json', json.dumps({'name': 'Example', 'flags': []}))
|
||||
bundle.writestr('Example/dist/index.js', 'export default {};' + ' ' * 1200)
|
||||
for mode, payload in ((0o100644, b'#!/bin/sh\nexit 0\n'), (second_mode, second)):
|
||||
info = zipfile.ZipInfo('Example/bin/helper')
|
||||
info.create_system = 3
|
||||
info.external_attr = mode << 16
|
||||
bundle.writestr(info, payload)
|
||||
data = stream.getvalue()
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def test_catalog_selects_valid_visible_latest_versions(self):
|
||||
records = [{'id': 42, 'name': 'Example', 'author': 'Panel Author', 'visible': True, 'versions':
|
||||
@@ -80,6 +95,14 @@ class ArchiveTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, 'manifest'):
|
||||
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
|
||||
|
||||
def test_only_identical_duplicate_payloads_with_execute_bit_difference_are_accepted(self):
|
||||
data, digest = duplicate_archive()
|
||||
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
||||
for data, digest in (duplicate_archive(second=b'#!/bin/sh\nexit 1\n'),
|
||||
duplicate_archive(second_mode=0o100666)):
|
||||
with self.subTest(digest=digest), self.assertRaisesRegex(ValueError, 'Unsafe'):
|
||||
frame_decky_store.archive_info(data, digest, 'Example')
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux')
|
||||
class StoreTransaction(unittest.TestCase):
|
||||
@@ -98,14 +121,14 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
|
||||
self.folder.parent.mkdir(parents=True)
|
||||
|
||||
def apply(self, action, data=None, expected='-'):
|
||||
def apply(self, action, data=None, expected='-', plugin_id=42, folder='Example', version='1.0'):
|
||||
token = secrets.token_hex(12)
|
||||
source = Path('/tmp/frame-control-decky-plugin-' + token)
|
||||
if data is not None:
|
||||
source.write_bytes(data)
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0', expected],
|
||||
return subprocess.run([sys.executable, '-', action, token, str(plugin_id), digest, folder, version, expected],
|
||||
input=frame_decky_store.APPLY, text=True, capture_output=True,
|
||||
env=self.env, timeout=15)
|
||||
finally:
|
||||
@@ -138,6 +161,94 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(external.exists())
|
||||
|
||||
def test_exact_shotty_archive_gets_isolated_click_import(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 53)
|
||||
data = official_archive(plugin)
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
|
||||
shotty = self.folder.parent / plugin['folder']
|
||||
args = {'plugin_id': plugin['id'], 'folder': plugin['folder'], 'version': plugin['version']}
|
||||
result = self.apply('install', data, **args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
receipt = json.loads((shotty / '.frame-control-store.json').read_text())
|
||||
self.assertEqual(receipt['sha256'], plugin['sha256'])
|
||||
self.assertEqual(receipt['compatShims'], ['shotty-click-app-dir'])
|
||||
compat = shotty / 'py_modules/click.py'
|
||||
self.assertTrue(compat.is_file())
|
||||
source = '''import asyncio, importlib.util, logging, sys, types
|
||||
from pathlib import Path
|
||||
folder = Path(sys.argv[1])
|
||||
sys.path.insert(0, str(folder/'py_modules'))
|
||||
decky = types.ModuleType('decky_plugin')
|
||||
decky.logger = logging.getLogger('shotty-test')
|
||||
decky.DECKY_PLUGIN_DIR = str(folder)
|
||||
sys.modules['decky_plugin'] = decky
|
||||
spec = importlib.util.spec_from_file_location('shotty', folder/'main.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
assert module.Plugin.__name__ == 'Plugin'
|
||||
from click import get_app_dir
|
||||
assert get_app_dir('Shotty') == str(Path.home()/'.config/Shotty')
|
||||
photo = Path.home()/'.local/share/Steam/userdata/100/760/remote/42/screenshots/test.jpg'
|
||||
photo.parent.mkdir(parents=True)
|
||||
photo.write_bytes(b'local screenshot fixture')
|
||||
plugin = module.Plugin()
|
||||
assert asyncio.run(plugin.aggregate_all([[42, 'Local Test Game']])) == 1
|
||||
copied = Path.home()/'Pictures/Screenshots/Local Test Game/test.jpg'
|
||||
assert copied.read_bytes() == b'local screenshot fixture'
|
||||
'''
|
||||
imported = subprocess.run([sys.executable, '-c', source, str(shotty)],
|
||||
capture_output=True, text=True,
|
||||
env={**self.env, 'XDG_CONFIG_HOME': str(Path(self.tmp.name)/'.config')}, timeout=10)
|
||||
self.assertEqual(imported.returncode, 0, imported.stderr)
|
||||
result = self.apply('remove', expected=plugin['sha256'], **args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(shotty.exists())
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as bundle:
|
||||
bundle.writestr('Shotty/plugin.json', json.dumps({'name': 'Shotty', 'flags': []}))
|
||||
bundle.writestr('Shotty/dist/index.js', 'export default {};' + ' ' * 1200)
|
||||
other = stream.getvalue()
|
||||
result = self.apply('install', other, **args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((shotty / 'py_modules/click.py').exists())
|
||||
self.assertNotIn('compatShims', json.loads((shotty / '.frame-control-store.json').read_text()))
|
||||
|
||||
def test_installed_script_entrypoints_execute_without_zip_exec_bits(self):
|
||||
data, _ = archive(extra=('Example/scripts/start.py',
|
||||
b'#!/bin/sh\nprintf "Framey script: %s\\n" "$1"\n'))
|
||||
result = self.apply('install', data)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
script = self.folder / 'scripts/start.py'
|
||||
self.assertTrue(script.stat().st_mode & 0o100)
|
||||
self.assertFalse(script.stat().st_mode & 0o6111 & ~0o100)
|
||||
ran = subprocess.run([str(script), 'ready'], text=True, capture_output=True, timeout=5)
|
||||
self.assertEqual(ran.returncode, 0, ran.stderr)
|
||||
self.assertEqual(ran.stdout, 'Framey script: ready\n')
|
||||
self.assertFalse((self.folder / 'plugin.json').stat().st_mode & 0o111)
|
||||
self.assertFalse((self.folder / 'dist/index.js').stat().st_mode & 0o111)
|
||||
|
||||
def test_identical_duplicate_archive_installs_without_widening_permissions(self):
|
||||
data, _ = duplicate_archive()
|
||||
result = self.apply('install', data)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
helper = self.folder / 'bin/helper'
|
||||
self.assertEqual(helper.read_bytes(), b'#!/bin/sh\nexit 0\n')
|
||||
self.assertTrue(helper.stat().st_mode & 0o100)
|
||||
self.assertFalse(helper.stat().st_mode & 0o6000)
|
||||
self.assertEqual(self.apply('remove').returncode, 0)
|
||||
|
||||
def test_declared_executable_is_owner_only_without_privilege_bits(self):
|
||||
info = zipfile.ZipInfo('Example/bin/entrypoint')
|
||||
info.create_system = 3
|
||||
info.external_attr = 0o104777 << 16
|
||||
data, _ = archive(extra=(info, b'entrypoint payload'))
|
||||
result = self.apply('install', data)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
executable = self.folder / 'bin/entrypoint'
|
||||
self.assertEqual(executable.stat().st_mode & 0o6111, 0o100)
|
||||
self.assertEqual(executable.read_bytes(), b'entrypoint payload')
|
||||
|
||||
def test_remove_while_service_is_stopped_does_not_restart_it(self):
|
||||
data, _ = archive()
|
||||
self.assertEqual(self.apply('install', data).returncode, 0)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""The local frontend audit still renders receipt-bound Framey panels."""
|
||||
import sandbox # noqa: F401
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
MODULE = Path(__file__).resolve().parents[1]/'scripts/framey-audit-browser.py'
|
||||
spec = importlib.util.spec_from_file_location('framey_audit_browser', MODULE)
|
||||
audit = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(audit)
|
||||
|
||||
|
||||
class ReceiptBoundAudit(unittest.TestCase):
|
||||
def test_digest_checked_archive_renders_without_installed_plugin(self):
|
||||
bundle = b"import './chunks/frontend.js';"
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as archive:
|
||||
archive.writestr('Example/dist/index.js', bundle)
|
||||
archive.writestr('Example/dist/chunks/frontend.js', b'export const ready = true;')
|
||||
data = stream.getvalue()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
record = {'id':42, 'folder':'Example', 'sha256':digest}
|
||||
page = audit.render_panel(record, data)
|
||||
self.assertIn(base64.b64encode(bundle), page)
|
||||
self.assertIn(f'/framey/module/42/{digest}/index.js'.encode(), page)
|
||||
self.assertIn(b'const report=(kind,message)', page)
|
||||
with self.assertRaisesRegex(ValueError, 'hash mismatch'):
|
||||
audit.render_panel({**record, 'sha256':'0'*64}, data)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cache = Path(directory)
|
||||
(cache/(digest+'.zip')).write_bytes(data)
|
||||
records = {42:record}
|
||||
route = f'/framey/module/42/{digest}/'
|
||||
self.assertEqual(audit.read_module_asset(route+'index.js', records, cache), bundle)
|
||||
self.assertEqual(audit.read_module_asset(route+'chunks/frontend.js', records, cache),
|
||||
b'export const ready = true;')
|
||||
for bad in (f'/framey/module/42/{"0"*64}/index.js',
|
||||
route+'chunks/../frontend.js', route+'other.json',
|
||||
f'/framey/module/042/{digest}/index.js'):
|
||||
with self.subTest(bad=bad), self.assertRaises((ValueError, KeyError)):
|
||||
audit.read_module_asset(bad, records, cache)
|
||||
(cache/(digest+'.zip')).write_bytes(data+b'changed')
|
||||
with self.assertRaisesRegex(ValueError, 'hash mismatch'):
|
||||
audit.read_module_asset(route+'index.js', records, cache)
|
||||
@@ -33,7 +33,8 @@ class OfficialBundles(unittest.TestCase):
|
||||
self.assertIn(b'deckyFrontendLib.definePlugin', frontend)
|
||||
bundle.extractall(root)
|
||||
folder = Path(root) / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({
|
||||
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
|
||||
with patch.object(framey_panel, 'PLUGINS', Path(root)):
|
||||
page = framey_panel.panel(plugin['id']).decode()
|
||||
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
|
||||
@@ -71,16 +72,17 @@ class OfficialBundles(unittest.TestCase):
|
||||
self.assertEqual(manifest.get('api_version', 'legacy'), plugin['api_version'])
|
||||
z.extractall(plugins)
|
||||
folder = plugins / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({
|
||||
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
|
||||
with patch.object(framey_panel, 'PLUGINS', plugins):
|
||||
page = framey_panel.panel(plugin['id']).decode()
|
||||
encoded = re.search(r'<script id="framey-bundle" type="application/json"[^>]*>([^<]+)</script>', page)
|
||||
self.assertIsNotNone(encoded)
|
||||
self.assertEqual(base64.b64decode(encoded.group(1)), (folder / 'dist/index.js').read_bytes())
|
||||
if plugin['id'] == 135:
|
||||
self.assertIn('data-module="/framey/module/135/index.js"', page)
|
||||
self.assertIn(f'data-module="/framey/module/135/{plugin["sha256"]}/index.js"', page)
|
||||
with patch.object(framey_panel, 'PLUGINS', plugins):
|
||||
self.assertEqual(framey_panel.local_module(135, 'chunks/pluginApi-B8sV7970.js'),
|
||||
self.assertEqual(framey_panel.local_module(135, plugin['sha256'], 'chunks/pluginApi-B8sV7970.js'),
|
||||
(folder/'dist/chunks/pluginApi-B8sV7970.js').read_bytes())
|
||||
|
||||
|
||||
|
||||
+124
-32
@@ -18,11 +18,61 @@ import framey_panel
|
||||
import frame_headset
|
||||
import server
|
||||
|
||||
RECEIPT = 'a' * 64
|
||||
|
||||
|
||||
class Panel(unittest.TestCase):
|
||||
def test_running_queue_and_host_contract(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_running.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_running_bridge_is_installed_scoped_bounded_and_headset_only(self):
|
||||
def fake_ssh(command, stdin, **_):
|
||||
self.assertEqual(command, 'python3 -')
|
||||
compile(stdin, '<running read>', 'exec')
|
||||
self.assertIn('RegisterForAppLifetimeNotifications', stdin)
|
||||
self.assertIn('262144', stdin)
|
||||
self.assertNotIn('__FRAMEY_STEAM_', stdin)
|
||||
return '{"result":{"apps":[],"events":[],"cursor":0}}'
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={'plugins':[{'storeId':13,'sha256':'a'*64}]}):
|
||||
self.assertEqual(framey_panel.poll_running(fake_ssh,13,'test-1',True,receipt='a'*64)['result']['cursor'],0)
|
||||
for plugin_id,session,active,cursor in [(True,'test',True,0),(0,'test',True,0),
|
||||
(14,'test',True,0),(13,'bad session',True,0),(13,'test',1,0),
|
||||
(13,'test',True,True),(13,'test',True,-1),(13,'test',True,2**53)]:
|
||||
with self.subTest(args=(plugin_id,session,active,cursor)):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.poll_running(lambda *a,**k:self.fail('Unexpected SSH'),
|
||||
plugin_id,session,active,cursor,receipt='a'*64)
|
||||
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.framey_running_action({'id':13,'session':'test','active':True})
|
||||
with patch.object(server, 'HEADSET_RUNTIME', True), patch.object(framey_panel, 'poll_running',return_value={'result':None}) as poll:
|
||||
self.assertEqual(server.framey_running_action({'id':13,'session':'test','active':False,'receipt':'a'*64}),{'result':None})
|
||||
poll.assert_called_once_with(server.ssh,13,'test',False,0,'a'*64)
|
||||
|
||||
def test_running_rejects_stale_or_missing_receipt_after_plugin_replacement(self):
|
||||
installed = {'storeId':13,'sha256':'a'*64}
|
||||
def safe_ssh(command, stdin, **_):
|
||||
self.assertEqual(command,'python3 -')
|
||||
compile(stdin,'<running read>','exec')
|
||||
return '{"result":null}'
|
||||
with patch.object(framey_panel.frame_decky,'status',side_effect=lambda _: {'plugins':[installed]}):
|
||||
framey_panel.poll_running(safe_ssh,13,'old-panel',True,receipt='a'*64)
|
||||
installed['sha256'] = 'b'*64 # Another manager replaces the installed plugin.
|
||||
for active in (True,False):
|
||||
for receipt in (None,'','bad','a'*64):
|
||||
with self.subTest(active=active,receipt=receipt):
|
||||
with self.assertRaisesRegex(framey_panel.PanelError,'receipt|changed'):
|
||||
framey_panel.poll_running(lambda *a,**k:self.fail('Stale running request reached CEF'),
|
||||
13,'old-panel',active,receipt=receipt)
|
||||
# A reopened panel uses the new receipt for both polling and cleanup.
|
||||
framey_panel.poll_running(safe_ssh,13,'new-panel',True,receipt='b'*64)
|
||||
framey_panel.poll_running(safe_ssh,13,'new-panel',False,receipt='b'*64)
|
||||
|
||||
def test_steam_reads_are_allowlisted_and_installed_only(self):
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck'}]}):
|
||||
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck','sha256':RECEIPT}]}):
|
||||
for method, expression in framey_panel.STEAM_READS.items():
|
||||
def fake_ssh(command, stdin, **_):
|
||||
self.assertEqual(command, 'python3 -')
|
||||
@@ -30,13 +80,16 @@ class Panel(unittest.TestCase):
|
||||
self.assertIn(expression.replace('\n', r'\n'), stdin)
|
||||
self.assertNotIn('__FRAMEY_STEAM_READ__', stdin)
|
||||
return '{"result":"actual read"}'
|
||||
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method)['result'], 'actual read')
|
||||
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method,RECEIPT)['result'], 'actual read')
|
||||
for plugin_id, method in [(True,'GetIPCountry'), (0,'GetIPCountry'),
|
||||
(82,'GetIPCountry'), (81,'ShutdownPC'), (81,'User.GetIPCountry'),
|
||||
(81,[]), (81,'GetIPCountry();alert(1)')]:
|
||||
with self.subTest(plugin_id=plugin_id, method=method):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method)
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method,RECEIPT)
|
||||
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Stale read reached SSH'),
|
||||
81,'GetIPCountry','b'*64)
|
||||
|
||||
def test_steam_read_response_limit_and_socket_cleanup(self):
|
||||
from types import SimpleNamespace
|
||||
@@ -59,12 +112,22 @@ class Panel(unittest.TestCase):
|
||||
script = '''const assert=require('node:assert/strict');
|
||||
const app={appid:10,display_name:'Game',sort_as:'game',app_type:1,gameid:'10',
|
||||
minutes_playtime_forever:60,minutes_playtime_last_two_weeks:20,rt_last_time_played:123,
|
||||
owner_account_id:'PRIVATE',per_client_data:{path:'PRIVATE'}};
|
||||
owner_account_id:'PRIVATE',per_client_data:{path:'PRIVATE'},
|
||||
local_per_client_data:{installed:true,clientid:'PRIVATE',install_path:'PRIVATE'}};
|
||||
global.window=global;global.appStore={allApps:[app]};
|
||||
global.App={m_CurrentUser:{strSteamID:'76561198000000001',strAccountBalance:'PRIVATE'}};
|
||||
global.collectionStore={allGamesCollection:{allApps:[app]}};
|
||||
const value=eval(EXPRESSION);
|
||||
assert.deepEqual(value,{apps:[[10,'Game','game',1,'10',60,20,123]],gameIds:[10],steamId:'76561198000000001'});
|
||||
assert.deepEqual(value,{apps:[[10,'Game','game',1,'10',60,20,123,true]],gameIds:[10],steamId:'76561198000000001'});
|
||||
assert.equal(JSON.stringify(value).includes('PRIVATE'),false);
|
||||
app.local_per_client_data.installed=false;
|
||||
assert.equal(eval(EXPRESSION).apps[0][8],false);
|
||||
delete app.local_per_client_data.installed;
|
||||
assert.equal(eval(EXPRESSION).apps[0][8],null);
|
||||
app.local_per_client_data=undefined;
|
||||
assert.equal(eval(EXPRESSION).apps[0][8],null);
|
||||
app.local_per_client_data={installed:'true'};
|
||||
assert.equal(eval(EXPRESSION).apps[0][8],null);
|
||||
appStore.allApps=Array(10001).fill(app);
|
||||
assert.throws(()=>eval(EXPRESSION),/snapshot limit/);
|
||||
appStore=undefined;
|
||||
@@ -86,19 +149,19 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertEqual(json.loads(ast.literal_eval(assignment.value.args[0]))['args'],args)
|
||||
return '{"result":null}'
|
||||
with patch.object(framey_panel.frame_decky,'status',return_value={
|
||||
'plugins':[{'storeId':53,'name':'Shotty'}]}):
|
||||
'plugins':[{'storeId':53,'name':'Shotty','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,
|
||||
53,'set_id_map_fronend',args,True),{'result':None})
|
||||
53,'set_id_map_fronend',args,True,RECEIPT),{'result':None})
|
||||
with self.assertRaisesRegex(framey_panel.PanelError,'too large'):
|
||||
framey_panel.call_method(lambda *a,**k:self.fail('Unexpected SSH'),
|
||||
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True)
|
||||
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True,RECEIPT)
|
||||
|
||||
def test_steam_reads_have_the_headset_service_boundary(self):
|
||||
with patch.object(server,'HEADSET_RUNTIME',False), patch.dict(server.os.environ,{'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):server.framey_steam_action({'id':81,'method':'GetIPCountry'})
|
||||
with patch.object(server,'HEADSET_RUNTIME',True), patch.object(framey_panel,'read_steam',return_value={'result':'AU'}) as read:
|
||||
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry'}),{'result':'AU'})
|
||||
read.assert_called_once_with(server.ssh,81,'GetIPCountry')
|
||||
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry','receipt':RECEIPT}),{'result':'AU'})
|
||||
read.assert_called_once_with(server.ssh,81,'GetIPCountry',RECEIPT)
|
||||
|
||||
def test_decky_event_queue_scope_bounds_expiry_and_reconnect(self):
|
||||
for name in ('framey_events.mjs', 'framey_event_parent.mjs'):
|
||||
@@ -117,8 +180,11 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
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)
|
||||
'plugins':[{'storeId':96,'name':'Simple Timer','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5, RECEIPT)['cursor'], 6)
|
||||
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
|
||||
framey_panel.poll_events(lambda *a,**k:self.fail('Stale poll reached SSH'),
|
||||
96,'test-123.45',['timer:tick'],5,'b'*64)
|
||||
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),
|
||||
@@ -129,7 +195,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
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)
|
||||
plugin_id, session, events, cursor, RECEIPT)
|
||||
|
||||
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'}):
|
||||
@@ -137,8 +203,8 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
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)
|
||||
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick'],'receipt':RECEIPT})['events'], [])
|
||||
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0, RECEIPT)
|
||||
|
||||
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
|
||||
@@ -161,7 +227,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.plugins = Path(tmp.name)/'plugins'
|
||||
self.folder = self.plugins/'protondb-decky'
|
||||
(self.folder/'dist').mkdir(parents=True)
|
||||
(self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4'}))
|
||||
(self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4','sha256':RECEIPT}))
|
||||
(self.folder/'plugin.json').write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
|
||||
(self.folder/'dist/index.js').write_text("export default () => ({content: SP_REACT.createElement('p', null, 'OK')});")
|
||||
home = patch.object(framey_panel, 'PLUGINS', self.plugins)
|
||||
@@ -194,18 +260,36 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
chunks.mkdir()
|
||||
(chunks/'view.js').write_text('export const title = "Settings";')
|
||||
page = framey_panel.panel(14).decode()
|
||||
self.assertIn('data-module="/framey/module/14/index.js"', page)
|
||||
self.assertEqual(framey_panel.local_module(14, 'chunks/view.js'),
|
||||
self.assertIn(f'data-module="/framey/module/14/{RECEIPT}/index.js"', page)
|
||||
self.assertEqual(framey_panel.local_module(14, RECEIPT, 'chunks/view.js'),
|
||||
b'export const title = "Settings";')
|
||||
for name in ('../plugin.json', 'chunks/../../plugin.json',
|
||||
'chunks/view.js.map', 'other.js', 'chunks/%2e%2e/view.js'):
|
||||
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(14, name)
|
||||
framey_panel.local_module(14, RECEIPT, name)
|
||||
(chunks/'linked.js').symlink_to(chunks/'view.js')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(14, 'chunks/linked.js')
|
||||
framey_panel.local_module(14, RECEIPT, 'chunks/linked.js')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(15, 'chunks/view.js')
|
||||
framey_panel.local_module(15, RECEIPT, 'chunks/view.js')
|
||||
|
||||
def test_chunked_panel_rejects_modules_from_a_replaced_store_receipt(self):
|
||||
(self.folder/'dist/index.js').write_text("import './chunks/view.js'; export default () => ({});")
|
||||
chunks = self.folder/'dist/chunks'
|
||||
chunks.mkdir()
|
||||
(chunks/'view.js').write_text('export const version = 1;')
|
||||
old_url = f'/framey/module/14/{RECEIPT}/chunks/view.js'
|
||||
self.assertIn(f'/framey/module/14/{RECEIPT}/index.js', framey_panel.panel(14).decode())
|
||||
self.assertEqual(framey_panel.local_module(14, RECEIPT, 'chunks/view.js'), b'export const version = 1;')
|
||||
(self.folder/'.frame-control-store.json').write_text(json.dumps({
|
||||
'storeId':14,'version':'2.0','sha256':'b'*64}))
|
||||
(chunks/'view.js').write_text('export const version = 2;')
|
||||
with self.assertRaisesRegex(FileNotFoundError, 'changed'):
|
||||
framey_panel.local_module(14, RECEIPT, 'chunks/view.js')
|
||||
self.assertEqual(framey_panel.local_module(14, 'b'*64, 'chunks/view.js'), b'export const version = 2;')
|
||||
self.assertNotIn(old_url, framey_panel.panel(14).decode())
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(14, 'invalid', 'chunks/view.js')
|
||||
|
||||
def test_legacy_keyword_rpc_is_scoped_and_validated(self):
|
||||
import shlex
|
||||
@@ -220,12 +304,12 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertIn('loader/call_legacy_plugin_method', stdin)
|
||||
return json.dumps({'result':{'success':True, 'result':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'Legacy'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True),
|
||||
'plugins':[{'storeId':14,'name':'Legacy','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True, RECEIPT),
|
||||
{'result':{'success':True, 'result':'dark'}})
|
||||
for args, legacy in [([],True), ([[]],True), ([{},{}],True), ([{}],'true')]:
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy)
|
||||
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy, RECEIPT)
|
||||
|
||||
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
|
||||
manifest = self.folder/'plugin.json'
|
||||
@@ -255,11 +339,15 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertNotIn('__FRAMEY_METHOD_PAYLOAD__',stdin)
|
||||
return json.dumps({'result':{'theme':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'ProtonDB Badges'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark']),
|
||||
'plugins':[{'storeId':14,'name':'ProtonDB Badges','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark'],receipt=RECEIPT),
|
||||
{'result':{'theme':'dark'}})
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[])
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[])
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[],receipt=RECEIPT)
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[],receipt=RECEIPT)
|
||||
for stale in (None, 'b' * 64, 'invalid'):
|
||||
with self.subTest(stale=stale), self.assertRaisesRegex(framey_panel.PanelError, 'receipt|changed'):
|
||||
framey_panel.call_method(lambda *a, **k:self.fail('Stale receipt reached SSH'),
|
||||
14,'get_setting',[],receipt=stale)
|
||||
compile(framey_panel.METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__',repr('{}')), '<remote method>', 'exec')
|
||||
|
||||
def test_headset_bundle_contains_runtime_and_licenses(self):
|
||||
@@ -269,7 +357,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
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/framey-events.js','ui/framey-running.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)
|
||||
|
||||
@@ -295,12 +383,16 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
|
||||
(self.folder/'dist/chunks').mkdir()
|
||||
(self.folder/'dist/chunks/view.js').write_text('export const value = 1;')
|
||||
status, headers, body = get('/framey/module/14/chunks/view.js')
|
||||
status, headers, body = get(f'/framey/module/14/{RECEIPT}/chunks/view.js')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(headers['Access-Control-Allow-Origin'], 'null')
|
||||
self.assertEqual(body, b'export const value = 1;')
|
||||
self.assertEqual(get('/framey/module/14/chunks/../plugin.json')[0], 404)
|
||||
self.assertEqual(get('/framey/module/15/chunks/view.js')[0], 404)
|
||||
self.assertEqual(get(f'/framey/module/14/{RECEIPT}/chunks/../plugin.json')[0], 404)
|
||||
self.assertEqual(get(f'/framey/module/15/{RECEIPT}/chunks/view.js')[0], 404)
|
||||
(self.folder/'.frame-control-store.json').write_text(json.dumps({
|
||||
'storeId':14,'version':'2.0','sha256':'b'*64}))
|
||||
self.assertEqual(get(f'/framey/module/14/{RECEIPT}/chunks/view.js')[0], 404)
|
||||
self.assertEqual(get(f'/framey/module/14/{"b"*64}/chunks/view.js')[0], 200)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -15,6 +15,18 @@ spec.loader.exec_module(check)
|
||||
|
||||
|
||||
class Safety(unittest.TestCase):
|
||||
def test_status_row_can_follow_metrics_and_ambiguous_status_fails_closed(self):
|
||||
self.assertEqual(check.steamvr_activity([
|
||||
{'operation':'performance','frame_presents':12},
|
||||
{'operation':'status','activity_level':3}]), 3)
|
||||
self.assertEqual(check.steamvr_activity([
|
||||
{'operation':'performance','frame_presents':12},
|
||||
{'operation':'status','activity_level':1}]), 1)
|
||||
self.assertIsNone(check.steamvr_activity([
|
||||
{'operation':'status','activity_level':3},
|
||||
{'operation':'status','activity_level':1}]))
|
||||
self.assertIsNone(check.steamvr_activity([{'activity_level':3}]))
|
||||
|
||||
def test_overlay_wait_is_bounded_and_stops_when_visible(self):
|
||||
from unittest.mock import Mock
|
||||
remote = Mock()
|
||||
@@ -61,7 +73,7 @@ class Safety(unittest.TestCase):
|
||||
def test_power_restore_after_partial_setter_failure(self):
|
||||
state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true',
|
||||
'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False,
|
||||
'stats':[{'activity_level':3}], 'proximity':[6.25]}
|
||||
'stats':[{'operation':'status','activity_level':3}], 'proximity':[6.25]}
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory:
|
||||
remote = factory.return_value
|
||||
remote.python.return_value = state
|
||||
@@ -78,7 +90,9 @@ class Safety(unittest.TestCase):
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', commands[-1])
|
||||
|
||||
def test_awake_or_unknown_activity_refuses_wake_and_install(self):
|
||||
for stats in ([{'activity_level':1}], [{'activity_level':2}], [], None):
|
||||
for stats in ([{'operation':'status','activity_level':1}],
|
||||
[{'operation':'performance'}, {'operation':'status','activity_level':2}],
|
||||
[], None):
|
||||
state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true',
|
||||
'timeout':'power.turnOffScreensTimeout=17.000000',
|
||||
'dashboardVisible':False, 'stats':stats, 'proximity':[0.0]}
|
||||
|
||||
@@ -3,7 +3,9 @@ import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -30,7 +32,7 @@ class WornGate(unittest.TestCase):
|
||||
self.assertIn('trusted pointer events alone do not prove',out.getvalue())
|
||||
|
||||
def test_standby_is_read_only_under_owned_lock_and_releases_it(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':3, 'panelService':True,
|
||||
state = {'battery':[{'percent':99}], 'activity':3, 'proximity':[6.25], 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
@@ -41,7 +43,7 @@ class WornGate(unittest.TestCase):
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args_list[1].args[0])
|
||||
|
||||
def test_low_battery_releases_owned_lock(self):
|
||||
state = {'battery':[{'percent':19}], 'activity':0, 'panelService':True,
|
||||
state = {'battery':[{'percent':19}], 'activity':0, 'proximity':[6.25], 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
@@ -50,7 +52,7 @@ class WornGate(unittest.TestCase):
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
|
||||
def test_foreign_lock_prevents_all_sensor_reads_and_is_never_removed(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'panelService':True,
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'proximity':[6.25], 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state, RuntimeError('File exists'))
|
||||
self.assertEqual(code, 1)
|
||||
@@ -61,3 +63,56 @@ class WornGate(unittest.TestCase):
|
||||
|
||||
def test_screenshot_palette_counts_distinct_colors(self):
|
||||
self.assertEqual(worn.color_count('0 #ff00aa 1 #ff00aa 2 #00ff00'), 2)
|
||||
|
||||
def test_missing_proximity_stops_before_panel_or_input_and_releases_lock(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'proximity':[],
|
||||
'panelService':True, 'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('Proximity reading unavailable', report)
|
||||
probe.assert_called_once()
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args_list[-1].args[0])
|
||||
|
||||
def test_proximity_disappearing_on_second_read_stops_before_input(self):
|
||||
first = {'battery':[{'percent':99}], 'activity':0, 'proximity':[200.0],
|
||||
'panelService':True, 'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
second = {**first, 'proximity':[]}
|
||||
out = io.StringIO()
|
||||
with patch.object(worn, 'probe', side_effect=[first, second]) as probe, \
|
||||
patch.object(worn, 'remote') as remote, contextlib.redirect_stdout(out):
|
||||
code = worn.main(worn_confirmed=True)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('Activity, proximity or battery changed', out.getvalue())
|
||||
self.assertEqual(probe.call_count, 2)
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
|
||||
def test_probe_reads_activity_from_status_after_metrics(self):
|
||||
rows = [{'operation':'performance','frame_presents':12},
|
||||
{'operation':'status','activity_level':1}]
|
||||
def run(argv, **_):
|
||||
if argv[-1] == '--stats':
|
||||
return SimpleNamespace(returncode=0, stdout=json.dumps(rows))
|
||||
return SimpleNamespace(returncode=1, stdout='')
|
||||
for current, expected in (
|
||||
(rows, 1),
|
||||
([{'operation':'performance','frame_presents':12}], None),
|
||||
(rows + [{'operation':'status','activity_level':3}], None),
|
||||
):
|
||||
with self.subTest(rows=current):
|
||||
rows = current
|
||||
out = io.StringIO()
|
||||
with patch('subprocess.run', side_effect=run), contextlib.redirect_stdout(out):
|
||||
exec(worn.PROBE, {})
|
||||
result = json.loads(out.getvalue())
|
||||
self.assertEqual(result['activity'], expected)
|
||||
self.assertIsInstance(result['proximity'], list)
|
||||
|
||||
def test_probe_discards_nonfinite_proximity(self):
|
||||
def glob(_path, pattern):
|
||||
return [SimpleNamespace(read_text=lambda: 'nan')] if pattern == 'iio:device*/in_proximity_raw' else []
|
||||
with patch('pathlib.Path.glob', autospec=True, side_effect=glob), \
|
||||
patch('subprocess.run', return_value=SimpleNamespace(returncode=1, stdout='')), \
|
||||
contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
exec(worn.PROBE, {})
|
||||
self.assertEqual(json.loads(out.getvalue())['proximity'], [])
|
||||
@@ -76,6 +76,8 @@ class NativeContracts(unittest.TestCase):
|
||||
frameworks = str(Path(developer) / "Platforms/MacOSX.platform/Developer/Library/Frameworks")
|
||||
libraries = str(Path(developer) / "Platforms/MacOSX.platform/Developer/usr/lib")
|
||||
shared = str(Path(developer).parent / "SharedFrameworks")
|
||||
if not (Path(frameworks) / "XCTest.framework").is_dir():
|
||||
self.skipTest("Foundation XCTest contracts need full Xcode; Command Line Tools lack XCTest")
|
||||
result = subprocess.run(["swiftc", "-I", libraries, "-L", libraries, "-F", frameworks, "-Xlinker", "-rpath", "-Xlinker", frameworks,
|
||||
"-Xlinker", "-rpath", "-Xlinker", libraries,
|
||||
"-Xlinker", "-rpath", "-Xlinker", shared,
|
||||
|
||||
@@ -131,14 +131,14 @@ class ObsSourceTests(unittest.TestCase):
|
||||
wrapper = ("import importlib.util; "
|
||||
f"s=importlib.util.spec_from_file_location('obs', {str(SCRIPT)!r}); "
|
||||
"o=importlib.util.module_from_spec(s);s.loader.exec_module(o); "
|
||||
"o.STARTUP_TIMEOUT=o.STREAM_STALL=o.CHILD_STOP_TIMEOUT=0.15; "
|
||||
"o.STARTUP_TIMEOUT=5; o.STREAM_STALL=o.CHILD_STOP_TIMEOUT=0.15; "
|
||||
"o.main(['--frame','fake-frame','--control-stdin'])")
|
||||
env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', '')}
|
||||
process = subprocess.Popen([sys.executable, '-c', wrapper], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env)
|
||||
try:
|
||||
# Keep stdin OPEN: real Electron does this until the helper exits.
|
||||
self.assertEqual(process.wait(timeout=4), 1)
|
||||
self.assertEqual(process.wait(timeout=10), 1)
|
||||
error = process.stderr.read().decode(errors='replace')
|
||||
self.assertIn('video stopped', error)
|
||||
self.assertNotIn('Fatal Python error', error)
|
||||
@@ -209,6 +209,7 @@ class ObsSourceTests(unittest.TestCase):
|
||||
for mode in ('startup', 'stream'):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as folder:
|
||||
directory = Path(folder)
|
||||
ready = directory / 'ready'
|
||||
fake = directory / 'ssh'
|
||||
fake.write_text("#!/usr/bin/env python3\n"
|
||||
"import os, signal, sys, time\n"
|
||||
@@ -219,12 +220,29 @@ class ObsSourceTests(unittest.TestCase):
|
||||
"if os.environ['FAKE_SSH_MODE'] == 'stream':\n"
|
||||
" sys.stdout.buffer.write(b'G' * 1316)\n"
|
||||
" sys.stdout.buffer.flush()\n"
|
||||
"open(os.environ['FAKE_SSH_READY'], 'w').close()\n"
|
||||
"while True: time.sleep(1)\n")
|
||||
fake.chmod(0o755)
|
||||
stopped = directory / 'stopped'
|
||||
env = {'PATH': str(directory) + os.pathsep + os.environ.get('PATH', ''),
|
||||
'FAKE_SSH_STOPPED': str(stopped), 'FAKE_SSH_MODE': mode}
|
||||
'FAKE_SSH_STOPPED': str(stopped), 'FAKE_SSH_MODE': mode,
|
||||
'FAKE_SSH_READY': str(ready)}
|
||||
real_popen = subprocess.Popen
|
||||
|
||||
def start_ready(*args, **kwargs):
|
||||
proc = real_popen(*args, **kwargs)
|
||||
# Start the short watchdog clock after the stand-in installed
|
||||
# its handler, independent of native interpreter launch time.
|
||||
end = time.monotonic() + 5
|
||||
while not ready.exists() and proc.poll() is None and time.monotonic() < end:
|
||||
time.sleep(0.01)
|
||||
if not ready.exists():
|
||||
obs.stop_child(proc)
|
||||
self.fail('Stand-in SSH did not finish startup')
|
||||
return proc
|
||||
|
||||
with mock.patch.dict(os.environ, env), \
|
||||
mock.patch.object(obs.subprocess, 'Popen', side_effect=start_ready), \
|
||||
mock.patch.object(obs, 'STARTUP_TIMEOUT', 0.3), \
|
||||
mock.patch.object(obs, 'STREAM_STALL', 0.3):
|
||||
with self.assertRaisesRegex(RuntimeError, 'no OBS video' if mode == 'startup' else 'video stopped'):
|
||||
|
||||
@@ -95,6 +95,60 @@ class RealProcesses(unittest.TestCase):
|
||||
|
||||
|
||||
class Safety(unittest.TestCase):
|
||||
def test_missing_x86_runtime_skips_launch_but_still_removes_title(self):
|
||||
smoke = frame_smoke.Smoke()
|
||||
gid = 'fc_smoke_x86_64'
|
||||
with mock.patch.object(frame_smoke.tiny_programs, 'write', return_value=Path('/tmp/tiny')), \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'install', return_value={
|
||||
'runtime': 'SteamLinuxRuntime_4'}), \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'list_titles', side_effect=[
|
||||
[{'id': gid}], []]), \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'launch') as launch, \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'remove') as remove, \
|
||||
mock.patch.object(frame_smoke, 'steam_log_lines', side_effect=[
|
||||
[], [], [], ['Tool 4183110 Steam Linux Runtime 4.0 is found, but is not installed']]), \
|
||||
mock.patch.object(frame_smoke, 'ssh', return_value=''), \
|
||||
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': []}):
|
||||
frame_smoke.title_cycle(smoke, 'x86_64', '/tmp')
|
||||
self.assertEqual([step['step'] for step in smoke.steps],
|
||||
['x86_64: install', 'x86_64: launch', 'x86_64: remove'])
|
||||
self.assertTrue(smoke.steps[0]['ok'])
|
||||
self.assertFalse(smoke.steps[1]['ok'])
|
||||
self.assertTrue(smoke.steps[1]['skipped'])
|
||||
self.assertEqual(smoke.steps[1]['detail']['outcome'], 'runtime missing')
|
||||
self.assertTrue(smoke.steps[2]['ok'])
|
||||
self.assertNotIn(gid, smoke.installed)
|
||||
launch.assert_called_once_with(gid)
|
||||
remove.assert_called_once_with(gid)
|
||||
|
||||
def test_optional_runtime_skip_is_counted_separately_in_saved_result(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
output = Path(root) / 'results'
|
||||
folder = Path(root) / 'titles'
|
||||
folder.mkdir()
|
||||
def cycle(smoke, kind, _folder):
|
||||
if kind == 'x86_64':
|
||||
def missing():
|
||||
raise frame_smoke.Skipped('optional runtime absent', {'outcome': 'runtime missing'})
|
||||
smoke.step(f'{kind}: launch', missing)
|
||||
else:
|
||||
smoke.step(f'{kind}: launch', lambda: {'outcome': 'running'})
|
||||
with mock.patch.object(frame_smoke, 'RESULTS', output), \
|
||||
mock.patch.object(frame_smoke, 'os_release', return_value={
|
||||
'BUILD_ID': 'fake', 'VERSION_ID': 'fake', 'VARIANT_ID': 'fake'}), \
|
||||
mock.patch.object(frame_smoke, 'ssh', return_value='yes'), \
|
||||
mock.patch.object(frame_smoke, 'cleanup', return_value={}), \
|
||||
mock.patch.object(frame_smoke, 'properties', return_value={}), \
|
||||
mock.patch.object(frame_smoke, 'status', return_value={}), \
|
||||
mock.patch.object(frame_smoke, 'title_cycle', side_effect=cycle), \
|
||||
mock.patch.object(frame_smoke.tempfile, 'mkdtemp', return_value=str(folder)):
|
||||
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
|
||||
{'activity': 3, 'proximity': [], 'battery': [100]}), 0)
|
||||
result = json.loads(next(output.glob('*-fake.json')).read_text())
|
||||
self.assertEqual((result['passed'], result['skipped'], result['failed']), (6, 1, 0))
|
||||
self.assertEqual(next(s for s in result['steps'] if s['step'] == 'x86_64: launch')
|
||||
['detail']['outcome'], 'runtime missing')
|
||||
|
||||
def test_non_test_ids_rejected_before_scanning(self):
|
||||
for ids in ([], ['portal2'], ['fc_smoke_exe/../portal2'], ['fc_smoke_custom']):
|
||||
with self.subTest(ids=ids), self.assertRaises(ValueError):
|
||||
|
||||
+4
-3
@@ -529,9 +529,10 @@ class PanelsAsAFreshProcess(unittest.TestCase):
|
||||
self.assertNotIn("why", out)
|
||||
|
||||
def test_slow_x_tools_stay_inside_the_servers_timeout(self):
|
||||
# Review of #83: X tools answering in 4.6 s each would take well over the server's
|
||||
# 20 s; one deadline for the run (PANELS_DEADLINE, 12 s) returns in time, saying why.
|
||||
out, took = self.run_panels(4.6)
|
||||
# Several 3 s calls exceed the whole-run deadline before a panel is complete.
|
||||
# Keep clear of each tool's 5 s timeout, including process startup on Intel Macs.
|
||||
# One deadline (12 s) must return inside the server's 20 s timeout, saying why.
|
||||
out, took = self.run_panels(3)
|
||||
self.assertLess(took, 15, out)
|
||||
self.assertEqual(out["panels"], [])
|
||||
self.assertIn("took too long", out["why"])
|
||||
|
||||
@@ -192,6 +192,12 @@ class Probe(unittest.TestCase):
|
||||
@unittest.skipUnless(wireless.frame_host.WINDOWS, 'Live read-only Windows inventory runs on Windows CI')
|
||||
def test_live_windows_inventory(self):
|
||||
r = wireless.diagnose()
|
||||
# Shared Windows runners can spend the entire probe budget starting
|
||||
# PowerShell. Retry only that transient timeout; a second failure must
|
||||
# still fail this live inventory gate.
|
||||
if (r['state'] == 'unavailable' and
|
||||
r.get('message') == 'Windows wireless inventory timed out. No settings were changed.'):
|
||||
r = wireless.diagnose()
|
||||
self.assertIn(r['state'], ('ok', 'partial'), r)
|
||||
self.assertEqual(r['sources']['adapters'], 'ok', r['sources'])
|
||||
self.assertTrue(r['devices'], 'Windows runner should expose at least its virtual network interface')
|
||||
|
||||
@@ -123,6 +123,12 @@ try:
|
||||
next_binary.write_bytes(data)
|
||||
os.replace(next_binary, binary)
|
||||
binary.chmod(0o700)
|
||||
# The Frame's user-installed tools (including its Tailscale CLI)
|
||||
# live here, while the systemd user manager omits this directory.
|
||||
# Keep standard system paths for plugins that invoke OS tools.
|
||||
plugin_path = ':'.join((str(home / '.local/bin'), '/usr/local/sbin',
|
||||
'/usr/local/bin', '/usr/sbin', '/usr/bin',
|
||||
'/sbin', '/bin'))
|
||||
text = ('[Unit]\nDescription=Decky Loader (Frame Control experimental user install)\n'
|
||||
'After=default.target\n[Service]\nType=simple\nRestart=on-failure\n'
|
||||
'RestartSec=5\nExecStart='+str(binary)+'\n'
|
||||
@@ -130,6 +136,7 @@ try:
|
||||
'Environment=PRIVILEGED_PATH='+str(base)+'\n'
|
||||
'Environment=CHOWN_PLUGIN_PATH=0\nEnvironment=LIVE_RELOAD=0\n'
|
||||
'Environment=USE_CEF_CLOSE_WORKAROUND=0\nEnvironment=KEEP_SYSTEMD_SERVICE=1\n'
|
||||
'Environment=PATH='+plugin_path+'\n'
|
||||
'[Install]\nWantedBy=default.target\n')
|
||||
service.write_text(text)
|
||||
receipt.write_text(json.dumps({'managedBy':'Frame Control','version':version,'sha256':digest})+'\n')
|
||||
|
||||
+58
-7
@@ -86,7 +86,7 @@ def archive_info(data, expected_hash, expected_name):
|
||||
folder = manifests[0].split('/')[0]
|
||||
if not SAFE_FOLDER.fullmatch(folder):
|
||||
raise ValueError('Invalid plugin folder')
|
||||
seen = set()
|
||||
seen = {}
|
||||
for entry in files:
|
||||
name = entry.filename
|
||||
mode = entry.external_attr >> 16
|
||||
@@ -95,11 +95,21 @@ def archive_info(data, expected_hash, expected_name):
|
||||
if entry.is_dir() and parts[-1] == '':
|
||||
parts = parts[:-1]
|
||||
if ('\\' in name or name.startswith('/') or
|
||||
any(part in ('', '.', '..') for part in parts) or name in seen or
|
||||
any(part in ('', '.', '..') for part in parts) or
|
||||
parts[0] != folder or entry.file_size > 30 * 1024 * 1024 or
|
||||
kind not in (0, 0o040000, 0o100000)):
|
||||
raise ValueError('Unsafe plugin archive member')
|
||||
seen.add(name)
|
||||
prior = seen.get(name)
|
||||
if prior is not None:
|
||||
# Some official ZIPs repeat unchanged files with execute-bit
|
||||
# differences. Reject any content or other mode ambiguity.
|
||||
old_mode = prior.external_attr >> 16
|
||||
if (prior.file_size != entry.file_size or
|
||||
((old_mode ^ mode) & ~0o111) or
|
||||
archive.read(prior) != archive.read(entry)):
|
||||
raise ValueError('Unsafe plugin archive member')
|
||||
else:
|
||||
seen[name] = entry
|
||||
if entry.is_dir():
|
||||
continue
|
||||
with archive.open(entry) as source:
|
||||
@@ -164,21 +174,60 @@ try:
|
||||
files=z.infolist()
|
||||
if not files or len(files)>3000 or sum(f.file_size for f in files)>150*1024*1024:
|
||||
raise RuntimeError('Plugin archive too large')
|
||||
seen=set()
|
||||
seen={}
|
||||
for f in files:
|
||||
parts=f.filename.split('/')
|
||||
if f.is_dir() and parts[-1]=='': parts=parts[:-1]
|
||||
kind=f.external_attr>>16&0o170000
|
||||
if (not parts or parts[0]!=folder or f.filename.startswith('/') or
|
||||
any(part in ('','.','..') for part in parts) or '\\' in f.filename or
|
||||
f.filename in seen or f.file_size>30*1024*1024 or
|
||||
f.file_size>30*1024*1024 or
|
||||
kind not in (0,0o040000,0o100000)):
|
||||
raise RuntimeError('Unsafe plugin archive')
|
||||
seen.add(f.filename)
|
||||
prior=seen.get(f.filename)
|
||||
if prior is not None:
|
||||
# Match desktop validation before zipfile overwrites a
|
||||
# repeated member during extraction.
|
||||
old_mode=prior.external_attr>>16
|
||||
if (prior.file_size!=f.file_size or
|
||||
((old_mode^ (f.external_attr>>16)) & ~0o111) or
|
||||
z.read(prior)!=z.read(f)):
|
||||
raise RuntimeError('Unsafe plugin archive')
|
||||
else: seen[f.filename]=f
|
||||
stage.mkdir(mode=0o700)
|
||||
z.extractall(stage)
|
||||
# zipfile drops execute bits. Legacy plugins also publish shebang
|
||||
# entrypoints as 0644 because Decky's installer chmods recursively.
|
||||
# Grant only owner execute for scripts or declared executables;
|
||||
# never restore archive privilege/write bits or mark other data executable.
|
||||
for member in files:
|
||||
if member.is_dir(): continue
|
||||
entry=stage/member.filename
|
||||
with entry.open('rb') as source: script=source.read(2)==b'#!'
|
||||
if script or (member.external_attr>>16)&0o111:
|
||||
entry.chmod(entry.stat().st_mode|0o100)
|
||||
unpacked=stage/folder
|
||||
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
|
||||
compat_shims=[]
|
||||
# Shotty 0.1.0 imports click.get_app_dir but never calls it. Its
|
||||
# official ZIP omits click and Decky's bundled Python lacks it.
|
||||
# Supply the Linux function only inside this exact plugin's
|
||||
# py_modules path; other plugins keep their original imports.
|
||||
if int(plugin_id)==53 and digest=='ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe' and folder=='Shotty':
|
||||
compat=unpacked/'py_modules/click.py'
|
||||
if compat.exists(): raise RuntimeError('Shotty compatibility module collides with archive')
|
||||
compat.parent.mkdir(parents=True,exist_ok=True)
|
||||
compat.write_text(
|
||||
'"""Frame Control Linux compatibility for Shotty 0.1.0."""\n'
|
||||
'import os\n'
|
||||
'def get_app_dir(app_name, roaming=True, force_posix=False):\n'
|
||||
' if not isinstance(app_name, str) or not app_name:\n'
|
||||
' raise ValueError("app_name must be a nonempty string")\n'
|
||||
' base = os.environ.get("XDG_CONFIG_HOME")\n'
|
||||
' if not base or not os.path.isabs(base):\n'
|
||||
' base = os.path.join(os.path.expanduser("~"), ".config")\n'
|
||||
' return os.path.join(base, app_name)\n',encoding='utf-8')
|
||||
compat_shims.append('shotty-click-app-dir')
|
||||
# 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')
|
||||
@@ -192,7 +241,9 @@ try:
|
||||
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')
|
||||
record={'storeId':int(plugin_id),'sha256':digest,'version':version}
|
||||
if compat_shims: record['compatShims']=compat_shims
|
||||
(unpacked/marker).write_text(json.dumps(record)+'\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')
|
||||
try:
|
||||
|
||||
+201
-34
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkedAtUtc": "2026-10-25T23:19:48.854504+00:00",
|
||||
"checkedAtUtc": "2026-10-26T06:10:58.537029+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. Backend calls were denied in the baseline browser audit. Scoped live backend follow-ups are recorded separately in docs/framey-compatibility.md.",
|
||||
@@ -580,7 +580,7 @@
|
||||
"https://github.com/wynn1212/SDH-PauseGames.git"
|
||||
],
|
||||
"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.",
|
||||
"reason": "Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -613,6 +613,11 @@
|
||||
"sha256": "4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1006,7 +1011,7 @@
|
||||
"https://github.com/popsUlfr/SDH-Notebook.git"
|
||||
],
|
||||
"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.",
|
||||
"reason": "Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Running-game context is now supplied; per-game selection was attempted with a synthetic game and backend calls denied. Real per-game selection, background artwork and worn-VR input remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -1080,6 +1085,11 @@
|
||||
"existingNotesTouched": false
|
||||
},
|
||||
"method": "local Chromium + real Frame SSH; private actual Decky 3.3.0 ARM64 service"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2870,8 +2880,8 @@
|
||||
"https://github.com/andrewburgess/steamdeck-discord-status",
|
||||
"https://github.com/andrewburgess/steamdeck-discord-status.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Detached app overviews are now available, but RunningApps/lifetime observations and Steam artwork helpers still lack a live mapping. Discord activity integration remains unavailable/unverified.",
|
||||
"status": "partial",
|
||||
"reason": "Disconnected panel renders; running-activity construction accepts a synthetic game in local Chromium. Exact official is_connected returned false in real Decky 3.3.0 ARM64. Empty live running-list reads and lifetime registration/cleanup passed. Real game transitions, Discord shortcut detection/connection/activity, local custom artwork and worn VR remain unverified/unsupported.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -2917,6 +2927,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3170,12 +3185,13 @@
|
||||
"https://github.com/tailscale-dev/deck-tailscale"
|
||||
],
|
||||
"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.",
|
||||
"reason": "Settings render locally after the SuspensefulImage fallback. On the real Frame, exact official read-only backend status calls returned live state, exit-node and device entries when ~/.local/bin was in PATH; a transient user unit found the CLI with the proposed managed-service PATH. Live Framey Decky RPC, connection/exit-node changes and worn VR remain unverified; the bundled localhost QR asset is unavailable. No network settings were changed.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame read-only backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 43,
|
||||
@@ -3404,6 +3420,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3774,8 +3795,8 @@
|
||||
"https://github.com/SteamDeckHomebrew/decky-loader",
|
||||
"https://github.com/yt-dlp/yt-dlp"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Frontend audit failed: Cannot read properties of null (reading 'type').",
|
||||
"status": "partial",
|
||||
"reason": "An inert local component lets the exact frontend initialize and render its Settings view in opaque Chromium. Backend get_setting and plugin network were denied in the audit; Steam game-page theme injection has no verified target in Framey. Music search, playback, downloads, settings persistence and worn VR remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -3808,6 +3829,13 @@
|
||||
"sha256": "9ce293ab4db212c03c6b96dfbc5e4d92e874d33387463f53223773921f75a80e",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"renderFollowUp": {
|
||||
"evidence": "docs/evidence/framey-game-theme-music-2026-10-26.md",
|
||||
"mode": "exact archive in opaque local Chromium; backend and network denied",
|
||||
"frontendReady": true,
|
||||
"settingsRendered": true,
|
||||
"steamGamePagePatched": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3854,14 +3882,15 @@
|
||||
"sourceURLs": [
|
||||
"https://github.com/safijari/Shotty"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Library snapshot fixes the frontend allApps crash. Actual published Decky 3.3.0 amd64 backend fails importing click.get_app_dir (ModuleNotFoundError); the archive does not bundle click. Screenshot aggregation/rescue was not run.",
|
||||
"status": "partial",
|
||||
"reason": "The frontend starts with the read-only Frame library snapshot. Frame Control adds a digest-scoped click.get_app_dir compatibility module to the managed install; the exact backend then imports and copies a synthetic Steam screenshot into a temporary Pictures folder in isolated Python. Actual Decky 3.3.0 ARM64 loading, live screenshots/rescue, in-VR panel and physical controller use remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
"real Frame SSH",
|
||||
"isolated Python backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 53,
|
||||
@@ -3892,6 +3921,13 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"backendFollowUp": {
|
||||
"evidence": "docs/evidence/framey-shotty-python-2026-10-26.md",
|
||||
"mode": "exact archive managed transaction and isolated Python import/copy with temporary HOME",
|
||||
"compatShim": "shotty-click-app-dir",
|
||||
"syntheticScreenshotCopied": true,
|
||||
"actualDeckyArm64": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3985,6 +4021,11 @@
|
||||
"sha256": "71dc21ce5f9f4ee0573bd927e9b444d45dc47327888ca78ed063c7bcc57bfac1",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4030,8 +4071,8 @@
|
||||
"https://github.com/saumya-banthia/volume-boost",
|
||||
"https://github.com/saumya-banthia/volume-boost.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Real Decky 3.3.0 ARM64 default-sink reads match independent Frame pactl channel volumes; controls render locally. Sliders/numeric writes passed on an isolated amd64 virtual sink only. Real default-sink writes, boost above 100%, audible output and worn VR unverified.",
|
||||
"status": "works",
|
||||
"reason": "Default-sink controls pass in local Chromium through real Decky 3.3.0 ARM64: linked slider/keyboard and numeric writes, separate channels, re-link and reopen, with independent Pulse readback including 125%/150% while muted. Exact original volume/mute restored. Audible output, Bluetooth sinks and worn VR unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -4159,12 +4200,13 @@
|
||||
"https://github.com/jessebofill/DeckWebBrowser.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Frontend audit failed: deckyFrontendLib.getReactRoot is not a function.",
|
||||
"reason": "The exact frontend now initializes with a null Steam React-root lookup. The browser itself still requires Steam GamepadUI CDP injection and a backend WebSocket unavailable in Framey; no working browser tab was verified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"focused local browser"
|
||||
],
|
||||
"browser": {
|
||||
"id": 59,
|
||||
@@ -4216,6 +4258,17 @@
|
||||
"sha256": "1192864fddc5d72550346b8dff02c21d71f0c40a06ac41c4e67bb6220d267f9d",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"focusedBrowser": {
|
||||
"hostChange": "DFL.getReactRoot returns null without exposing a Steam tree",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"methods": [
|
||||
"set_main_tab",
|
||||
"load"
|
||||
],
|
||||
"backendAndNetwork": "denied",
|
||||
"scope": "initializer only; no usable browser tab or real backend verified"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4447,6 +4500,11 @@
|
||||
"sha256": "e08333859a32b0c177b8e9a7959c5eeeafe38f946578f7f1d161c721f0e05da5",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4608,13 +4666,15 @@
|
||||
"https://github.com/jwhitlow45/free-loader",
|
||||
"https://github.com/jwhitlow45/free-loader.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Unsafe plugin archive member.",
|
||||
"blockInstall": true,
|
||||
"status": "partial",
|
||||
"reason": "The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame pinned install/remove",
|
||||
"local exact-archive transaction",
|
||||
"isolated Python backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 63,
|
||||
@@ -4631,8 +4691,8 @@
|
||||
},
|
||||
"archiveValidation": {
|
||||
"id": 63,
|
||||
"accepted": false,
|
||||
"reason": "Unsafe plugin archive member"
|
||||
"accepted": true,
|
||||
"folder": "free-loader"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
@@ -4643,9 +4703,15 @@
|
||||
"fakeFrame": {
|
||||
"id": 63,
|
||||
"sha256": "05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a",
|
||||
"archiveValidation": "Unsafe plugin archive member",
|
||||
"result": "blocked",
|
||||
"reason": "ValueError: Unsafe plugin archive member."
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed",
|
||||
"reason": "Targeted pinned official fake-Frame install/remove passed after the validator change; the earlier full 116-plugin audit blocked this digest."
|
||||
},
|
||||
"localFollowUp": {
|
||||
"evidence": "docs/evidence/framey-free-loader-duplicates-2026-10-26.md",
|
||||
"hostCommit": "d963305",
|
||||
"frontendReady": true,
|
||||
"backendScope": "isolated official Python settings/defaults and empty local-deals read with a Decky stub"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4964,7 +5030,7 @@
|
||||
"https://github.com/your-username/PlayTime"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. ARM64 backend, live MobX/running-game observations and automatic recording remain unverified/unsupported.",
|
||||
"reason": "Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -5016,6 +5082,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -6470,13 +6541,15 @@
|
||||
"https://github.com/ebenbruyns/junkstore",
|
||||
"https://github.com/ebenbruyns/junkstore.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Local CSS lookup fixes the loading view crash. Actual Decky 3.3.0 amd64 startup/static InitActions return a script execution/JSON error; the published scripts are not executable after extraction. Store install/login/game launch and ARM64 remain unverified.",
|
||||
"status": "partial",
|
||||
"reason": "Official Decky 3.3.0 ARM64 static menu/backend calls pass; local Chromium Games/Epic navigation, About/Dependencies tabs, Back and reopen render with real Frame library metadata. Legendary is absent; dependency install, login, game operations, RSS, optional websocket and worn VR remain unverified. Existing installs need update/reinstall for script permissions.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Linux backend",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 84,
|
||||
@@ -6508,6 +6581,30 @@
|
||||
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"localBackend": {
|
||||
"runtime": "Decky 3.3.0 amd64",
|
||||
"evidence": "docs/evidence/framey-script-entrypoints-2026-10-26.json",
|
||||
"before": "init and InitActions return inner Type:Error",
|
||||
"after": "init Type:Success; InitActions Type:ActionSet/MainMenu"
|
||||
},
|
||||
"realFrame": {
|
||||
"runtime": "Decky 3.3.0 ARM64",
|
||||
"evidence": "docs/evidence/framey-script-entrypoints-2026-10-26.json",
|
||||
"staticActions": [
|
||||
"JunkStoreActions/GetContent",
|
||||
"JunkStoreActions/GetEpicActions",
|
||||
"MainMenu/GetContent",
|
||||
"MainMenu/JunkStoreInit",
|
||||
"init/InitActions"
|
||||
],
|
||||
"browser": "main menu; Games/Epic static tab; back; close/reopen",
|
||||
"installedContext": {
|
||||
"evidence": "docs/evidence/framey-installed-context-2026-10-26.json",
|
||||
"field": "local_per_client_data.installed",
|
||||
"browser": "About/Dependencies, Back, close/reopen; real library projection; static ARM64 backend calls",
|
||||
"installedUnknown": "preserved as undefined; no fabricated false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -6920,6 +7017,11 @@
|
||||
"sha256": "2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7289,6 +7391,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7719,6 +7826,11 @@
|
||||
"sha256": "49309a02fa028d26c77230cc9f70fec9bcce91d59e887bfe8f9d3e42f8a19139",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7906,6 +8018,11 @@
|
||||
"sha256": "b6c514d147cc46bad5331ca9da36628eb9c330976e13735687499a4a9f17f6d4",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -8607,6 +8724,11 @@
|
||||
"sha256": "8a69f45aadce763406b5c27bfef194761006d5e01c5e8841badeb13a27dd0d88",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -8716,6 +8838,11 @@
|
||||
"sha256": "65e021ebb37d2ebfbfbe3dbc8af95aef9556cf484730362c6b26c4ecc3e62ccd",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -9710,6 +9837,11 @@
|
||||
"sha256": "ec59fc8aceb4e1abe2066b3a0707ed6313cabc4421b7c17765397903e2f8fd26",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -10768,12 +10900,13 @@
|
||||
"https://github.com/mpcallahan/DeckyHourNotifications"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Initializer succeeds; timers, reminders, suspend inhibition and audio are unverified.",
|
||||
"reason": "Silent labeled timer create/ticks/pause/cancel, preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with the official Decky 3.3.0 ARM64 backend. Audio, alarms/reminders, suspend inhibition, Steam overlays and worn VR remain unverified; notifications require the panel to stay open.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 124,
|
||||
@@ -10833,7 +10966,7 @@
|
||||
"folder": "alarme-decky-plugin"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
"hardware": "Timers/stopwatch use Python. Optional sleep inhibition opens /dev/uinput; sound and auto-suspend are unverified. Steam game overlays have no target in the isolated panel.",
|
||||
"uiSurfaces": [
|
||||
"local/Steam routes",
|
||||
"toasts",
|
||||
@@ -10845,6 +10978,17 @@
|
||||
"sha256": "17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
},
|
||||
"controlsFollowUp": {
|
||||
"evidence": "docs/evidence/framey-dialog-controls-2026-10-26.json",
|
||||
"mode": "local opaque-iframe Chromium with receipt-bound official ARM64 backend RPC/events; private silent data only",
|
||||
"frontendReady": true,
|
||||
"status": "partial"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -11207,6 +11351,11 @@
|
||||
"sha256": "3def99993c412b669933376b6420e44c68cd1fe9d9baf14b77a1f231242bd7e8",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -11691,6 +11840,11 @@
|
||||
"sha256": "248294b5d6b9d5a5d635ce35f4b5185ce59d4e4fd59353e24df4b29d74c2ade4",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -11922,12 +12076,13 @@
|
||||
"https://github.com/sponsors/santojon"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "The exact chunked frontend now initializes and renders Deck Shelves settings in an opaque local Chromium panel. Backend calls were denied; Home-screen shelf injection has no verified Steam UI target in Framey. Settings writes, real backend behavior and worn VR are unverified.",
|
||||
"reason": "The exact chunked frontend renders settings in opaque local Chromium. The official Python backend saved a shelf and returned it after reopen with a stub Decky module and temporary settings directory. Actual Decky RPC, real Frame settings, Steam Home shelf injection and worn VR remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Python backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 135,
|
||||
@@ -11962,6 +12117,13 @@
|
||||
"mode": "exact archive in opaque local Chromium; backend and network denied",
|
||||
"frontendReady": true,
|
||||
"settingsRendered": true
|
||||
},
|
||||
"backendFollowUp": {
|
||||
"evidence": "docs/evidence/framey-deck-shelves-browser-2026-10-26.md",
|
||||
"mode": "exact archive Python with stub Decky module and temporary settings directory",
|
||||
"defaultSettingsRead": true,
|
||||
"shelfSaveReopen": true,
|
||||
"actualDeckyRpc": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -13228,6 +13390,11 @@
|
||||
"sha256": "33fb674993c9b2bd119f0c6118c465e379f166ec1539c8183fcf7580f1d89577",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+256
-27
@@ -15,7 +15,14 @@
|
||||
// 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});
|
||||
let runningApps = Object.freeze([]), mainRunningApp, runningAvailable = false;
|
||||
let runningArtwork = new Map();
|
||||
const runningListeners = new Set();
|
||||
let runningCursor = 0, runningRequest = null, runningTimer = null, runningClosed = false;
|
||||
window.SteamUIStore = Object.freeze({
|
||||
get RunningApps() {return runningApps;}, get MainRunningApp() {return mainRunningApp;},
|
||||
get FrameyRunningAvailable() {return runningAvailable;},
|
||||
});
|
||||
// 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'],
|
||||
@@ -27,6 +34,25 @@
|
||||
const root = document.getElementById('framey-root');
|
||||
const error = document.getElementById('framey-error');
|
||||
const toastRoot = document.getElementById('framey-toasts');
|
||||
let lastScrollTarget = null;
|
||||
function scrollTarget(element) {
|
||||
for (let node = element; node && node !== document; node = node.parentElement) {
|
||||
if (node.scrollHeight <= node.clientHeight + 1) continue;
|
||||
if (node === document.scrollingElement || /^(auto|scroll|overlay)$/.test(getComputedStyle(node).overflowY))
|
||||
return node;
|
||||
}
|
||||
return document.scrollingElement;
|
||||
}
|
||||
function scrollPlugin(direction) {
|
||||
if (direction !== 'up' && direction !== 'down') return false;
|
||||
const target = lastScrollTarget?.isConnected ? lastScrollTarget : document.scrollingElement;
|
||||
if (!target) return false;
|
||||
const before = target.scrollTop;
|
||||
const maximum = Math.max(0, target.scrollHeight - target.clientHeight);
|
||||
const step = Math.max(120, Math.round(target.clientHeight * 0.75));
|
||||
target.scrollTop = Math.max(0, Math.min(maximum, before + (direction === 'down' ? step : -step)));
|
||||
return target.scrollTop !== before;
|
||||
}
|
||||
const pending = new Map();
|
||||
const routes = new Map();
|
||||
const routeListeners = new Set();
|
||||
@@ -132,6 +158,14 @@
|
||||
return request(method, args, legacy);
|
||||
}
|
||||
addEventListener('message', event => {
|
||||
if (event.source === parent && event.data?.type === 'framey:scroll' &&
|
||||
event.data.session === cacheSession) {
|
||||
if (event.data.direction !== 'up' && event.data.direction !== 'down') return;
|
||||
const moved = scrollPlugin(event.data.direction);
|
||||
parent.postMessage({type:'framey:scroll:result', session:cacheSession,
|
||||
direction:event.data.direction, moved}, '*');
|
||||
return;
|
||||
}
|
||||
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
|
||||
event.data.session === cacheSession && acceptSeed) {
|
||||
const entries = event.data.entries;
|
||||
@@ -158,8 +192,31 @@
|
||||
addEventListener('pointerup', event => {
|
||||
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
|
||||
}, {passive:true});
|
||||
addEventListener('pointermove', event => {
|
||||
if (event.isTrusted) lastScrollTarget = scrollTarget(event.target);
|
||||
}, {passive:true});
|
||||
|
||||
const passthrough = tag => ({children, ...props}) => R.createElement(tag, props, children);
|
||||
const Focusable = ({children, onActivate, onClick, onKeyDown, disabled, tabIndex, role, ...props}) => {
|
||||
const actionable=typeof onActivate==='function' || typeof onClick==='function';
|
||||
const activate = event => {
|
||||
if (disabled || event.defaultPrevented) return;
|
||||
event.stopPropagation();
|
||||
(onClick || onActivate)?.(event);
|
||||
};
|
||||
return R.createElement('div', {...props, role:role || (actionable ? 'button' : undefined),
|
||||
tabIndex:tabIndex ?? (actionable ? (disabled ? -1 : 0) : undefined),
|
||||
'aria-disabled':actionable && disabled ? true : undefined,
|
||||
onClick:actionable ? activate : undefined, onKeyDown:event=>{
|
||||
onKeyDown?.(event);
|
||||
if (actionable && !disabled && !event.defaultPrevented && event.target===event.currentTarget &&
|
||||
(event.key==='Enter' || event.key===' ')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
(onActivate || onClick)?.(event);
|
||||
}
|
||||
}}, children);
|
||||
};
|
||||
const Field = ({label, description, children}) => R.createElement('label', null,
|
||||
R.createElement('strong', null, label), description && R.createElement('small', null, description), children);
|
||||
const ToggleField = ({label, description, checked, disabled, onChange}) => R.createElement(Field, {label, description},
|
||||
@@ -226,11 +283,61 @@
|
||||
const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) =>
|
||||
R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled),
|
||||
onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children);
|
||||
const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null,
|
||||
R.createElement('h2', null, strTitle), children,
|
||||
R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close'));
|
||||
const ConfirmModal = ({strTitle, strDescription, strOKButtonText, strCancelButtonText,
|
||||
strMiddleButtonText, onOK, onCancel, onMiddleButton, closeModal,
|
||||
bOKDisabled, bCancelDisabled, bMiddleDisabled, bAlertDialog, children}) => {
|
||||
const action = (callback, disabled) => () => {
|
||||
if (disabled) return;
|
||||
callback?.();
|
||||
closeModal?.();
|
||||
};
|
||||
return R.createElement('section', null,
|
||||
R.createElement('h2', null, strTitle),
|
||||
strDescription && R.createElement('div', null, strDescription), children,
|
||||
R.createElement('button', {type:'button', disabled:!!bOKDisabled,
|
||||
onClick:action(onOK, bOKDisabled)}, strOKButtonText || 'OK'),
|
||||
onMiddleButton && R.createElement('button', {type:'button', disabled:!!bMiddleDisabled,
|
||||
onClick:action(onMiddleButton, bMiddleDisabled)}, strMiddleButtonText || 'More'),
|
||||
!bAlertDialog && R.createElement('button', {type:'button', disabled:!!bCancelDisabled,
|
||||
onClick:action(onCancel, bCancelDisabled)}, strCancelButtonText || 'Cancel'));
|
||||
};
|
||||
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
|
||||
const closeModal = () => modalRoot.render(null);
|
||||
const modals = [];
|
||||
let modalId = 0;
|
||||
const renderModals = () => {
|
||||
document.getElementById('framey-root').inert = modals.length > 0;
|
||||
modalRoot.render(modals.length ? modals.map((modal,index) =>
|
||||
R.createElement('section', {key:modal.id, className:'framey-dialog', tabIndex:-1,
|
||||
ref:node=>{modal.node=node;}, hidden:index!==modals.length-1,
|
||||
role:'dialog', 'aria-modal':index===modals.length-1 ? true : undefined,
|
||||
'aria-label':typeof modal.element?.props?.strTitle==='string' ? modal.element.props.strTitle : 'Plugin dialog'},
|
||||
!modal.element?.props?.bHideCloseIcon && R.createElement('button', {
|
||||
type:'button', disabled:!!modal.element?.props?.bCancelDisabled,
|
||||
onClick:()=>dismissModal(modal)}, 'Close'), modal.element)) : null);
|
||||
};
|
||||
function dismissModal(modal) {
|
||||
if (modal.element?.props?.bCancelDisabled) return;
|
||||
modal.element?.props?.onCancel?.();
|
||||
modal.dismiss();
|
||||
}
|
||||
addEventListener('keydown', event => {
|
||||
const modal=modals.at(-1);
|
||||
if (!modal) return;
|
||||
if (event.key === 'Tab') {
|
||||
const controls = [...(modal.node?.querySelectorAll('button,input,select,textarea,a[href],[tabindex]') || [])]
|
||||
.filter(node=>!node.disabled && node.tabIndex>=0 && node.getClientRects().length);
|
||||
const index=controls.indexOf(document.activeElement);
|
||||
if (index < 0 || (!event.shiftKey && index===controls.length-1) || (event.shiftKey && index===0)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? controls.at(-1) : controls[0])?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
if (modal.element?.props?.onEscKeypress) modal.element.props.onEscKeypress();
|
||||
else dismissModal(modal);
|
||||
});
|
||||
const localPath = path => typeof path === 'string' && path.length <= 120 &&
|
||||
/^\/[A-Za-z0-9/_-]+$/.test(path) && !path.includes('//');
|
||||
const routePattern = path => typeof path === 'string' && path.length <= 120 &&
|
||||
@@ -283,10 +390,40 @@
|
||||
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
|
||||
};
|
||||
const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;};
|
||||
class MissingSteamComponent {}
|
||||
const showModal = element => {
|
||||
modalRoot.render(R.createElement('section', null,
|
||||
R.createElement('button', {type:'button',onClick:closeModal}, 'Close'), element));
|
||||
return {Close:closeModal};
|
||||
let resolveClosed;
|
||||
const ClosedPromise = new Promise(resolve=>{resolveClosed=resolve;});
|
||||
const modal = {id:++modalId, previousFocus:document.activeElement, source:element};
|
||||
const Close = () => {
|
||||
const index=modals.indexOf(modal);
|
||||
if (index < 0) return;
|
||||
const wasTop=index===modals.length-1;
|
||||
modals.splice(index,1);
|
||||
renderModals();
|
||||
resolveClosed();
|
||||
if (wasTop) setTimeout(()=>{
|
||||
if (modals.length) modals.at(-1).node?.focus();
|
||||
else if (modal.previousFocus?.isConnected) modal.previousFocus.focus();
|
||||
},0);
|
||||
};
|
||||
modal.dismiss = () => {
|
||||
if (!modals.includes(modal)) return;
|
||||
const callback=modal.source?.props?.closeModal;
|
||||
if (callback !== modal.dismiss) callback?.();
|
||||
Close();
|
||||
};
|
||||
modal.element = R.isValidElement(element) ? R.cloneElement(element, {closeModal:modal.dismiss}) : element;
|
||||
modals.push(modal);
|
||||
renderModals();
|
||||
setTimeout(()=>{if(modals.at(-1)===modal) modal.node?.focus();},0);
|
||||
return {Close, ClosedPromise, Update:next=>{
|
||||
if (!modals.includes(modal)) return;
|
||||
// Keep replacement dialogs dismissible inside this isolated host too.
|
||||
modal.source=next;
|
||||
modal.element=R.isValidElement(next) ? R.cloneElement(next, {closeModal:modal.dismiss}) : next;
|
||||
renderModals();
|
||||
}};
|
||||
};
|
||||
// These are Framey's own styles and components, never Steam's webpack cache.
|
||||
// Keep the registry finite: an unknown Steam implementation stays unavailable.
|
||||
@@ -336,7 +473,7 @@
|
||||
definePlugin: init => init,
|
||||
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem,
|
||||
Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField,
|
||||
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
|
||||
Focusable, ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
|
||||
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
|
||||
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
|
||||
Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
|
||||
@@ -359,7 +496,10 @@
|
||||
showModal, showContextMenu:showModal,
|
||||
// Steam app, library, and store patches have no target inside this panel.
|
||||
afterPatch:noPatch, beforePatch:noPatch, replacePatch:noPatch, createReactTreePatcher:noPatch,
|
||||
findInReactTree: () => null, findModule, findModuleChild, findModuleDetailsByExport,
|
||||
// The isolated panel has no Steam React root. Legacy plugins can inspect
|
||||
// this null result and keep their own controls without patching Steam UI.
|
||||
getReactRoot: () => null, findInReactTree: () => null,
|
||||
findModule, findModuleChild, findModuleDetailsByExport,
|
||||
findModuleByExport:(filter, minExports)=>findModuleDetailsByExport(filter,minExports)[0],
|
||||
findModuleExport:(filter, minExports)=>findModuleDetailsByExport(filter,minExports)[1],
|
||||
createModuleMapping, findAllModules:filter=>[...createModuleMapping(filter).values()],
|
||||
@@ -370,7 +510,11 @@
|
||||
for(const classes of classModules.values())
|
||||
for(const name of Object.keys(classes)) if(classes[name]===token)return name;
|
||||
},
|
||||
findSP:()=>null, fakeRenderComponent:()=>null,
|
||||
findSP:()=>null,
|
||||
// Some legacy panels dereference the type of a Steam-only component before
|
||||
// installing a patch. Give them an inert local type; no Steam tree is
|
||||
// rendered or patched in this isolated iframe.
|
||||
fakeRenderComponent:()=>({type:MissingSteamComponent}),
|
||||
appDetailsClasses: {}, appDetailsHeaderClasses: {},
|
||||
useParams: () => currentParams,
|
||||
};
|
||||
@@ -383,8 +527,8 @@
|
||||
PlayAudioURL:()=>{throw Error('Steam UI sound playback is not supported in Framey');},
|
||||
})}),
|
||||
})}));
|
||||
// No Steam game is attached to an isolated panel. Subscriptions have a
|
||||
// disposable handle but emit no fabricated game, power, or input events.
|
||||
// Unsupported subscriptions remain inert. Lifetime callbacks below relay
|
||||
// actual Steam notifications; no power, input or launch events are invented.
|
||||
const subscription = () => {const dispose=()=>{};dispose.unregister=dispose;return dispose;};
|
||||
const register = names => Object.fromEntries(names.map(name=>[name,subscription]));
|
||||
const steamLanguage = () => {
|
||||
@@ -403,7 +547,14 @@
|
||||
...register(['RegisterForSettingsChanges'])},
|
||||
Apps:register(['RegisterForAppDetails','RegisterForGameActionTaskChange',
|
||||
'RegisterForGameActionStart','RegisterForGameActionEnd','RegisterForGameActionUserRequest']),
|
||||
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
|
||||
GameSessions:{...register(['RegisterForScreenshotNotification']),
|
||||
RegisterForAppLifetimeNotifications:listener=>{
|
||||
if (typeof listener !== 'function') throw Error('Invalid Steam lifetime listener');
|
||||
if (runningListeners.size >= 32) throw Error('Too many Steam lifetime listeners');
|
||||
runningListeners.add(listener);
|
||||
const dispose=()=>runningListeners.delete(listener);
|
||||
dispose.unregister=dispose; return dispose;
|
||||
}},
|
||||
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
|
||||
GetSystemInfo:()=>request('GetSystemInfo',[],false,'framey:steam'),
|
||||
UI:register(['RegisterForFocusChangeEvents','RegisterForSystemKeyEvents'])},
|
||||
@@ -428,7 +579,9 @@
|
||||
},
|
||||
},
|
||||
};
|
||||
DFL.Router = {...DFL.Navigation, ...window.SteamUIStore};
|
||||
DFL.Router = {...DFL.Navigation,
|
||||
get RunningApps() {return runningApps;}, get MainRunningApp() {return mainRunningApp;},
|
||||
};
|
||||
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};
|
||||
@@ -541,6 +694,26 @@
|
||||
},
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
};
|
||||
function projectApps(rows, limit) {
|
||||
if (!Array.isArray(rows) || rows.length > limit) throw Error('Invalid Steam app overview');
|
||||
return rows.map(row => {
|
||||
if (!Array.isArray(row) || (row.length !== 8 && row.length !== 9) || !Number.isSafeInteger(row[0]) || row[0] <= 0 ||
|
||||
typeof row[1] !== 'string' || row[1].length > 2048 ||
|
||||
(row[2] != null && typeof row[2] !== 'string') || !Number.isSafeInteger(row[3]) ||
|
||||
typeof row[4] !== 'string' || !/^\d{1,20}$/.test(row[4]) ||
|
||||
row.slice(5,8).some(value=>value != null && (!Number.isFinite(value) || value < 0)) ||
|
||||
(row.length === 9 && row[8] !== null && typeof row[8] !== 'boolean'))
|
||||
throw Error('Invalid Steam app overview');
|
||||
const [appid,display_name,sort_as,app_type,gameid,
|
||||
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played] = row;
|
||||
return {appid,display_name,sort_as,app_type,gameid,
|
||||
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played,
|
||||
// null means Steam supplied no installed boolean, not false. Keep the
|
||||
// detached metadata object for legacy readers; never copy paths/client IDs.
|
||||
// Older/running snapshots omit this field and must not erase library data.
|
||||
...(row.length === 9 ? {local_per_client_data:row[8] === null ? {} : {installed:row[8]}} : {})};
|
||||
});
|
||||
}
|
||||
function setLibrarySnapshot(snapshot) {
|
||||
// These are detached plugin-local values. Patching an overview or its Map
|
||||
// must never mutate Steam's library. Live MobX observations are unsupported.
|
||||
@@ -548,28 +721,82 @@
|
||||
!Array.isArray(snapshot.gameIds) || snapshot.gameIds.length > 10000 ||
|
||||
(snapshot.steamId != null && (typeof snapshot.steamId !== 'string' || !/^\d{17}$/.test(snapshot.steamId))))
|
||||
throw Error('Invalid Steam library snapshot');
|
||||
const apps = snapshot.apps.map(row => {
|
||||
if (!Array.isArray(row) || row.length !== 8 || !Number.isSafeInteger(row[0]) || row[0] <= 0 ||
|
||||
typeof row[1] !== 'string' || row[1].length > 2048 ||
|
||||
(row[2] != null && typeof row[2] !== 'string') || !Number.isSafeInteger(row[3]) ||
|
||||
typeof row[4] !== 'string' || !/^\d{1,20}$/.test(row[4]) ||
|
||||
row.slice(5).some(value=>value != null && (!Number.isFinite(value) || value < 0)))
|
||||
throw Error('Invalid Steam app overview');
|
||||
const [appid,display_name,sort_as,app_type,gameid,
|
||||
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played] = row;
|
||||
return {appid,display_name,sort_as,app_type,gameid,
|
||||
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played};
|
||||
});
|
||||
const apps = projectApps(snapshot.apps, 10000);
|
||||
const map = new Map(apps.map(app=>[app.appid,app]));
|
||||
if (map.size !== apps.length || snapshot.gameIds.some(id=>!Number.isSafeInteger(id) || !map.has(id)))
|
||||
throw Error('Invalid Steam game collection');
|
||||
window.appStore = {allApps:apps, m_mapApps:map,
|
||||
GetAppOverviewByAppID:id=>map.get(Number(id)),
|
||||
GetAppOverviewByGameID:id=>apps.find(app=>app.gameid === String(id)),
|
||||
GetVerticalCapsuleURLForApp:app=>runningArtwork.get(Number(app?.appid))?.[0] || '',
|
||||
GetCustomVerticalCapsuleURLs:app=>[...(runningArtwork.get(Number(app?.appid))?.[1] || [])],
|
||||
};
|
||||
window.collectionStore = {allGamesCollection:{allApps:snapshot.gameIds.map(id=>map.get(id))}};
|
||||
window.App = snapshot.steamId ? {m_CurrentUser:{strSteamID:snapshot.steamId}} : undefined;
|
||||
}
|
||||
function setRunningSnapshot(data) {
|
||||
if (!data || !Array.isArray(data.runningIds) || data.runningIds.length > 64 ||
|
||||
!Array.isArray(data.events) || data.events.length > 64 ||
|
||||
!Array.isArray(data.artwork) || data.artwork.length > 65 ||
|
||||
!Number.isSafeInteger(data.cursor) || data.cursor < 0 || typeof data.lifetimeAvailable !== 'boolean')
|
||||
throw Error('Invalid Steam running snapshot');
|
||||
const apps = projectApps(data.apps, 65), map = new Map(apps.map(app=>[app.appid,app]));
|
||||
if (map.size !== apps.length || new Set(data.runningIds).size !== data.runningIds.length ||
|
||||
data.runningIds.some(id=>!Number.isSafeInteger(id) || !map.has(id)) ||
|
||||
(data.mainAppId !== null && (!Number.isSafeInteger(data.mainAppId) || !map.has(data.mainAppId))))
|
||||
throw Error('Invalid Steam running apps');
|
||||
let sequence = 0;
|
||||
const publicArt = value => typeof value === 'string' && value.length <= 2048 &&
|
||||
/^https:\/\/(?:[a-z0-9-]+\.)*steamstatic\.com\/steam\/apps\/[^?#\s\\]+$/.test(value);
|
||||
const artwork = new Map();
|
||||
for (const row of data.artwork) {
|
||||
if (!Array.isArray(row) || row.length !== 3 || !map.has(row[0]) || artwork.has(row[0]) ||
|
||||
(row[1] !== null && !publicArt(row[1])) || !Array.isArray(row[2]) || row[2].length > 8 ||
|
||||
row[2].some(url=>!publicArt(url))) throw Error('Invalid Steam public artwork');
|
||||
artwork.set(row[0],[row[1],[...row[2]]]);
|
||||
}
|
||||
for (const event of data.events) {
|
||||
if (!Number.isSafeInteger(event?.sequence) || event.sequence <= sequence || event.sequence > data.cursor ||
|
||||
!Number.isSafeInteger(event.unAppID) || event.unAppID <= 0 || typeof event.bRunning !== 'boolean')
|
||||
throw Error('Invalid Steam lifetime event');
|
||||
sequence = event.sequence;
|
||||
}
|
||||
// Update detached stores before callbacks read Router.RunningApps.
|
||||
runningApps = Object.freeze(data.runningIds.map(id=>map.get(id)));
|
||||
mainRunningApp = data.mainAppId === null ? undefined : map.get(data.mainAppId);
|
||||
runningAvailable = true;
|
||||
runningArtwork = artwork;
|
||||
if (window.appStore) for (const app of apps) {
|
||||
const existing = window.appStore.m_mapApps.get(app.appid);
|
||||
if (existing) Object.assign(existing,app);
|
||||
else {window.appStore.m_mapApps.set(app.appid,app);window.appStore.allApps.push(app);}
|
||||
}
|
||||
const previous = runningCursor;
|
||||
runningCursor = data.cursor;
|
||||
for (const event of data.events) if (event.sequence > previous)
|
||||
for (const listener of runningListeners)
|
||||
Promise.resolve().then(()=>runningListeners.has(listener) && listener({unAppID:event.unAppID,bRunning:event.bRunning}))
|
||||
.catch(failure=>console.warn('Steam lifetime listener failed',failure.message));
|
||||
}
|
||||
function refreshRunning() {
|
||||
if (runningClosed) return Promise.resolve();
|
||||
if (runningRequest) return runningRequest;
|
||||
runningRequest = request('running',[runningCursor],false,'framey:running')
|
||||
.then(data=>{if (!runningClosed) setRunningSnapshot(data);})
|
||||
.catch(failure=>{
|
||||
runningAvailable = false;
|
||||
runningApps = Object.freeze([]); mainRunningApp = undefined; runningArtwork = new Map();
|
||||
console.warn('Framey Steam running context unavailable',failure.message);
|
||||
}).finally(()=>{
|
||||
runningRequest = null;
|
||||
if (!runningClosed) runningTimer = setTimeout(refreshRunning,2000);
|
||||
});
|
||||
return runningRequest;
|
||||
}
|
||||
addEventListener('pagehide',()=>{
|
||||
runningClosed = true; clearTimeout(runningTimer); runningListeners.clear();
|
||||
parent.postMessage({type:'framey:running:close',session:cacheSession},'*');
|
||||
});
|
||||
async function start() {
|
||||
try {
|
||||
await seeded;
|
||||
@@ -582,6 +809,8 @@
|
||||
try {setLibrarySnapshot(await request('GetLibrarySnapshot',[],false,'framey:steam'));}
|
||||
catch (failure) {console.warn('Framey Steam library unavailable', failure.message);}
|
||||
}
|
||||
if (/\b(?:RunningApps|MainRunningApp|RegisterForAppLifetimeNotifications)\b/.test(bundleText))
|
||||
await refreshRunning();
|
||||
// Old Decky builds are expressions returning the initializer, rather than
|
||||
// ES modules. A module wrapper preserves the sandbox and avoids eval.
|
||||
const legacy = /^\s*\(function\s*\(/.test(bundleText);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/* Fixed, read-only projection executed in Steam CEF. No plugin source runs here. */
|
||||
(function(payload) {
|
||||
const api = window.SteamClient?.GameSessions;
|
||||
let bridge = window.__frameyRunningBridgeV1;
|
||||
if (bridge && bridge.api !== api) {
|
||||
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);
|
||||
item?.handle?.unregister();
|
||||
sessions.delete(key);
|
||||
if (!sessions.size) {clearInterval(bridge.timer); bridge.timer = null;}
|
||||
};
|
||||
bridge = window.__frameyRunningBridgeV1 = {api, sessions, remove, timer:null};
|
||||
}
|
||||
let item = bridge.sessions.get(payload.session);
|
||||
if (item && item.pluginId !== payload.pluginId) throw Error('Framey running session belongs to another plugin');
|
||||
if (!payload.active) {bridge.remove(payload.session); return null;}
|
||||
const store = window.SteamUIStore;
|
||||
if (!Array.isArray(store?.RunningApps)) throw Error('Steam running apps are unavailable');
|
||||
const apps = store.RunningApps;
|
||||
if (apps.length > 64) throw Error('Too many running Steam apps');
|
||||
const main = store.MainRunningApp;
|
||||
const project = app => [app.appid, app.display_name, app.sort_as, app.app_type, String(app.gameid),
|
||||
app.minutes_playtime_forever, app.minutes_playtime_last_two_weeks, app.rt_last_time_played];
|
||||
// Include a selected main app even if Steam's list changes between the reads.
|
||||
const rows = apps.map(project);
|
||||
if (main && !apps.some(app => app.appid === main.appid)) rows.push(project(main));
|
||||
const publicArt = value => {
|
||||
if (typeof value !== 'string' || value.length > 2048) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port ||
|
||||
!/(^|\.)steamstatic\.com$/.test(url.hostname) || !url.pathname.startsWith('/steam/apps/')) return null;
|
||||
return url.origin + url.pathname; // Never copy local artwork paths or query data.
|
||||
} catch (_) {return null;}
|
||||
};
|
||||
const artwork = [...apps, ...(main && !apps.some(app=>app.appid === main.appid) ? [main] : [])].map(app => {
|
||||
const art = window.appStore;
|
||||
const vertical = publicArt(art?.GetVerticalCapsuleURLForApp?.(app));
|
||||
const custom = art?.GetCustomVerticalCapsuleURLs?.(app);
|
||||
return [app.appid, vertical, Array.isArray(custom) ? custom.slice(0,8).map(publicArt).filter(Boolean) : []];
|
||||
});
|
||||
if (!item) {
|
||||
if (bridge.sessions.size >= 4) throw Error('Too many Framey running sessions');
|
||||
item = {pluginId:payload.pluginId, queue:[], sequence:0, touched:Date.now(), handle:null};
|
||||
if (typeof api?.RegisterForAppLifetimeNotifications === 'function') {
|
||||
item.handle = api.RegisterForAppLifetimeNotifications(event => {
|
||||
// This queue contains actual Steam notifications, never snapshot diffs.
|
||||
if (!Number.isSafeInteger(event?.unAppID) || event.unAppID <= 0 || typeof event.bRunning !== 'boolean') return;
|
||||
item.queue.push({sequence:++item.sequence, unAppID:event.unAppID, bRunning:event.bRunning});
|
||||
if (item.queue.length > 64) item.queue.shift();
|
||||
});
|
||||
}
|
||||
bridge.sessions.set(payload.session, item);
|
||||
if (!bridge.timer) bridge.timer = setInterval(() => {
|
||||
for (const [key, state] of bridge.sessions)
|
||||
if (Date.now() - state.touched > 15000) bridge.remove(key);
|
||||
}, 5000);
|
||||
}
|
||||
item.touched = Date.now();
|
||||
return {apps:rows, artwork, runningIds:apps.map(app => app.appid), mainAppId:main?.appid ?? null,
|
||||
events:item.queue.filter(event => event.sequence > payload.cursor), cursor:item.sequence,
|
||||
lifetimeAvailable:!!item.handle};
|
||||
})
|
||||
+77
-31
@@ -11,6 +11,7 @@ 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')
|
||||
RECEIPT = re.compile(r'[0-9a-f]{64}\Z')
|
||||
MODULE_PATH = re.compile(r'[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*/[A-Za-z0-9_.-]+\.js\Z|index\.js\Z')
|
||||
PLUGINS = Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
|
||||
@@ -19,7 +20,17 @@ class PanelError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _owned_dist(plugin_id):
|
||||
def _receipt_digest(path):
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > 16_384:
|
||||
raise FileNotFoundError('Framey plugin store receipt unavailable')
|
||||
meta = json.loads(path.read_text())
|
||||
digest = meta.get('sha256') if isinstance(meta, dict) else None
|
||||
if not isinstance(digest, str) or not RECEIPT.fullmatch(digest):
|
||||
raise FileNotFoundError('Framey plugin store receipt unavailable')
|
||||
return meta, digest
|
||||
|
||||
|
||||
def _owned_dist(plugin_id, expected_receipt=None):
|
||||
"""Locate a compatible store-owned frontend without following linked paths."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0 or not PLUGINS.is_dir() or PLUGINS.is_symlink():
|
||||
raise FileNotFoundError('Framey plugin unavailable')
|
||||
@@ -30,16 +41,17 @@ def _owned_dist(plugin_id):
|
||||
manifest = folder/'plugin.json'
|
||||
dist = folder/'dist'
|
||||
entry = dist/'index.js'
|
||||
if any(p.is_symlink() for p in (receipt, manifest, dist, entry)):
|
||||
if any(p.is_symlink() for p in (manifest, dist, entry)):
|
||||
continue
|
||||
try:
|
||||
if not receipt.is_file() or receipt.stat().st_size > 16_384:
|
||||
continue
|
||||
meta = json.loads(receipt.read_text())
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
if meta.get('storeId') != plugin_id:
|
||||
continue
|
||||
meta, digest = _receipt_digest(receipt)
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
continue
|
||||
if meta.get('storeId') != plugin_id:
|
||||
continue
|
||||
try:
|
||||
if expected_receipt is not None and digest != expected_receipt:
|
||||
raise FileNotFoundError('Framey plugin changed; reopen its panel')
|
||||
if not manifest.is_file() or manifest.stat().st_size > 16_384:
|
||||
raise FileNotFoundError('Framey plugin manifest unavailable')
|
||||
data = json.loads(manifest.read_text())
|
||||
@@ -75,7 +87,11 @@ def _owned_dist(plugin_id):
|
||||
supported = True
|
||||
if not supported:
|
||||
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
|
||||
return dist, bundle
|
||||
if _receipt_digest(receipt) != (meta, digest):
|
||||
raise FileNotFoundError('Framey plugin changed; reopen its panel')
|
||||
return dist, bundle, digest
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except (OSError, ValueError):
|
||||
raise FileNotFoundError('Framey plugin files are invalid')
|
||||
raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store')
|
||||
@@ -86,11 +102,13 @@ def local_bundle(plugin_id):
|
||||
return _owned_dist(plugin_id)[1]
|
||||
|
||||
|
||||
def local_module(plugin_id, name):
|
||||
def local_module(plugin_id, receipt, name):
|
||||
"""Serve only JavaScript within a store-owned dist directory for module imports."""
|
||||
if not isinstance(name, str) or not MODULE_PATH.fullmatch(name):
|
||||
raise FileNotFoundError('Framey module path unavailable')
|
||||
dist, bundle = _owned_dist(plugin_id)
|
||||
if not isinstance(receipt, str) or not RECEIPT.fullmatch(receipt):
|
||||
raise FileNotFoundError('Framey module receipt unavailable')
|
||||
dist, bundle, _ = _owned_dist(plugin_id, receipt)
|
||||
if name == 'index.js':
|
||||
return bundle
|
||||
target = dist
|
||||
@@ -100,14 +118,17 @@ def local_module(plugin_id, name):
|
||||
raise FileNotFoundError('Framey module path unavailable')
|
||||
if not target.is_file() or target.stat().st_size > 8*1024*1024:
|
||||
raise FileNotFoundError('Framey module unavailable')
|
||||
return target.read_bytes()
|
||||
data = target.read_bytes()
|
||||
if _receipt_digest(dist.parent/'.frame-control-store.json')[1] != receipt:
|
||||
raise FileNotFoundError('Framey plugin changed; reopen its panel')
|
||||
return data
|
||||
|
||||
|
||||
def panel(plugin_id):
|
||||
"""The plugin receives no Frame UI key and is sandboxed to an opaque origin."""
|
||||
bundle = local_bundle(plugin_id)
|
||||
_, bundle, receipt = _owned_dist(plugin_id)
|
||||
encoded = base64.b64encode(bundle).decode('ascii')
|
||||
module = f' data-module="/framey/module/{plugin_id}/index.js"' if b'./chunks/' in bundle else ''
|
||||
module = f' data-module="/framey/module/{plugin_id}/{receipt}/index.js"' if b'./chunks/' in bundle else ''
|
||||
return ('''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Framey plugin</title><style>body{font:21px/1.4 system-ui;background:#0d1925;color:#f3f7fa;margin:0;padding:20px}
|
||||
button,select,input{font:inherit;min-height:52px;background:#23618c;color:#fff;border:1px solid #72baf0;border-radius:9px;padding:8px}
|
||||
@@ -118,7 +139,9 @@ label{display:grid;gap:6px;margin:10px 0}small{color:#b9c9d6}
|
||||
.framey-sections,.framey-library{display:flex;flex-direction:column;gap:12px}.framey-actions{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.framey-action{min-height:52px}.framey-button-child{display:inline-flex;align-items:center;gap:8px}.framey-green{background:#246b42}
|
||||
.framey-long-title{overflow-wrap:anywhere}.framey-muted{background:#1b3042}.framey-image{max-width:100%;object-fit:contain}
|
||||
.framey-focus:focus-visible{outline:4px solid #f8d370}</style>
|
||||
.framey-focus:focus-visible{outline:4px solid #f8d370}
|
||||
.framey-dialog{position:fixed;inset:12px;margin:0;z-index:100;overflow:auto;box-shadow:0 0 0 24px #0d1925e6}
|
||||
.framey-dialog[hidden]{display:none}</style>
|
||||
<p id="framey-error" role="status"></p><div id="framey-toasts" aria-live="polite"></div><div id="framey-root"></div><div id="framey-modal"></div>
|
||||
<script id="framey-bundle" type="application/json"''' + module + '''>''' + encoded + '''</script>
|
||||
<script src="/framey-runtime/react.js"></script><script src="/framey-runtime/react-dom.js"></script>
|
||||
@@ -139,7 +162,18 @@ finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
def _current_plugin(ssh, plugin_id, receipt):
|
||||
if not isinstance(receipt, str) or not RECEIPT.fullmatch(receipt):
|
||||
raise PanelError('Reopen the plugin panel to refresh its store receipt')
|
||||
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')
|
||||
if found[0].get('sha256') != receipt:
|
||||
raise PanelError('The plugin changed; close and reopen its panel')
|
||||
return found[0]
|
||||
|
||||
|
||||
def call_method(ssh, plugin_id, method, args, legacy=False, receipt=None):
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(method, str) or not METHOD.fullmatch(method):
|
||||
@@ -148,10 +182,8 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
raise PanelError('Plugin method arguments are too large')
|
||||
if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))):
|
||||
raise PanelError('Legacy plugin methods need one keyword argument object')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
plugin = _current_plugin(ssh, plugin_id, receipt)
|
||||
payload = {'name':plugin['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
# Library-sized arguments can exceed the OS limit for a single SSH command
|
||||
# argument. Send them as a quoted Python literal on stdin instead.
|
||||
script = METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__', repr(json.dumps(payload)))
|
||||
@@ -173,7 +205,8 @@ STEAM_READS = {
|
||||
const apps = Array.from(appStore.allApps);
|
||||
if (apps.length > 10000) throw Error('Steam library exceeds the Framey snapshot limit');
|
||||
return {apps:apps.map(a=>[a.appid,a.display_name,a.sort_as,a.app_type,String(a.gameid),
|
||||
a.minutes_playtime_forever,a.minutes_playtime_last_two_weeks,a.rt_last_time_played]),
|
||||
a.minutes_playtime_forever,a.minutes_playtime_last_two_weeks,a.rt_last_time_played,
|
||||
typeof a.local_per_client_data?.installed === 'boolean' ? a.local_per_client_data.installed : null]),
|
||||
gameIds:Array.from(collectionStore.allGamesCollection.allApps,a=>a.appid),
|
||||
steamId:window.App?.m_CurrentUser?.strSteamID || null};
|
||||
})()''',
|
||||
@@ -192,15 +225,13 @@ finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def read_steam(ssh, plugin_id, method):
|
||||
def read_steam(ssh, plugin_id, method, receipt=None):
|
||||
"""Read one explicitly supported Steam API for an installed store panel."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(method, str) or method not in STEAM_READS:
|
||||
raise PanelError('This Steam API is unavailable in Framey')
|
||||
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')
|
||||
_current_plugin(ssh, plugin_id, receipt)
|
||||
expression = '(async()=>await ' + STEAM_READS[method] + ')()'
|
||||
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
|
||||
'__FRAMEY_STEAM_LIMIT__', str(2*1024*1024 if method == 'GetLibrarySnapshot' else 256*1024))
|
||||
@@ -219,7 +250,7 @@ finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def poll_events(ssh, plugin_id, session, events, cursor=0):
|
||||
def poll_events(ssh, plugin_id, session, events, cursor=0, receipt=None):
|
||||
"""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')
|
||||
@@ -230,11 +261,26 @@ def poll_events(ssh, plugin_id, session, events, cursor=0):
|
||||
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}
|
||||
plugin = _current_plugin(ssh, plugin_id, receipt)
|
||||
payload = {'name':plugin['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))
|
||||
|
||||
|
||||
def poll_running(ssh, plugin_id, session, active, cursor=0, receipt=None):
|
||||
"""Project running apps and actual lifetime events for one installed panel."""
|
||||
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 running session')
|
||||
if type(active) is not bool or type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
|
||||
raise PanelError('Invalid Framey running request')
|
||||
_current_plugin(ssh, plugin_id, receipt)
|
||||
payload = {'pluginId':plugin_id, 'session':session, 'active':active, 'cursor':cursor}
|
||||
source = Path(__file__).with_name('framey-running.js').read_text(encoding='utf-8')
|
||||
expression = '(' + source + ')(' + json.dumps(payload) + ')'
|
||||
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
|
||||
'__FRAMEY_STEAM_LIMIT__', str(256*1024))
|
||||
return json.loads(ssh('python3 -', stdin=script, timeout=25))
|
||||
+47
-7
@@ -53,7 +53,8 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
|
||||
<label>Show <select id="deckyStoreFilter"><option value="all">All plugins</option><option value="compatible">No known install blocker</option><option value="installed">Installed plugins</option></select></label></div>
|
||||
<p id="deckyStoreSummary" role="status" aria-live="polite"></p><div id="deckyStoreList" class="stack"></div>
|
||||
<div class="row" style="margin-top:14px"><button id="deckyStorePrevious" class="secondary">Previous</button><span id="deckyStorePage"></span><button id="deckyStoreNext" class="secondary">Next</button></div></div>
|
||||
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button></div>
|
||||
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button>
|
||||
<button id="frameyScrollUp" class="secondary">Scroll plugin up</button><button id="frameyScrollDown" class="secondary">Scroll plugin down</button></div>
|
||||
<p id="frameyCompatibility"></p>
|
||||
<p id="frameyPanelStatus" role="status">Loading plugin panel…</p>
|
||||
<iframe id="frameyFrame" title="Framey Decky plugin panel" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer"></iframe></div>
|
||||
@@ -237,15 +238,26 @@ 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);
|
||||
}
|
||||
let frameyEventsActive=false;
|
||||
let frameyEventsActive=false,frameyRunningActive=false;
|
||||
function closeFrameyEvents(){
|
||||
if(frameyRunningActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/running',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,active:false}).catch(()=>{});
|
||||
frameyRunningActive=false;
|
||||
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
api('/api/framey/events',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
frameyEventsActive=false;
|
||||
}
|
||||
function closeFrameyPanel(){closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
|
||||
byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;}
|
||||
byId('frameyClose').onclick=closeFrameyPanel;
|
||||
function scrollFramey(direction){
|
||||
if(activeFrameyId===null||!frameySession||!['up','down'].includes(direction))return;
|
||||
byId('frameyFrame').contentWindow?.postMessage({type:'framey:scroll',session:frameySession,direction},'*');
|
||||
}
|
||||
byId('frameyScrollUp').onclick=()=>scrollFramey('up');
|
||||
byId('frameyScrollDown').onclick=()=>scrollFramey('down');
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
function frameyCacheRead(id){
|
||||
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
|
||||
@@ -266,6 +278,12 @@ function frameyCacheSave(id,entries){
|
||||
window.addEventListener('message',async event=>{
|
||||
if(activeFrameyId===null||event.source!==byId('frameyFrame').contentWindow)return;
|
||||
const req=event.data;if(!req||typeof req!=='object')return;
|
||||
if(req.type==='framey:scroll:result'&&req.session===frameySession){
|
||||
if(['up','down'].includes(req.direction)&&typeof req.moved==='boolean')
|
||||
byId('frameyPanelStatus').textContent=req.moved?'Scrolled plugin '+req.direction:
|
||||
'No more plugin content in that direction';
|
||||
return;
|
||||
}
|
||||
if(req.type==='framey:cache:init'&&req.session===frameySession){
|
||||
event.source.postMessage({type:'framey:cache:seed',session:frameySession,
|
||||
entries:frameyCacheRead(activeFrameyId)},'*');return;
|
||||
@@ -278,6 +296,25 @@ 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:running:close'){
|
||||
if(req.session===frameySession&&frameyRunningActive){
|
||||
frameyRunningActive=false;
|
||||
api('/api/framey/running',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,active:false}).catch(()=>{});
|
||||
}return;
|
||||
}
|
||||
if(req.type==='framey:running'){
|
||||
if(req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||
|
||||
!Array.isArray(req.args)||req.args.length!==1||!Number.isSafeInteger(req.args[0])||req.args[0]<0)return;
|
||||
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
|
||||
reply={type:'framey:reply',id:req.id};
|
||||
frameyRunningActive=true;
|
||||
try{const data=await api('/api/framey/running',{id:pluginId,receipt,session,active:true,cursor:req.args[0]});reply.result=data.result;}
|
||||
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:events:close'){
|
||||
if(req.session===frameySession)closeFrameyEvents();return;
|
||||
}
|
||||
@@ -286,9 +323,11 @@ window.addEventListener('message',async event=>{
|
||||
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};
|
||||
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
|
||||
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]});}
|
||||
try{reply.result=await api('/api/framey/events',{id:pluginId,receipt,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,'*');
|
||||
@@ -297,9 +336,10 @@ window.addEventListener('message',async event=>{
|
||||
if(!['framey:call','framey:steam'].includes(req.type)||req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const steamRead=req.type==='framey:steam';
|
||||
if(steamRead&&(req.args.length!==0||!['GetIPCountry','GetInstallFolders','GetSystemInfo','GetLibrarySnapshot'].includes(req.method)))return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
|
||||
reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api(steamRead?'/api/framey/steam':'/api/framey/method',
|
||||
{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
{id:pluginId,receipt,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
});
|
||||
|
||||
+17
-5
@@ -581,7 +581,8 @@ def framey_method_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'), body.get('legacy', False))
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'),
|
||||
body.get('legacy', False), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
@@ -590,7 +591,7 @@ def framey_steam_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.read_steam(ssh, body.get('id'), body.get('method'))
|
||||
return framey_panel.read_steam(ssh, body.get('id'), body.get('method'), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
@@ -600,7 +601,17 @@ def framey_events_action(body):
|
||||
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))
|
||||
body.get('events'), body.get('cursor', 0), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
def framey_running_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_running(ssh, body.get('id'), body.get('session'),
|
||||
body.get('active'), body.get('cursor', 0), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
@@ -3473,6 +3484,7 @@ POST = {
|
||||
"/api/framey/method": framey_method_action,
|
||||
"/api/framey/steam": framey_steam_action,
|
||||
"/api/framey/events": framey_events_action,
|
||||
"/api/framey/running": framey_running_action,
|
||||
"/api/framey/check": framey_check_action,
|
||||
"/api/headset": headset_action,
|
||||
"/api/headset/quick": headset_quick,
|
||||
@@ -3693,8 +3705,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({'error':'Framey panel unavailable'}, 404)
|
||||
elif path.startswith('/framey/module/'):
|
||||
try:
|
||||
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
|
||||
module = framey_panel.local_module(int(plugin_id), name)
|
||||
plugin_id, receipt, name = path.removeprefix('/framey/module/').split('/', 2)
|
||||
module = framey_panel.local_module(int(plugin_id), receipt, name)
|
||||
# The plugin iframe has an opaque origin; only its module
|
||||
# imports need cross-origin access to these public JS files.
|
||||
self.send_bytes(module, 'text/javascript; charset=utf-8', framey=True,
|
||||
|
||||
Reference in new issue
Block a user