Compare commits

...
Author SHA1 Message Date
SAPHID b0d8bf3968 Fix receipt-bound modules in Framey browser audit 2026-10-26 13:08:50 +00:00
Alex Southwell b05a666e50 Merge pull request #327 from saphid/docs/v0446-release-evidence
Document v0.4.46 release verification
2026-10-26 12:56:58 +00:00
SAPHID 8feb19a6d3 Document v0.4.46 release verification 2026-10-26 12:49:18 +00:00
Alex Southwell f84a4da9b1 Merge pull request #326 from saphid/release/v0.4.46
Prepare Frame Control v0.4.46
2026-10-26 12:31:06 +00:00
SAPHID fc978a2afd Record exact v0.4.46 candidate Frame smoke 2026-10-26 12:19:32 +00:00
SAPHID 9aff6adcec Merge remote-tracking branch 'origin/main' into release/v0.4.46 2026-10-26 12:17:07 +00:00
SAPHID 2d8b70d607 Prepare Frame Control v0.4.46 candidate 2026-10-26 12:16:54 +00:00
Alex Southwell 80657b8a8e Merge pull request #325 from saphid/fix/framey-identical-zip-members
Accept identical Decky ZIP members for Free Loader
2026-10-26 12:16:23 +00:00
SAPHID 21b85ba99d Record full Free Loader regression run 2026-10-26 12:07:13 +00:00
SAPHID a38d598f14 Merge remote-tracking branch 'origin/main' into fix/framey-identical-zip-members 2026-10-26 12:03:23 +00:00
SAPHID 08567ca571 Accept identical Decky ZIP members for Free Loader 2026-10-26 12:03:13 +00:00
Alex Southwell 4fc5b83873 Merge pull request #324 from saphid/fix/framey-worn-proximity
Require proximity telemetry before worn Framey input
2026-10-26 12:00:16 +00:00
Alex Southwell d96330554a Merge pull request #323 from saphid/fix/framey-decky-user-path
Expose user tools to managed Decky plugins
2026-10-26 11:49:33 +00:00
SAPHID 385ae8290b Require proximity telemetry before worn Framey input 2026-10-26 11:45:51 +00:00
SAPHID 858edaf7eb Expose user tools to managed Decky plugins 2026-10-26 11:40:01 +00:00
Alex Southwell f5213b7c44 Merge pull request #322 from saphid/fix/framey-vr-activity-row
Read SteamVR activity from the status row
2026-10-26 11:28:39 +00:00
SAPHID 0219cd10f0 Read SteamVR activity from the status row 2026-10-26 11:18:03 +00:00
Alex Southwell b0c0599f45 Merge pull request #321 from saphid/docs/v0445-release-evidence
Record v0.4.45 release verification
2026-10-26 11:13:32 +00:00
SAPHID 1dcca6d13a Record v0.4.45 release verification 2026-10-26 11:04:13 +00:00
Alex Southwell 212f0af35c Merge pull request #320 from saphid/release/v0.4.45
Prepare Frame Control v0.4.45
2026-10-26 10:45:56 +00:00
SAPHID 5da87c8dc0 Record exact v0.4.45 candidate Frame smoke 2026-10-26 10:35:54 +00:00
SAPHID 59ea09325c Prepare Frame Control v0.4.45 candidate 2026-10-26 10:33:21 +00:00
Alex Southwell f88e2ed209 Merge pull request #317 from saphid/feat/framey-next-backend-compat
Fix Decky activation and confirmation controls in Framey
2026-10-26 10:25:37 +00:00
Alex Southwell 43a6b5e008 Merge pull request #316 from saphid/feat/framey-receipt-modules
Bind Framey plugin modules to installed receipts
2026-10-26 10:13:21 +00:00
Alex Southwell 221d2b26ab Merge pull request #318 from saphid/docs/v0444-release-evidence
Record v0.4.44 release verification
2026-10-26 10:10:11 +00:00
SAPHID 087bb1ef9f Keep updated Framey confirmation dialogs dismissible 2026-10-26 10:10:05 +00:00
SAPHID 70b4bb1368 Merge remote-tracking branch 'origin/main' into docs/v0444-release-evidence 2026-10-26 10:01:30 +00:00
Alex Southwell b961fc4cef Merge pull request #319 from saphid/fix/fakeframe-pacman-retry
Retry truncated fake Frame package downloads
2026-10-26 10:00:55 +00:00
SAPHID 9af06c7845 Retry transient fake Frame package downloads 2026-10-26 09:52:47 +00:00
SAPHID 9359b4ffbd Record v0.4.44 release verification 2026-10-26 09:40:30 +00:00
SAPHID 0051ac4570 Fix Decky activation and confirmation controls in Framey 2026-10-26 09:32:34 +00:00
Alex Southwell 37e25e62ac Merge pull request #315 from saphid/release/v0.4.44
Prepare Frame Control v0.4.44
2026-10-26 09:17:25 +00:00
SAPHID dddfc6bea8 Bind Framey plugin modules to installed receipts 2026-10-26 09:16:07 +00:00
SAPHID d1e526cb4b Record exact v0.4.44 candidate Frame smoke 2026-10-26 09:01:33 +00:00
SAPHID 8230fdb19d Merge remote-tracking branch 'origin/main' into release/v0.4.44 2026-10-26 08:59:06 +00:00
Alex Southwell 2937a12864 Merge pull request #314 from saphid/docs/v0443-release-evidence
Document v0.4.43 tagged release verification
2026-10-26 08:58:23 +00:00
SAPHID 71fe4a3ebb Prepare Frame Control v0.4.44 candidate 2026-10-26 08:50:12 +00:00
SAPHID 42f3b2e1e7 Record post-tag Framey scroll button exercise 2026-10-26 08:47:27 +00:00
SAPHID ca4c370b33 Document v0.4.43 tagged release verification 2026-10-26 08:41:10 +00:00
Alex Southwell c50f96510f Merge pull request #313 from saphid/feat/framey-installed-app-context
fix(framey): restore Junk-Store About with installed metadata
2026-10-26 08:31:28 +00:00
Alex Southwell 43e801091a Merge pull request #312 from saphid/feat/framey-scroll-buttons
Add visible scroll controls to Framey plugin panels
2026-10-26 08:30:07 +00:00
Alex Southwell b4e632b218 Merge pull request #311 from saphid/release/v0.4.43
Prepare Frame Control v0.4.43
2026-10-26 08:25:09 +00:00
SAPHID d84491c0ec fix(framey): project installed metadata for Junk-Store About 2026-10-26 08:23:50 +00:00
SAPHID 83b8d13846 Add visible scroll controls to Framey plugin panels 2026-10-26 08:20:11 +00:00
SAPHID 340ca88359 Record test-only main merge in v0.4.43 candidate 2026-10-26 08:10:20 +00:00
SAPHID e62589417b Merge remote-tracking branch 'origin/main' into release/v0.4.43 2026-10-26 08:09:54 +00:00
Alex Southwell 03b3796d8d Merge pull request #310 from saphid/fix/windows-wireless-live-retry
Retry transient Windows wireless inventory smoke timeout
2026-10-26 08:09:31 +00:00
SAPHID 93da4e466f Prepare Frame Control v0.4.43 candidate 2026-10-26 08:05:12 +00:00
Alex Southwell 05093ea4d4 Merge pull request #309 from saphid/feat/framey-script-entrypoints
fix(framey): restore executable scripts for Junk-Store
2026-10-26 08:03:32 +00:00
SAPHID 42492e7879 Retry transient Windows wireless smoke timeout once 2026-10-26 07:45:31 +00:00
49 changed files with 2019 additions and 120 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.42",
"version": "0.4.46",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.42",
"version": "0.4.46",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.42",
"version": "0.4.46",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -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,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.
@@ -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,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,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,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,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,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.
+79
View File
@@ -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.
+58
View File
@@ -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.
+61
View File
@@ -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.
+58
View File
@@ -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.
+1
View File
@@ -63,6 +63,7 @@ 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 |
| `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. |
+9 -7
View File
@@ -4,7 +4,7 @@ Snapshot: 2026-10-26T06:10:58.537029+00:00; 116 current visible official plugins
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
**Works: 4 · Partial: 49 · Broken: 52 · N/A: 11.**
**Works: 4 · Partial: 50 · Broken: 51 · 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. 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. 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 followLine truncated
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
@@ -59,7 +61,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 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 |
@@ -73,7 +75,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 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. 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 |
@@ -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 | partial | Executable script extraction repaired. Official Decky 3.3.0 ARM64 static menu/backend calls and local Chromium Games/Epic navigation, back and reopen pass. About still fails on missing installed-app metadata; Legendary is absent. Dependency install, login, game operations, RSS and worn VR remain unverified. Existing installs need update/reinstall. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
| 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 |
@@ -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 |
+13 -1
View File
@@ -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
@@ -209,6 +217,10 @@ 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.
+8 -2
View File
@@ -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
+36 -16
View File
@@ -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
@@ -29,6 +30,39 @@ window.fetch=()=>Promise.reject(Error('Audit blocks plugin network'));
window.WebSocket=class{constructor(){throw Error('Audit blocks WebSocket');}};
</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.
@@ -116,27 +150,13 @@ def main():
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):
+5 -1
View File
@@ -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. '
@@ -64,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. 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. 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 '
@@ -73,6 +74,9 @@ def render(data):
'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 '
+10 -3
View File
@@ -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
+19 -6
View File
@@ -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))
+24 -1
View File
@@ -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)
+7 -2
View File
@@ -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.
Binary file not shown.
+2
View File
@@ -17,6 +17,8 @@ PLUGINS = (
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
{'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'},
)
+126 -3
View File
@@ -7,6 +7,7 @@ const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__s
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,7 +22,8 @@ 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.
@@ -265,6 +267,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);
@@ -283,11 +296,12 @@ 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]],runningIds:[10],mainAppId:10,events:[],cursor:0,lifetimeAvailable:true};
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)});
@@ -296,6 +310,7 @@ 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);
@@ -343,7 +358,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({});
@@ -374,6 +468,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);
+16 -1
View File
@@ -69,7 +69,6 @@ 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]};
@@ -96,3 +95,19 @@ 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);
+6 -1
View File
@@ -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())
+33
View File
@@ -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):
@@ -152,6 +175,16 @@ class StoreTransaction(unittest.TestCase):
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
+51
View File
@@ -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)
+6 -4
View File
@@ -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())
+43 -11
View File
@@ -112,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;
@@ -217,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)
@@ -250,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
@@ -355,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__':
+16 -2
View File
@@ -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]}
+58 -3
View File
@@ -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'], [])
+6
View File
@@ -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')
+7
View File
@@ -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')
+25 -6
View File
@@ -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,17 +174,26 @@ 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
+37 -16
View File
@@ -3185,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,
@@ -4650,13 +4651,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,
@@ -4673,8 +4676,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.",
@@ -4685,9 +4688,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"
}
},
{
@@ -6518,7 +6527,7 @@
"https://github.com/ebenbruyns/junkstore.git"
],
"status": "partial",
"reason": "Executable script extraction repaired. Official Decky 3.3.0 ARM64 static menu/backend calls and local Chromium Games/Epic navigation, back and reopen pass. About still fails on missing installed-app metadata; Legendary is absent. Dependency install, login, game operations, RSS and worn VR remain unverified. Existing installs need update/reinstall.",
"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",
@@ -6575,7 +6584,12 @@
"init/InitActions"
],
"browser": "main menu; Games/Epic static tab; back; close/reopen",
"remainingFailure": "About: Cannot read properties of undefined (reading 'installed'); detached app overview lacks local_per_client_data.installed"
"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"
}
}
},
{
@@ -10871,12 +10885,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,
@@ -10936,7 +10951,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",
@@ -10953,6 +10968,12 @@
"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"
}
},
{
+145 -11
View File
@@ -34,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();
@@ -139,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;
@@ -165,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},
@@ -233,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 &&
@@ -291,9 +391,38 @@
};
const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;};
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.
@@ -343,7 +472,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,
@@ -563,16 +692,21 @@
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 || !Number.isSafeInteger(row[0]) || row[0] <= 0 ||
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).some(value=>value != null && (!Number.isFinite(value) || value < 0)))
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};
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) {
+40 -17
View File
@@ -20,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')
@@ -31,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())
@@ -76,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')
@@ -87,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
@@ -101,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}
@@ -119,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>
@@ -183,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};
})()''',
+14 -1
View File
@@ -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>
@@ -251,6 +252,12 @@ function closeFrameyEvents(){
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))||'{}');
@@ -271,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;
+2 -2
View File
@@ -3705,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,