Compare commits

...
Author SHA1 Message Date
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
SAPHID dddfc6bea8 Bind Framey plugin modules to installed receipts 2026-10-26 09:16:07 +00:00
19 changed files with 938 additions and 51 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.44",
"version": "0.4.45",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.44",
"version": "0.4.45",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.44",
"version": "0.4.45",
"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,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.
+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.
+3 -3
View File
@@ -27,7 +27,7 @@ Open the browser audit URL in local Chromium, run `runAudit()` and inspect/downl
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. 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
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
@@ -129,7 +129,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 +252,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 |
+4
View File
@@ -217,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.
+3
View File
@@ -73,6 +73,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 '
+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.
+81 -1
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={};}
@@ -357,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({});
+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())
+31 -9
View File
@@ -227,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)
@@ -260,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
@@ -365,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__':
+10 -3
View File
@@ -10876,12 +10876,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,
@@ -10941,7 +10942,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",
@@ -10958,6 +10959,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"
}
},
{
+107 -8
View File
@@ -197,6 +197,26 @@
}, {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},
@@ -263,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 &&
@@ -321,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.
@@ -373,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,
+38 -16
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>
+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,