Compare commits

...
Author SHA1 Message Date
Alex Southwell 33838da03a Merge pull request #352 from saphid/docs/framey-author-api-current
Document current Framey module and dialog APIs
2026-10-26 23:56:45 +00:00
Alex Southwell 2970f5da61 Merge pull request #349 from saphid/feat/framey-scroll-hold-on-345
Keep Framey plugin scrolling while a button is held
2026-10-26 23:49:03 +00:00
Alex Southwell 5741ae6243 Merge pull request #345 from saphid/fix/framey-panel-status-session
Scope Framey panel status and input to active session
2026-10-26 23:42:21 +00:00
SAPHID 6cfaabbe8f Document current Framey module and dialog APIs 2026-10-26 23:37:12 +00:00
Alex Southwell 0905842ded Merge pull request #350 from saphid/docs/v0449-release-evidence
Record v0.4.49 release verification
2026-10-26 23:34:13 +00:00
SAPHID f4cc9ce0a1 Record v0.4.49 release verification 2026-10-26 23:26:59 +00:00
SAPHID 617661b7a2 Keep Framey plugin scrolling while a button is held 2026-10-26 23:24:42 +00:00
Alex Southwell 38db6a2846 Merge pull request #348 from saphid/release/v0.4.49
Prepare Frame Control v0.4.49 repair candidate
2026-10-26 23:05:19 +00:00
SAPHID a17ebf96fe Record locked v0.4.49 candidate smoke 2026-10-26 22:51:00 +00:00
SAPHID 6247dfbf89 Prepare Frame Control v0.4.49 candidate 2026-10-26 22:48:02 +00:00
Alex Southwell 7ed59a9b25 Merge pull request #344 from saphid/fix/release-publisher-gates
Require exact CI and installer gates before publishing
2026-10-26 22:46:48 +00:00
SAPHID 134ceea2b7 Merge remote-tracking branch 'origin/main' into fix/framey-panel-status-session 2026-10-26 22:38:10 +00:00
SAPHID 1824b54140 Scope Framey panel status and input to active session 2026-10-26 22:38:05 +00:00
SAPHID 14d677f527 Merge remote-tracking branch 'origin/main' into fix/release-publisher-gates 2026-10-26 22:36:58 +00:00
Alex Southwell fe8a860afb Merge pull request #343 from saphid/test/framey-decky-runtime
Test managed shims in actual Decky 3.3.0 runtime
2026-10-26 22:36:34 +00:00
SAPHID 72f8152525 Resolve gh consistently through publisher shell 2026-10-26 22:35:21 +00:00
SAPHID 62e27a2450 Require native Decky regression gate for publication 2026-10-26 22:28:50 +00:00
Alex Southwell 4c3381f006 Merge pull request #339 from saphid/docs/v0448-release-evidence
Record v0.4.48 release verification
2026-10-26 22:26:58 +00:00
SAPHID 1cd1c07b7a Keep withdrawn releases out of the update channel 2026-10-26 22:25:05 +00:00
SAPHID 55fb9226ba Fail closed on exact release CI and installer gates 2026-10-26 22:21:53 +00:00
SAPHID 5418c6f094 Clarify cancelled v0.4.48 preview versus tagged release 2026-10-26 22:18:01 +00:00
Alex Southwell 14f9506e74 Merge pull request #340 from saphid/fix/artwork-resolver-deadline-test
Keep artwork DNS deadline test stable on loaded CI
2026-10-26 22:16:56 +00:00
SAPHID 0447fe217c Test managed shims in actual Decky 3.3.0 runtime 2026-10-26 22:14:45 +00:00
SAPHID 0026095096 Record merged repair and installed-plugin recovery 2026-10-26 22:10:06 +00:00
SAPHID 1309a0883d Compare resolver wait with computed deadline interval 2026-10-26 22:06:30 +00:00
SAPHID d1a4270b45 Record v0.4.48 withdrawal after Decky regression 2026-10-26 22:02:20 +00:00
SAPHID 1e6d742b98 Check artwork DNS deadline without runner timing flake 2026-10-26 21:58:44 +00:00
SAPHID 818ec2dcba Record v0.4.48 release verification 2026-10-26 21:54:28 +00:00
26 changed files with 1016 additions and 33 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
name: Decky backend runtime
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
decky-runtime:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Actual Decky 3.3.0 discovery and safe backend probes
run: python3 scripts/framey-test-decky-runtime.py --output "$RUNNER_TEMP/decky-runtime.json"
- uses: actions/upload-artifact@v4
if: always()
with:
name: decky-runtime-${{ matrix.os }}
path: ${{ runner.temp }}/decky-runtime.json
if-no-files-found: warn
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.48",
"version": "0.4.49",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.48",
"version": "0.4.49",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.48",
"version": "0.4.49",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+85
View File
@@ -0,0 +1,85 @@
# v0.4.48 release verification and withdrawal, 26 October 2026
The annotated [`v0.4.48` tag](https://github.com/saphid/frame-control/releases/tag/v0.4.48)
dereferences to main commit `5438e1d9d05ca472f200dcde50799c8ae882fafb`,
which contains the version bump and [candidate evidence](v0448-candidate-2026-10-26.md).
The exact-commit [main checks run](https://github.com/saphid/frame-control/actions/runs/40356645911)
finished successfully: all seven jobs passed, including e2e, iOS, Windows,
macOS and Linux tests. Its first two attempts had an iOS job stuck in the
runner queue and were cancelled; attempt 3 ran that job and ended with all
seven required checks successful. The later documentation-only main commit
`1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6` also passed all seven
[main checks](https://github.com/saphid/frame-control/actions/runs/40406851705).
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40406781383)
finished successfully. Its draft, Linux, Windows, Apple Silicon Mac, Intel
Mac and checksum jobs all passed; no tagged installer build failed or was
cancelled. Tagged CI launched the Linux x86_64 AppImage on Xvfb, checked both
Mac bundles against their runner architecture, installed the Windows Setup
and compared its installed resources with the unpacked build, and tested its
packaged Python and native PC libraries.
An earlier [release-PR preview run](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/1)
was not a passing gate: its Apple Silicon Mac build was cancelled and its
checksum job was skipped. [Attempt 2](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/2)
completed all four preview installer builds successfully. Publication used the
separate exact-tag workflow above, whose six jobs all succeeded.
The draft had exactly ten installers and `SHA256SUMS`, each with a GitHub
SHA-256 digest. An independently downloaded `SHA256SUMS` matched a fresh
ten-line list generated from GitHub's asset metadata byte for byte. Both
its downloaded SHA-256 and GitHub's own asset digest were
`8cf7d8808f0b0b9da08e2590461e83ef557412a0ce2cc780c8e1d02e34ce1a60`.
Independent downloads on lxso1 matched `SHA256SUMS` for these packages:
| Package | SHA-256 and independent check |
| --- | --- |
| Linux x86_64 AppImage | `7c0cbef7c0330c48ecb69f6fcee027a8b45fe84b0458985db31d16b09fc45cbe`; isolated Xvfb launch served HTTP 200 with `FrameControl/1`, then its server stopped. |
| Linux arm64 DEB | `d3c2cdc4310fc3fed11460ad970a3ff6da8b80a6ec566acf1bcaa5a9570efc3e`; package declares version 0.4.48 and arm64 architecture. |
| Mac arm64 ZIP | `3c5b0ad0877e4c1e1adbac17a9eb4f1114294a25576330050ac9ce338e0b1c00`; ZIP CRC, bundle version 0.4.48 and main Mach-O arm64 CPU passed. |
| Mac x64 ZIP | `3bb90246794f0082fd4f34d86113e292ae543671ff8bea4096d888a9273a529c`; ZIP CRC, bundle version 0.4.48 and main Mach-O x86_64 CPU passed. |
| Windows x64 Setup | `def654c238575778120c6f2a4df1af319b0cd169f64c2fceebe26ddc5e3c68ff`; downloaded file is a PE/Nullsoft installer; tagged Windows CI installed and tested its contents. |
After attaching the final release notes,
`scripts/publish-release.sh --dry-run v0.4.48` passed the exact tag, ten
installer digests, checksum asset and update-manifest checks.
`scripts/publish-release.sh v0.4.48` published the release at
**21:53:29 UTC**. GitHub's latest-release API read back tag `v0.4.48`,
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS` and
`update.json`. Immediately after publication, a fresh public latest-download
of `update.json` reported version 0.4.48, the release page, ten hashed
installers and the worn VR limit in its notes. This is historical evidence,
not the current update-channel state.
## Withdrawn after a real Decky regression was confirmed
The all-green release gate did not include Music Control's actual Decky 3.3.0
legacy class calling convention. [PR #338](https://github.com/saphid/frame-control/pull/338)
reproduced a regression in the #335 digest-scoped Music Control 1.1.7 install
shim: it removes explicit `self` from calls that the real legacy runtime needs,
so player discovery fails with `_get_dbus_env() missing 1 required positional
argument: 'self'`. The earlier fixture had instantiated `Plugin()` and missed
this behavior. A passing CI run therefore did not prove this plugin path worked.
At **22:01:20 UTC**, v0.4.48 was returned to draft and the previously green
v0.4.47 was marked latest. The v0.4.48 tag and build artifacts were preserved;
no tag or installer was deleted. The preserved draft's notes now begin with a
withdrawal notice. GitHub's latest-release API and a fresh public
`releases/latest/download/update.json` now both point to **v0.4.47** with ten
hashed installers. Existing v0.4.48 installations do not auto-downgrade.
The repair in #338 merged at `04cde721c862c80a18dad17c2aab0c86ca8d3082`.
It requires green post-merge main checks, a new release candidate and all
release gates before any replacement version is offered. An app update alone
does not rewrite the bad installed plugin shim: affected users need an explicit
managed plugin update or remove/reinstall after the repair ships.
The [candidate smoke](v0448-candidate-2026-10-26.md) on the real Frame passed
**12 / one optional x86_64 launch skipped / zero failed**. The optional launch
needed Steam Linux Runtime 4, which was absent; no x86_64 headset launch was
claimed. Owned test titles and processes were removed, the lock was released,
and a fresh locked check found standby and no test-title leftovers. A later
read-only locked check found SteamVR activity 3 (standby), proximity raw
3.0625 and battery 100%, then released its own lock. No wearer was confirmed,
so neither the worn-check script nor physical controller input was run.
Official-plugin wearer visibility, controller use, VR keyboard, optical comfort
and physical launch of these exact Mac/Windows installers remain unverified.
@@ -0,0 +1,37 @@
# v0.4.49 candidate gate, 26 October 2026
The version bump at `6247dfb` follows the merged Music Control repair
([#338](https://github.com/saphid/frame-control/pull/338)), the real Decky
runtime gate ([#343](https://github.com/saphid/frame-control/pull/343)), and
the fail-closed publisher ([#344](https://github.com/saphid/frame-control/pull/344)).
The published v0.4.48 draft remains withdrawn. This candidate is not a
published update.
## Locked real Frame smoke
At 22:48 UTC, `scripts/frame-smoke.sh` ran from exact version-bumped source
commit `6247dfb` on SteamOS 0.5.6, build `20261022.6153909`. It acquired a
unique `/tmp/frame-test.lock` owner before reading status. SteamVR activity was
**3 (standby)**, proximity raw **3.0625**, and battery **99%**. The result was
**12 passed / one optional x86_64 launch skipped / zero failed**. The x86_64
title could not launch because Steam Linux Runtime 4 was not installed; no
x86_64 launch is claimed. ARM64 and Windows/Proton test titles installed,
launched and were removed. The script's owned cleanup passed and exited 0.
The private result is
`tests/smoke/results/2026-10-26T224816Z-20261022.6153909.json`.
A separate read-only audit acquired a fresh unique lock after the smoke. It
found activity **3**, proximity raw **3.0**, battery **99%**, no `fc_smoke_*`
or `fc-smoke-*` title folders and no matching test processes. The audit
released only its own lock. Neither run woke the headset or ran the worn
interaction script. No wearer was confirmed; official-plugin wearer
visibility, physical controller input, VR keyboard and optical comfort remain
unverified.
## Remaining publication gates
The final candidate head needs green PR CI, native amd64 and ARM64 Decky
runtime checks, all four installer previews, post-merge exact-commit main CI,
tagged builds of all ten installers, checksums, independent package
verification, publisher dry run and public update-channel readback. A gate
that fails, is cancelled or remains pending holds publication.
+59
View File
@@ -0,0 +1,59 @@
# v0.4.49 tagged release verification, 26 October 2026
The annotated `v0.4.49` tag dereferences to merged main commit
`38db6a284696c3bb86bf97f6b853af7e97681701`. This is the same tree as
the reviewed [candidate PR #348](https://github.com/saphid/frame-control/pull/348).
Its [main checks run](https://github.com/saphid/frame-control/actions/runs/40417275314)
passed all seven jobs, including e2e, iOS, macOS, Windows and Linux.
The [native Decky 3.3.0 run](https://github.com/saphid/frame-control/actions/runs/40417275256)
passed on AMD64 and ARM64. The regression gate includes an expected failure
for v0.4.48's bad legacy calling convention.
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40418177330)
passed all six jobs on its first attempt: draft, Linux, Windows, Apple Silicon
Mac, Intel Mac, and checksums. No tagged build failed, was cancelled, or was
skipped. The draft contained exactly ten installers and `SHA256SUMS` before
publication. Every asset had a GitHub SHA-256 digest. A fresh download of
`SHA256SUMS` had SHA-256
`8a04f81f3b1efea9903e9acbba2c61b67f275869c160e0c6fa1d388a4803b42a`;
it matched a list regenerated from the ten GitHub installer digests byte for
byte. All ten installers were independently downloaded and passed
`sha256sum -c SHA256SUMS`.
## Independent package checks
| Package | Check |
| --- | --- |
| Linux x86_64 AppImage | SHA-256 `0ef192e6f13aed39469392c22b971b311d911a0bebae16cbbabbfa729b95ced0`; isolated Xvfb launch returned HTTP 200 with `FrameControl/1`, then its server stopped. The first local launch attempt hit the workspace sandbox's `Operation not permitted` restriction; the same downloaded bytes passed outside that sandbox. Tagged Linux CI also launched the AppImage. |
| Linux arm64 DEB | SHA-256 `a8e448b8b8f10b2813546cb9d8fea6bbe424e14dc3a739df86daa69bfd4023c1`; declares version 0.4.49 and arm64. |
| Linux amd64 DEB | SHA-256 `10bb554cc761c61c99d059ae3601f4889187d7f18bdc8f9eb3cf768822b5765d`; declares version 0.4.49 and amd64. |
| Mac arm64 ZIP | SHA-256 `c43c1017b3d244fcf59ab8f5bd35dbad17af4562b2aca3baf65908814f2ae4c3`; ZIP CRC, bundle version 0.4.49 and main Mach-O arm64 CPU passed. |
| Mac x64 ZIP | SHA-256 `0dd838a1322107928893ff5efce817719867a5db1af2fa09124b6bb03018a19d`; ZIP CRC, bundle version 0.4.49 and main Mach-O x86_64 CPU passed. |
| Windows x64 Setup | SHA-256 `386d99fd15314e1caef577f7cdf21e9e9f572d1c0dbaa65c369e5a5b0c047d31`; downloaded file is a PE/Nullsoft installer. Tagged Windows CI installed it and checked its packaged resources and Python libraries. |
| Windows x64 ZIP | SHA-256 `2c201a8d1115dd1a07ba7b9293f7e05c319bd802f574a4b2f9565268a7f5cf8b`; ZIP CRC passed. |
The remaining three packages (both Mac DMGs and the ARM64 AppImage) also
matched `SHA256SUMS`; tagged CI built and checked their platform bundles.
Physical launch of these exact Mac and Windows installers was not performed.
## Publication and real Frame scope
The [candidate evidence](v0449-candidate-2026-10-26.md) records a unique-lock
real Frame smoke on the version-bumped source: **12 passed, one optional
x86_64 launch skipped, zero failed**, followed by an independent locked audit
with no test-title folders or processes. The optional runtime was absent; no
x86_64 launch is claimed. SteamVR stayed in standby, no wearer was confirmed,
and neither a worn test nor physical controller check was run.
`scripts/publish-release.sh --dry-run v0.4.49` passed the exact main, native
Decky and tagged release job gates, ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update manifest. The real publisher repeated
those gates, attached `update.json`, and published
[v0.4.49](https://github.com/saphid/frame-control/releases/tag/v0.4.49) at
**23:25:45 UTC**. GitHub's latest-release API returned `draft=false`,
`prerelease=false`, ten installers, `SHA256SUMS` and `update.json`. A fresh
public latest-download of `update.json` reported version 0.4.49, the correct
page, ten hashed assets, the v0.4.48 repair note and the worn-VR limit.
The v0.4.48 release remains a withdrawn draft; its notice points to v0.4.49.
Existing v0.4.48 installations do not auto-downgrade, and affected installed
Music Control plugins need managed Update or Remove and reinstall.
+16 -2
View File
@@ -63,7 +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 |
| 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. Hold either button to repeat; keyboard activation still moves one step. Plugins may use ordinary CSS overflow containers. | Parent/iframe message tests and a real Frame synthetic OpenVR pointer hold; physical controller scroll and hold remain 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. |
@@ -74,10 +74,13 @@ none of these frontend controls has passed a worn-headset check yet.
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
| `SteamClient.Storage.GetJSON` / `SetObject` | Uses the same bounded per-plugin cache; a missing key rejects with `Not found`. It cannot read Steam account or global storage. | Host contract tests and local Chromium settings views; headset persistence after reopen remains unverified. |
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
| `findModule`, `findModuleChild`, `findModuleExport`, class lookup and module mapping | Searches a finite registry of Framey controls and CSS maps. Unknown Steam modules return `undefined` or an empty mapping. The optional UI-audio export cannot play sound. | Host contract tests; pinned TabMaster 2.15.1 QAM and documentation render in local Chromium. Steam library editing and worn VR remain unverified. |
| `Navigation.CloseSideMenus` | Returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 shallow settings view in local host test. Live HLTB search remains unverified. |
| `Focusable.onActivate`, `showModal`, `ConfirmModal` | Pointer and Enter/Space activation, disabled state, and local confirmation dialogs with cancel/close and nested-dialog focus | Host tests and official AlarMe 1.6.3 timer/preset controls with a real ARM64 backend in local Chromium. Sound, suspend and worn VR remain unverified. |
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
| `fetchNoCors`, file picker, root calls | Unavailable | Not supported by Framey |
@@ -88,6 +91,17 @@ Optional route segments must be at the end. Framey accepts local path segments
only; query strings, external URLs, Steam library routes and duplicate parameter
names are rejected. Removing an active route returns to the plugin panel.
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
Treat module lookup results as Framey-local exports. Do not depend on Steam
webpack IDs, function-source signatures, or Steam-only CSS class names; a
lookup that works in the Steam Deck QAM can still return `undefined` here.
Use a local control or a visible unsupported message when it does. For a
confirmation, call `showModal` with a `ConfirmModal` and keep any irreversible
operation behind its OK action. `Focusable.onActivate` accepts pointer and
Enter/Space input in Framey, but only a worn-headset test can establish how a
physical controller reaches it. See the
[module lookup evidence](evidence/framey-module-lookup-2026-10-26.md) and
[dialog control evidence](evidence/framey-dialog-controls-2026-10-26.md) for
the exact tested official versions.
An API v1 manifest does not guarantee a working Framey panel. Test the exact
store archive and do not assume SteamClient app details, library patches, or
destructive actions work in VR. The
+3
View File
@@ -224,6 +224,9 @@ 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.
Panel-ready, error, and trusted-pointer reports also carry the current panel
session. A delayed report from a closed panel cannot change the new panel's
status or count as input to that panel.
## For plugin authors
+7 -3
View File
@@ -61,8 +61,10 @@ 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.
down and return to the top. It also holds each button through that transport,
requires Down to move farther than a single click, and requires Up to return
to the top. These prove the synthetic button paths only; they do 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,
@@ -119,7 +121,9 @@ the same-plugin navigation regression without a headset.
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
installed compatible plugin; check SteamVR's actual keyboard and Back.
installed compatible plugin. Hold **Scroll plugin down** and then **Scroll
plugin up** with the controller; record continuous movement separately from
native thumbstick or wheel scrolling. Check SteamVR's actual keyboard and Back.
4. **2:15–3:00:** Restore the original sample setting. Switch away and back,
close/reopen the Frame Control panel, and confirm controller input still
works. Record comfort/legibility, controller model, any haptic behavior,
+15 -1
View File
@@ -34,13 +34,27 @@ count. So a build reaches people only when you publish it, after testing it.
4. Publish:
Confirm the exact tag commit's main CI and e2e, the locked real Frame smoke,
release verification, independent package checks, and all four tagged
installer builds have passed. Hold the release if any gate is failed,
cancelled, skipped, still running, or unverified. The publisher also reads
the exact commit's GitHub CI, native Decky runtime gate, and tagged build
job records and refuses to
publish unless every required job succeeded; it cannot prove physical
headset or installer behavior on its own.
```sh
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
scripts/publish-release.sh v0.4.0
```
The script checks that the tag is on GitHub and that all ten installers
The script checks that the tag is on GitHub, that exact-commit main CI
(including e2e), native amd64/ARM64 Decky runtime checks, and the tagged
release workflow are green with no failed,
cancelled, skipped or running jobs, and that all ten installers
are attached, each with the SHA-256 digest GitHub records. It also checks
that the draft does not begin with a `WITHDRAWN` notice, so the preserved
v0.4.48 draft cannot accidentally be offered again. It checks
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
installer digests. It attaches
`update.json` (the version, the notes, the release page and each
+38
View File
@@ -549,3 +549,41 @@ Fixtures cover duplicate instance identity, unknown states, GUID matching,
localised/denied channel data and 6 GHz ambiguity. No tests contact a real Frame.
A Windows CI runner/VM cannot prove physical Steam dongle behaviour or audio RF
interference. See [sources and limits](windows-wireless-doctor.md).
### Actual Decky backend shim gate
Fake-Frame tests stub plugin execution, so they cannot prove Decky's Python
calling convention. `Decky backend runtime` CI separately runs the published,
SHA-256-pinned **Decky 3.3.0** executable on native amd64 and ARM64 Linux.
The loader discovers production-managed official archives and calls their public
methods over its actual websocket API. No test imports or instantiates `Plugin`.
```sh
python3 scripts/framey-test-decky-runtime.py --output /tmp/decky-runtime.json
# Offline loader cache (still checked against its pinned native digest):
python3 scripts/framey-test-decky-runtime.py --loader /path/to/PluginLoader --output /tmp/decky-runtime.json
```
Run on Linux with Docker. Image build and the pinned loader download need network;
plugin execution has no network, published ports, host home/audio/D-Bus/device
mounts, capabilities or writable repository. Each case has its own unprivileged
container, private tmpfs home and temporary files. Nothing contacts the headset
or its shared lock. Failed/timed-out containers and the private image tag are
removed by the runner.
Every `compat_shims.append` in the managed installer must have a case in
`tests/decky-runtime/cases.json`. Add each affected pinned official archive and
a safe public-method probe when introducing a shim. Ordinary unit tests reject
uncovered shim names. The runtime job verifies the managed receipt, loader
plugin discovery, and a successful backend call; a discovery listing alone
could conceal a crashed backend process.
Current cases cover both Music Control entries and Shotty. Music Control uses
the private silent MPRIS fixture to check discovery, metadata, play/pause,
next/previous, seek and volume, with independent service-state readbacks.
Shotty checks backend import, rescuer state and empty screenshot aggregation
inside its disposable home. A fourth negative-control case deliberately
reintroduces #335's incorrect bound-method transformation and must reproduce
the real loader's legacy backend-call failure. The gate fails if that regression goes
undetected. These checks verify backend behavior in a container; they do not
upgrade any plugin's Frame, actual media playback or worn-VR compatibility claim.
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Run every managed backend shim in native, isolated, published Decky 3.3.0.
Downloads a digest-pinned loader, or accepts --loader for an offline cached copy.
Only image build/download use network. All official plugin execution is in
network/device/session-isolated disposable Docker containers. Never uses Frame.
"""
import argparse
import ast
import hashlib
import json
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parents[1]
RELEASE = 'v3.3.0'
LOADERS = {
'x86_64': ('PluginLoader', 'e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181', 62),
'aarch64': ('PluginLoader-arm64', '8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6', 183),
}
def check_coverage():
"""A new production shim must declare an actual-runtime probe before CI runs."""
sys.path.insert(0, str(ROOT/'ui'))
import frame_decky_store
tree = ast.parse(frame_decky_store.APPLY)
shims = set()
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == 'compat_shims' and node.func.attr == 'append'):
if len(node.args) != 1 or not isinstance(node.args[0], ast.Constant) or not isinstance(node.args[0].value, str):
raise ValueError('Managed shims must declare a literal name for runtime test coverage')
shims.add(node.args[0].value)
cases = json.loads((ROOT/'tests/decky-runtime/cases.json').read_text())
if shims != {case['shim'] for case in cases}:
raise ValueError('Every managed shim needs an actual Decky runtime case')
return cases
def loader_bytes(path):
arch = platform.machine()
if arch not in LOADERS:
raise ValueError('Run on native Linux x86_64 or aarch64')
asset, digest, machine = LOADERS[arch]
if path:
data = path.read_bytes()
else:
url = f'https://github.com/SteamDeckHomebrew/decky-loader/releases/download/{RELEASE}/{asset}'
request = urllib.request.Request(url, headers={'User-Agent': 'Frame-Control-runtime-tests'})
with urllib.request.urlopen(request, timeout=60) as response:
data = response.read(80*1024*1024+1)
if hashlib.sha256(data).hexdigest() != digest:
raise ValueError('Decky 3.3.0 loader digest mismatch')
if data[:4] != b'\x7fELF' or int.from_bytes(data[18:20], 'little') != machine:
raise ValueError('Loader architecture does not match the native runner')
return data, digest
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--loader', type=Path, help='Optional cached, digest-verified native Decky 3.3.0 binary')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
if sys.platform != 'linux' or not shutil.which('docker'):
parser.error('Linux with Docker is required')
cases = check_coverage()
data, digest = loader_bytes(args.loader)
tag = 'framey-decky-runtime:'+uuid.uuid4().hex[:12]
report = {'decky': RELEASE, 'architecture': platform.machine(), 'loaderSha256': digest, 'cases': []}
try:
subprocess.run(['docker', 'build', '-q', '-t', tag, '-f', str(ROOT/'tests/decky-runtime/Containerfile'),
str(ROOT/'tests/decky-runtime')], check=True, timeout=300)
with tempfile.TemporaryDirectory(prefix='framey-decky-runtime-') as temp:
loader = Path(temp)/'PluginLoader'
loader.write_bytes(data)
loader.chmod(0o644) # Private container user copies/chmods its own executable.
probes = [(case['id'], False) for case in cases] + [(111, True)]
for plugin_id, negative in probes:
name = 'framey-decky-'+uuid.uuid4().hex[:12]
command = ['docker', 'run', '--rm', '--name', name, '--network', 'none', '--read-only',
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '128',
'--tmpfs', '/tmp:rw,exec,mode=1777',
'--tmpfs', '/home/tester:rw,exec,uid=1100,gid=1100,mode=700',
'--tmpfs', '/run/user/1100:rw,uid=1100,gid=1100,mode=700',
'--mount', f'type=bind,source={ROOT},target=/repo,readonly',
'--mount', f'type=bind,source={loader},target=/input/PluginLoader,readonly',
tag, str(plugin_id)]
if negative:
command.append('--reproduce-bound-method-regression')
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
if result.returncode:
raise RuntimeError(f'Decky runtime case {plugin_id} failed:\n{result.stdout}\n{result.stderr}')
observation = json.loads(result.stdout)
report['cases'].append(observation)
print(f"PASS {plugin_id}: {observation['result']}", flush=True)
finally:
subprocess.run(['docker', 'rm', '-f', name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
finally:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2)+'\n')
subprocess.run(['docker', 'image', 'rm', tag], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if __name__ == '__main__':
main()
+28 -2
View File
@@ -71,13 +71,19 @@ 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):
def parent_point(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']
if not 0 <= x <= sx or not 0 <= y <= sy:
raise RuntimeError('Parent control is outside the visible viewport')
return x, y
def click_parent(selector):
x, y = parent_point(selector)
count = parent.eval('window.__vrParentClickCount')
vr.post_mouse(300, x, y)
vr.post_mouse(301, x, y, 1)
@@ -86,6 +92,21 @@ def exercise(profile, plugin_id, folder):
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
raise RuntimeError('Native click missed parent control ' + selector)
def hold_parent(selector, seconds):
x, y = parent_point(selector)
count = parent.eval('window.__vrParentClickCount')
vr.post_mouse(300, x, y)
try:
vr.post_mouse(301, x, y, 1)
time.sleep(seconds)
offset = panel.eval('document.getElementById("vr-scroll-probe").scrollTop')
finally:
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 hold missed parent control ' + selector)
return offset
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')
@@ -172,6 +193,10 @@ def exercise(profile, plugin_id, folder):
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
result['holdScrollAfterDown'] = hold_parent('#frameyScrollDown', 1.2)
result['holdScrollAfterUp'] = hold_parent('#frameyScrollUp', 2.0)
result['holdScrollMoved'] = (result['holdScrollAfterDown'] > result['buttonScrollAfterDown']
and result['holdScrollAfterUp'] == 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.
@@ -179,7 +204,8 @@ 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['buttonScrollMoved'])
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved']
and result['holdScrollMoved'])
return result
except Exception as exc:
result['error'] = str(exc)
+11
View File
@@ -31,6 +31,11 @@ trap 'rm -rf "$tmp"' EXIT
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
# A complete asset list is insufficient: a cancelled installer build can leave
# old uploaded bytes behind. Check the exact tagged commit's main/e2e jobs and
# the latest exact-tag release attempt before any release mutation.
python3 "$(dirname "$0")/verify-release-gates.py" "$tag"
# Every release, drafts included, one JSON object per line.
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
# The release whose tag_name is the tag; failing that, the one untagged-... draft
@@ -56,6 +61,12 @@ json.dump(r, sys.stdout)
EOF
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
python3 - "$tmp/release.json" <<'EOF'
import json, sys
release = json.load(open(sys.argv[1]))
if (release.get("body") or "").lstrip().startswith("> **WITHDRAWN"):
sys.exit("not publishing: this release is marked WITHDRAWN")
EOF
missing=""
for name in $expected; do
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Refuse publication unless the exact tag commit and tagged installers passed CI.
This checks GitHub's current run and job records, not a prior PR's check marks.
The real Frame smoke and independent package checks remain release-procedure
gates documented in docs/releasing.md.
"""
import json
import subprocess
import sys
from urllib.parse import quote
REPO = "saphid/frame-control"
MAIN_JOBS = {
"checks", "e2e", "ios", "wireless-windows",
"server-tests (ubuntu-latest, 3.13)",
"server-tests (macos-latest, 3.12)",
"server-tests (windows-latest, 3.12)",
}
RELEASE_JOBS = {
"draft", "checksums",
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
}
DECKY_JOBS = {"decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)"}
class GateError(Exception):
pass
def api(path):
# publish-release.sh already requires sh. Resolve gh through that same
# shell: on Git Bash, Python's Windows CreateProcess skips an extensionless
# test gh shim in PATH and accidentally launches the real gh.exe instead.
command = ["sh", "-c", 'gh api "$1"', "sh", f"repos/{REPO}/{path}"]
result = subprocess.run(command, text=True, capture_output=True, check=False)
if result.returncode:
raise GateError(f"GitHub gate read failed for {path}: {result.stderr.strip()}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as error:
raise GateError(f"invalid GitHub gate response for {path}: {error}") from error
def tag_commit(tag):
target = api(f"git/ref/tags/{quote(tag, safe='')}").get("object") or {}
for _ in range(5):
kind, sha = target.get("type"), target.get("sha")
if not isinstance(sha, str) or len(sha) != 40:
raise GateError(f"{tag} has no valid tag target")
if kind == "commit":
return sha
if kind != "tag":
raise GateError(f"{tag} points to unsupported Git object {kind!r}")
target = api(f"git/tags/{sha}").get("object") or {}
raise GateError(f"{tag} has too many nested tag objects")
def exact_run(workflow, branch, sha, event):
query = f"actions/workflows/{workflow}/runs?branch={quote(branch, safe='')}&head_sha={sha}&per_page=100"
runs = api(query).get("workflow_runs") or []
matching = [run for run in runs if run.get("head_sha") == sha
and run.get("head_branch") == branch and run.get("event") == event]
if not matching:
raise GateError(f"no exact {workflow} {event} run for {branch} at {sha}")
run = max(matching, key=lambda item: item["id"])
if run.get("status") != "completed" or run.get("conclusion") != "success":
raise GateError(f"{workflow} run {run['id']} is {run.get('status')}/{run.get('conclusion')}")
return run
def successful_jobs(run, required):
attempt = run.get("run_attempt")
if not isinstance(attempt, int) or attempt < 1:
raise GateError(f"run {run['id']} has no valid attempt number")
data = api(f"actions/runs/{run['id']}/attempts/{attempt}/jobs?per_page=100")
jobs = data.get("jobs") or []
if data.get("total_count") != len(jobs):
raise GateError(f"run {run['id']} has more jobs than the gate inspected")
names = [job.get("name") for job in jobs]
if len(names) != len(set(names)) or not required.issubset(names):
missing = sorted(required.difference(names))
raise GateError(f"run {run['id']} has missing or duplicate jobs: {missing}")
bad = [f"{job.get('name')}: {job.get('status')}/{job.get('conclusion')}"
for job in jobs if job.get("status") != "completed" or job.get("conclusion") != "success"]
if bad:
raise GateError(f"run {run['id']} has unfinished, failed, cancelled, or skipped jobs: {bad}")
print(f"gate passed: {run.get('html_url')} ({len(jobs)} jobs)")
def main(tag):
if not tag.startswith("v") or not tag[1:]:
raise GateError("expected a version tag beginning with v")
sha = tag_commit(tag)
successful_jobs(exact_run("checks.yml", "main", sha, "push"), MAIN_JOBS)
successful_jobs(exact_run("decky-runtime.yml", "main", sha, "push"), DECKY_JOBS)
successful_jobs(exact_run("release.yml", tag, sha, "push"), RELEASE_JOBS)
print(f"exact release commit: {sha}")
if __name__ == "__main__":
try:
main(sys.argv[1])
except (IndexError, GateError) as error:
sys.exit(f"release gate failed: {error}")
+12
View File
@@ -0,0 +1,12 @@
# Native amd64 or ARM64; no emulation, host session bus, audio, devices or home.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-dbus python3-gi python3-websockets dbus-bin dbus-daemon \
ca-certificates zlib1g \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -u 1100 -s /bin/sh tester \
&& mkdir -p /run/user/1100 && chown tester:tester /run/user/1100
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 XDG_RUNTIME_DIR=/run/user/1100
USER tester
WORKDIR /repo
ENTRYPOINT ["python3", "/repo/tests/decky-runtime/check.py"]
+5
View File
@@ -0,0 +1,5 @@
[
{"id": 12, "shim": "music-control-system-dbus-env", "probe": "mpris"},
{"id": 111, "shim": "music-control-system-dbus-env", "probe": "mpris"},
{"id": 53, "shim": "shotty-click-app-dir", "probe": "screenshots"}
]
+199
View File
@@ -0,0 +1,199 @@
"""Container-only real Decky backend discovery and smoke tests; no host services."""
import argparse
import asyncio
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import time
import urllib.request
import websockets
from websockets.exceptions import WebSocketException
ROOT = Path('/repo')
sys.path[:0] = [str(ROOT/'ui'), str(ROOT/'tests')]
import frame_decky_store
from framey_official import PLUGINS, archive
async def request(route, args):
token = urllib.request.urlopen('http://127.0.0.1:1337/auth/token', timeout=2).read().decode()
async with websockets.connect('ws://127.0.0.1:1337/ws?auth='+token,
open_timeout=3, close_timeout=2) as ws:
await ws.send(json.dumps({'type': 0, 'id': 1, 'route': route, 'args': args}))
while True:
response = json.loads(await asyncio.wait_for(ws.recv(), timeout=8))
if response.get('id') != 1:
continue
await ws.send(json.dumps({'type': 3, 'id': 1}))
assert response['type'] == 1, response
return response['result']
def rpc(route, *args):
return asyncio.run(request(route, list(args)))
def until_ready(read, description):
deadline = time.monotonic()+20
last = None
while time.monotonic() < deadline:
try:
value = read()
if value:
return value
except (OSError, ValueError, KeyError, TimeoutError, WebSocketException) as error:
last = str(error)
time.sleep(.1)
raise RuntimeError(f'{description} not ready: {last}')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('id', type=int)
parser.add_argument('--reproduce-bound-method-regression', action='store_true')
args = parser.parse_args()
assert os.getuid() == 1100 and ROOT.is_dir(), 'Run through framey-test-decky-runtime.py'
record = next(p for p in PLUGINS if p['id'] == args.id)
case = next(p for p in json.loads((ROOT/'tests/decky-runtime/cases.json').read_text()) if p['id'] == args.id)
data = archive(record)
assert hashlib.sha256(data).hexdigest() == record['sha256']
assert frame_decky_store.archive_info(data, record['sha256'], record['name']) == record['folder']
home = Path.home()
base = home/'.local/share/frame-control/decky'
(base/'plugins').mkdir(parents=True)
unit = home/'.config/systemd/user/frame-control-decky.service'
unit.parent.mkdir(parents=True)
unit.write_text('# Private fixture marker; no real service manager is contacted.\n')
tools = home/'bin'
tools.mkdir()
systemctl = tools/'systemctl'
systemctl.write_text('#!/bin/sh\ncase "$*" in *is-active*) exit 3;; *) exit 0;; esac\n')
systemctl.chmod(0o700)
env = dict(os.environ, PATH=str(tools)+':'+os.environ['PATH'])
Path('/tmp/frame-control-decky-plugin-runtime-test').write_bytes(data)
install = subprocess.run([sys.executable, '-', 'install', 'runtime-test', str(record['id']),
record['sha256'], record['folder'], record['version'], '-'], input=frame_decky_store.APPLY,
capture_output=True, text=True, env=env, timeout=15)
assert install.returncode == 0, install.stderr
plugin = base/'plugins'/record['folder']
receipt = json.loads((plugin/'.frame-control-store.json').read_text())
assert receipt['compatShims'] == [case['shim']], receipt
# Exercise the production manifest's API convention, never import Plugin ourselves.
manifest = json.loads((plugin/'plugin.json').read_text())
assert 'api_version' not in manifest, manifest
if args.reproduce_bound_method_regression:
assert record['id'] == 111
source = (plugin/'main.py').read_text()
for old, new in [('self._get_dbus_env(self)', 'self._get_dbus_env()'),
('self._sp_player_dbus(self,', 'self._sp_player_dbus(')]:
assert old in source
source = source.replace(old, new)
(plugin/'main.py').write_text(source)
(home/'.local/share/Steam/steamui/images').mkdir(parents=True)
loader = base/'PluginLoader'
shutil.copy('/input/PluginLoader', loader)
loader.chmod(0o700)
env.update(UNPRIVILEGED_PATH=str(base), PRIVILEGED_PATH=str(base), CHOWN_PLUGIN_PATH='0',
LIVE_RELOAD='0', USE_CEF_CLOSE_WORKAROUND='0', KEEP_SYSTEMD_SERVICE='1',
DBUS_SESSION_BUS_ADDRESS='unix:path=/run/user/1100/bus')
processes = []
report = {'id': record['id'], 'name': record['name'], 'version': record['version'],
'sha256': record['sha256'], 'shim': case['shim'], 'calls': []}
with (home/'runtime.log').open('w+') as log:
try:
if case['probe'] == 'mpris':
bus = subprocess.Popen(['dbus-daemon', '--session', '--nofork',
'--address='+env['DBUS_SESSION_BUS_ADDRESS']], env=env, stdout=log, stderr=log,
start_new_session=True)
processes.append(bus)
until_ready(lambda: Path('/run/user/1100/bus').is_socket(), 'private session bus')
player = subprocess.Popen([sys.executable, str(ROOT/'tests/fixtures/framey-mpris/player.py')],
env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(player)
until_ready(lambda: Path('/tmp/mpris-state.json').is_file(), 'silent MPRIS player')
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(backend)
discovered = until_ready(lambda: rpc('loader/get_plugins'), 'published Decky plugin discovery')
assert len(discovered) == 1 and discovered[0]['name'] == record['name'], discovered
assert discovered[0]['version'] == record['version'] and not discovered[0]['disabled'], discovered
report['discovered'] = discovered
def legacy(method, **kwargs):
response = rpc('loader/call_legacy_plugin_method', record['name'], method, kwargs)
assert response['success'] is True, (method, response)
report['calls'].append({'method': method, 'result': response['result']})
return response['result']
if args.reproduce_bound_method_regression:
response = rpc('loader/call_legacy_plugin_method', record['name'], 'sp_list_media_players', {})
assert response['success'] is False and 'self' in response['result'], response
report['expectedRegression'] = response
report['result'] = 'historical bad shim rejected by actual Decky legacy dispatch'
elif case['probe'] == 'mpris':
player_name = 'org.mpris.MediaPlayer2.FrameySilent'
assert legacy('sp_list_media_players') == player_name
legacy('sp_set_media_player', player=player_name)
assert legacy('sp_get_media_player') == player_name
assert legacy('sp_identity', orgPath=player_name) == 'Player' # Upstream truncation.
metadata = legacy('get_meta_data')
assert 'title|Silent Test Track' in metadata and 'length|180000000' in metadata, metadata
assert legacy('sp_track_status') == 'Paused'
assert legacy('sp_track_progress') == '12000000'
assert legacy('sp_can_seek') == 'true'
assert float(legacy('sp_get_volume')) == .4
assert legacy('sp_test_volume_control') == 'true'
assert json.loads(Path('/tmp/mpris-state.json').read_text())['Volume'] == .4
legacy('sp_play')
assert legacy('sp_track_status') == 'Playing'
legacy('sp_pause')
legacy('sp_next')
legacy('sp_previous')
legacy('sp_seek', amount=2000000)
assert legacy('sp_track_progress') == '14000000'
legacy('sp_set_position', position=45000000, trackid='/org/framey/TestTrack/1')
legacy('sp_set_volume', volume=.25)
state = json.loads(Path('/tmp/mpris-state.json').read_text())
assert state['PlaybackStatus'] == 'Paused' and state['Volume'] == .25, state
assert state['next'] == state['previous'] == 1 and state['Position'] == 45000000, state
report['independentState'] = state
report['result'] = 'discovery, metadata, transport, seek and volume passed'
else:
assert case['probe'] == 'screenshots'
assert legacy('was_rescued') is False
assert legacy('aggregate_all', allapps=[]) == 0
report['result'] = 'discovery, backend import and empty screenshot aggregation passed'
log.flush()
log.seek(0)
assert 'Starting Decky version v3.3.0' in log.read()
print(json.dumps(report))
except Exception:
log.flush()
log.seek(0)
print(log.read()[-12000:], file=sys.stderr)
raise
finally:
for process in reversed(processes):
if process.poll() is None:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
continue
try:
process.wait(timeout=4)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=4)
if __name__ == '__main__':
main()
+37 -1
View File
@@ -13,6 +13,7 @@ import os
import shutil
import subprocess
import sys
from urllib.parse import parse_qs
args = sys.argv[1:]
with open(os.environ["FAKEGH_LOG"], "a") as f:
@@ -49,6 +50,18 @@ while i < len(args):
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
gate = json.load(open(os.environ["FAKEGH_GATES"])) if os.environ.get("FAKEGH_GATES") else {}
commit = gate.get("tag_commit", "a" * 40)
tag_object = "b" * 40
main_jobs = ("checks", "e2e", "ios", "wireless-windows",
"server-tests (ubuntu-latest, 3.13)", "server-tests (macos-latest, 3.12)",
"server-tests (windows-latest, 3.12)")
release_jobs = ("draft", "checksums",
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)")
decky_jobs = ("decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)")
def answer(response, fallback):
@@ -67,7 +80,30 @@ if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
tag = endpoint.rsplit("/", 1)[1]
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
sys.exit("gh: Not Found (HTTP 404)")
print(json.dumps({"ref": "refs/tags/" + tag}))
print(json.dumps({"ref": "refs/tags/" + tag,
"object": {"type": "tag", "sha": tag_object}}))
elif method == "GET" and endpoint == repo + "git/tags/" + tag_object:
print(json.dumps({"object": {"type": "commit", "sha": commit}}))
elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"):
path, _, query = endpoint.partition("?")
workflow = path.split("/")[5]
branch = parse_qs(query).get("branch", [""])[0]
is_main = workflow != "release.yml"
run_id = {"checks.yml": 501, "release.yml": 502, "decky-runtime.yml": 503}[workflow]
run = {"id": run_id, "head_sha": gate.get("main_sha" if is_main else "release_sha", commit),
"head_branch": branch, "event": "push", "run_attempt": 1,
"status": gate.get("decky_status" if run_id == 503 else ("main_status" if is_main else "release_status"), "completed"),
"conclusion": gate.get("decky_conclusion" if run_id == 503 else ("main_conclusion" if is_main else "release_conclusion"), "success"),
"html_url": "https://github.com/saphid/frame-control/actions/runs/%s" % run_id}
print(json.dumps({"workflow_runs": [run]}))
elif method == "GET" and endpoint.startswith(repo + "actions/runs/"):
run_id = endpoint[len(repo + "actions/runs/"):].split("/")[0]
names = {"501": main_jobs, "502": release_jobs, "503": decky_jobs}[run_id]
names = [name for name in names if name != gate.get("omit_job")]
jobs = [{"name": name, "status": "completed",
"conclusion": gate.get("bad_job_conclusion", "success") if name == gate.get("bad_job") else "success"}
for name in names]
print(json.dumps({"total_count": len(jobs), "jobs": jobs}))
elif method == "GET" and endpoint.startswith(repo + "releases?"):
# The fallback is what --jq '.[]' prints.
answer(releases, "\n".join(json.dumps(r) for r in releases))
+50 -1
View File
@@ -5,6 +5,7 @@ import vm from 'node:vm';
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
const source=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
const elements=new Map(),listeners=[],requests=[],replies=[];
const timeouts=new Map(),intervals=new Map();let nextTimer=0;
const el=id=>{if(!elements.has(id))elements.set(id,{removeAttribute(){},scrollIntoView(){}});return elements.get(id);};
const frame={postMessage:data=>replies.push(data)};
el('frameyFrame').contentWindow=frame;
@@ -12,7 +13,10 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
location:{hash:'',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem:()=>null,setItem(){}},localStorage:{getItem:()=>null,setItem(){}},
window:{addEventListener:(type,fn)=>listeners.push({type,fn})},
setTimeout(){},setInterval(){},console,
setTimeout(fn){const id=++nextTimer;timeouts.set(id,fn);return id;},
clearTimeout(id){timeouts.delete(id);},
setInterval(fn){const id=++nextTimer;intervals.set(id,fn);return id;},
clearInterval(id){intervals.delete(id);},console,
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
vm.runInContext(source,context);
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};openFrameyPanel(96,"Timer")',context);
@@ -101,13 +105,58 @@ 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');
const down=el('frameyScrollDown');
const beforeHold=replies.length;
down.onpointerdown({button:0,isPrimary:true,pointerId:7});
assert.equal(replies.length,beforeHold+1,'pointer press scrolls once');
down.onclick({detail:1});
assert.equal(replies.length,beforeHold+1,'pointer click must not scroll twice');
timeouts.get(nextTimer)();
assert.equal(intervals.size,1);
intervals.get(nextTimer)();
assert.equal(replies.length,beforeHold+2,'held pointer scrolls again');
down.onpointerup({pointerId:7});
assert.equal(intervals.size,0,'release stops scrolling');
down.onclick({detail:0});
assert.equal(replies.length,beforeHold+3,'keyboard click scrolls once');
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');
down.onpointerdown({button:0,isPrimary:true,pointerId:8});
timeouts.get(nextTimer)();
assert.equal(intervals.size,1);
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
assert.equal(intervals.size,0,'end of content stops held scrolling');
down.onpointerdown({button:0,isPrimary:true,pointerId:9});
const lastHoldTimer=nextTimer;
assert.equal(timeouts.has(lastHoldTimer),true);
listeners.find(({type})=>type==='blur').fn();
assert.equal(timeouts.has(lastHoldTimer),false,'loss of panel focus cancels a held scroll');
// WindowProxy also survives panel navigation for lifecycle and input messages.
// An old iframe must not mark a replacement ready, hide its error, or record
// controller evidence for the new panel.
const beforeStatusRequests=requests.length;
for(const type of ['framey:ready','framey:input','framey:error']) {
await send({type,session:'old',message:'old panel failed'});
await send({type,message:'missing session'});
}
assert.equal(requests.length,beforeStatusRequests);
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
await send({type:'framey:ready',session:scrollSession});
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel ready');
assert.equal(requests.at(-1).path,'/api/framey/check');
assert.equal(requests.at(-1).body.kind,'panel');
await send({type:'framey:input',session:scrollSession});
assert.equal(requests.at(-1).body.kind,'input');
await send({type:'framey:error',session:scrollSession,message:'current panel failed'});
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel unavailable: current panel failed');
down.onpointerdown({button:0,isPrimary:true,pointerId:10});
const closeTimer=nextTimer;
el('frameyClose').onclick();
assert.equal(timeouts.has(closeTimer),false,'closing the panel cancels a held scroll');
const replyCount=replies.length;
el('frameyScrollUp').onclick();
assert.equal(replies.length,replyCount);
+9 -1
View File
@@ -20,13 +20,15 @@ const elements=new Map(), roots=new Map();
const document={getElementById:id=>{
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);
},createElement:()=>new Element()};
const window={React,ReactDOM:{createRoot:node=>{
const window={React,location:{hash:'#sample-session'},ReactDOM:{createRoot:node=>{
const root={render(value){this.value=value;},unmount(){this.unmounted=true;}};
roots.set(node,root);return root;
}},addEventListener(){}};
const listeners=[];
const calls=[];
const messages=[];
const parent={postMessage(message){
messages.push(message);
if(message.type!=='framey:call')return;
calls.push(message);
listeners.find(x=>x.type==='message').handler({source:parent,
@@ -36,6 +38,12 @@ const context=vm.createContext({window,document,
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(host,context);
const pointerup=listeners.find(listener=>listener.type==='pointerup').handler;
pointerup({isTrusted:false});
assert.equal(messages.some(message=>message.type==='framey:input'),false);
pointerup({isTrusted:true});
assert.equal(messages.at(-1).type,'framey:input');
assert.equal(messages.at(-1).session,'sample-session');
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
vm.runInContext(bundle.replace(/export \{ index as default \};/, 'window.sample = index;'),context);
const plugin=window.sample();
+23 -6
View File
@@ -276,12 +276,29 @@ class ArtworkTests(unittest.TestCase):
import threading
from apk_sources import _images
gate = threading.Event()
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
start = time.monotonic()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=start + 0.1)
self.assertLess(time.monotonic() - start, 0.4)
gate.set()
real_join = threading.Thread.join
waits = []
def bounded_join(worker, timeout=None):
waits.append(timeout)
return real_join(worker, timeout)
try:
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []), \
patch.object(_images.threading.Thread, 'join', bounded_join):
start = time.monotonic()
deadline = start + 0.1
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=deadline)
# Check the requested wait, not elapsed runner time: a loaded CI
# host can deschedule this test after the bounded join returns.
self.assertEqual(len(waits), 1)
self.assertIsNotNone(waits[0])
self.assertGreaterEqual(waits[0], 0)
self.assertLessEqual(waits[0], deadline - start)
finally:
gate.set()
wait_for_idle_resolvers()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
+36
View File
@@ -0,0 +1,36 @@
"""Managed backend shims must stay covered by the actual-Decky CI gate."""
import sandbox # noqa: F401
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('decky_runtime_gate', ROOT/'scripts/framey-test-decky-runtime.py')
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
class RuntimeCoverage(unittest.TestCase):
def test_current_shims_have_unique_official_archive_probes(self):
from framey_official import PLUGINS
cases = gate.check_coverage()
official = {record['id'] for record in PLUGINS}
self.assertEqual(len(cases), len({case['id'] for case in cases}))
for case in cases:
self.assertIn(case['id'], official)
self.assertIn(case['probe'], ('mpris', 'screenshots'))
def test_new_shim_without_runtime_case_fails_coverage(self):
import frame_decky_store
with patch.object(frame_decky_store, 'APPLY', frame_decky_store.APPLY+
'\ncompat_shims.append("uncovered-new-shim")\n'):
with self.assertRaisesRegex(ValueError, 'Every managed shim'):
gate.check_coverage()
def test_dynamic_shim_names_cannot_evade_inventory(self):
import frame_decky_store
with patch.object(frame_decky_store, 'APPLY', frame_decky_store.APPLY+
'\ncompat_shims.append(variable_name)\n'):
with self.assertRaisesRegex(ValueError, 'literal name'):
gate.check_coverage()
+34 -2
View File
@@ -37,12 +37,14 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7"):
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
(d / "gates.json").write_text(json.dumps(gates or {}))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
FAKEGH_GATES=str(d / "gates.json"))
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
@@ -120,6 +122,36 @@ class PublishRelease(unittest.TestCase):
self.assertIsNone(upload)
self.assertTrue(all("-X" not in c for c in log))
def test_refuses_failed_e2e_or_cancelled_installer_before_mutation(self):
for gates in ({"bad_job": "e2e", "bad_job_conclusion": "failure"},
{"bad_job": "decky-runtime (ubuntu-24.04-arm)",
"bad_job_conclusion": "failure"},
{"bad_job": "build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
"bad_job_conclusion": "cancelled"}):
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
self.assertNotEqual(p.returncode, 0)
self.assertIn("release gate failed", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_missing_or_nonexact_gate_results(self):
for gates in ({"omit_job": "e2e"}, {"main_sha": "c" * 40},
{"decky_conclusion": "failure"}, {"release_conclusion": "failure"},
{"release_status": "in_progress"}):
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
self.assertNotEqual(p.returncode, 0)
self.assertIn("release gate failed", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_a_withdrawn_release_even_when_ci_is_green(self):
rel = dict(draft(), body="> **WITHDRAWN 26 October 2026.** Do not install.\n\n# Notes")
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("marked WITHDRAWN", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
if __name__ == "__main__":
unittest.main()
+4 -3
View File
@@ -190,7 +190,7 @@
parent.postMessage({type:'framey:events:close', session:cacheSession}, '*');
});
addEventListener('pointerup', event => {
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
if (event.isTrusted) parent.postMessage({type:'framey:input', session:cacheSession}, '*');
}, {passive:true});
addEventListener('pointermove', event => {
if (event.isTrusted) lastScrollTarget = scrollTarget(event.target);
@@ -829,10 +829,11 @@
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
R.createElement(PluginSurface, {plugin})));
parent.postMessage({type:'framey:ready'}, '*');
parent.postMessage({type:'framey:ready', session:cacheSession}, '*');
} catch (failure) {
error.textContent = `This Decky panel is not compatible with Framey: ${failure.message}`;
parent.postMessage({type:'framey:error', message:failure.message}, '*');
parent.postMessage({type:'framey:error', session:cacheSession,
message:failure.message}, '*');
}
}
start();
+50 -8
View File
@@ -55,6 +55,7 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
<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>
<button id="frameyScrollUp" class="secondary">Scroll plugin up</button><button id="frameyScrollDown" class="secondary">Scroll plugin down</button></div>
<p><small>Hold a scroll button to keep moving through plugin content.</small></p>
<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>
@@ -228,6 +229,7 @@ byId('deckyJobCheck').onclick=checkDeckyJob;
function openFrameyPanel(id,name){
if(deckyBusy||deckyJobPending()||!deckyState?.running)return;
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
stopFrameyScrollHold();
byId('frameyCompatibility').textContent=compatibilityText(installed);
closeFrameyEvents();
activeFrameyId=id;activeFrameyReceipt={sha256:installed.sha256,version:installed.version};
@@ -249,15 +251,52 @@ function closeFrameyEvents(){
session:frameySession,events:[],cursor:0}).catch(()=>{});
frameyEventsActive=false;
}
function closeFrameyPanel(){closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
function closeFrameyPanel(){stopFrameyScrollHold();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},'*');
if(activeFrameyId===null||!frameySession||!['up','down'].includes(direction))return false;
const target=byId('frameyFrame').contentWindow;if(!target)return false;
target.postMessage({type:'framey:scroll',session:frameySession,direction},'*');
return true;
}
byId('frameyScrollUp').onclick=()=>scrollFramey('up');
byId('frameyScrollDown').onclick=()=>scrollFramey('down');
let frameyScrollHold=null;
function stopFrameyScrollHold(){
if(!frameyScrollHold)return;
clearTimeout(frameyScrollHold.delay);
clearInterval(frameyScrollHold.repeat);
frameyScrollHold=null;
}
function bindFrameyScroll(id,direction){
const button=byId(id);
button.onpointerdown=event=>{
if(event.button!==0||event.isPrimary===false)return;
stopFrameyScrollHold();
if(!scrollFramey(direction))return;
const hold={button,direction,pointerId:event.pointerId,delay:null,repeat:null};
frameyScrollHold=hold;
hold.delay=setTimeout(()=>{
if(frameyScrollHold!==hold)return;
hold.repeat=setInterval(()=>{
if(frameyScrollHold!==hold)return;
if(!scrollFramey(direction))stopFrameyScrollHold();
},180);
},400);
};
const stop=event=>{
if(frameyScrollHold?.button===button&&frameyScrollHold.pointerId===event.pointerId)
stopFrameyScrollHold();
};
button.onpointerup=stop;
button.onpointercancel=stop;
button.onpointerleave=stop;
// Pointerdown already sent one step. Keyboard activation emits a click with
// detail 0; direct onclick calls from older tests also mean one step.
button.onclick=event=>{if(!event||event.detail===0)scrollFramey(direction);};
}
bindFrameyScroll('frameyScrollUp','up');
bindFrameyScroll('frameyScrollDown','down');
window.addEventListener('blur',stopFrameyScrollHold);
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
function frameyCacheRead(id){
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
@@ -282,6 +321,7 @@ window.addEventListener('message',async event=>{
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';
if(req.moved===false&&frameyScrollHold?.direction===req.direction)stopFrameyScrollHold();
return;
}
if(req.type==='framey:cache:init'&&req.session===frameySession){
@@ -292,10 +332,12 @@ window.addEventListener('message',async event=>{
if(req.session===frameySession)frameyCacheSave(activeFrameyId,req.entries);
return;
}
if(req.type==='framey:ready'){byId('frameyPanelStatus').textContent='Plugin panel ready';
if(req.type==='framey:ready'&&req.session===frameySession){byId('frameyPanelStatus').textContent='Plugin panel ready';
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
if(req.type==='framey:input'&&req.session===frameySession){
api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
if(req.type==='framey:error'&&req.session===frameySession){
byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
if(req.type==='framey:running:close'){
if(req.session===frameySession&&frameyRunningActive){
frameyRunningActive=false;