mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 12:02:05 +01:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7172be17fc | ||
|
|
761e952d8f | ||
|
|
0237e342d2 | ||
|
|
10b196beaa | ||
|
|
3f74ff4bb0 |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.42",
|
||||
"version": "0.4.41",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.42",
|
||||
"version": "0.4.41",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.42",
|
||||
"version": "0.4.41",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,78 @@
|
||||
# Running-game context and Discord Status, 2026-10-26
|
||||
|
||||
Framey now projects running apps and the selected main app from Steam CEF into
|
||||
detached `SteamUIStore` / `DFL.Router` getters. It relays actual Steam lifetime
|
||||
callbacks while the panel is open. Public Steam CDN capsule URLs are projected
|
||||
for running apps; local artwork paths and URL query data never cross the bridge.
|
||||
The [machine-readable evidence](framey-running-context-2026-10-26.json) records
|
||||
the exact archives, fixtures, browser observations and cleanup results.
|
||||
|
||||
| Plugin / scope | Result | Verification |
|
||||
| --- | --- | --- |
|
||||
| Discord Status 1.5.0 | **Broken → partial.** Disconnected panel renders; activity construction accepts the synthetic running game without the previous missing-store/artwork errors. | Exact unmodified official frontend in opaque Chromium iframe; backend/network denied. Actual official `is_connected` returns false in real Decky 3.3.0 ARM64, with no Discord IPC present. |
|
||||
| Pause Games | **Partial.** Running-list initialization reaches `pid_from_appid` for the synthetic game. | Local Chromium fixture; all backend calls, including settings writes and process actions, denied. |
|
||||
| Notebook | **Works for its previously verified default notebook scope.** Per-game context is now supplied, but real per-game selection remains unverified. | Synthetic game causes the official frontend to request `loadPage`; backend calls denied in this pass. Earlier default save/reopen ARM64 evidence remains separate. |
|
||||
| PlayTime | **Partial.** Running snapshots do not satisfy its private MobX observation. | Local Chromium explicitly logs that its running-app observer is unavailable. No automatic tracking claim. |
|
||||
| 14 other focused panels | Labels retained. Initialization alone does not establish backend functionality. | Exact frontends with synthetic nonempty running/library snapshots and backend/network denied; Game Sync still stops on the denied `get_config` read. |
|
||||
|
||||
The bridge addresses a shared dependency detected in 34 official frontend
|
||||
bundles, including hardware-inapplicable plugins whose installation remains
|
||||
blocked. This is infrastructure coverage, not 34 working plugins. Catalog after
|
||||
this pass: **3 works / 49 partial / 53 broken / 11 N/A**.
|
||||
|
||||
## Real Frame checks and cleanup
|
||||
|
||||
Every device probe acquired the atomic `/tmp/frame-test.lock` and released only
|
||||
its matching owner. Read-only probes verified the actual RunningApps and
|
||||
MainRunningApp getter implementations, the lifetime API's object with an
|
||||
`unregister()` method, and a public `https://shared.steamstatic.com` capsule URL.
|
||||
|
||||
A private removable user service ran the published Decky 3.3.0 ARM64 executable
|
||||
and the exact unmodified Discord Status 1.5.0 store archive. Production
|
||||
`framey_panel.call_method` returned false from `is_connected`. Production
|
||||
`poll_running` verified two empty running-list reads, an actual lifetime
|
||||
subscription and explicit close. The device had no running apps: **nonempty
|
||||
reads and launch/exit callbacks were not observed on hardware**. Decky startup
|
||||
reinjected its frontend/restarted Steam CEF. No plugin game, power, audio or process-control
|
||||
action was invoked, and no Discord connection/activity mutation was allowed.
|
||||
|
||||
After close, CEF reported zero sessions and a null timer. The test service,
|
||||
unit and private base were removed. A fresh independently locked audit read
|
||||
`LoadState=not-found`, `ActiveState=inactive`, `MainPID=0`, absent paths and zero
|
||||
CEF sessions/timers. Battery stayed **99 → 99**; normal Decky stayed failed and
|
||||
the normal panel service stayed inactive. No headset wipe or system service
|
||||
installation was used.
|
||||
|
||||
## Local verification and limits
|
||||
|
||||
- Full current official store fetch: 116 IDs/versions/digests unchanged at
|
||||
2026-10-26 05:18:22 UTC.
|
||||
- Fake Frame: 116 transport cases, 60 install/remove cycles and 56 expected
|
||||
blockers; private Compose project and volumes removed. Plugin execution was
|
||||
stubbed.
|
||||
- Full local Chromium sweep: 103 of 116 initializer-ready with explicitly
|
||||
synthetic library/running data. No backend/network request was allowed.
|
||||
The focused Discord Status observation lasted eight seconds and constructed
|
||||
`Test Running Game` activity; the visible UI correctly remained disconnected.
|
||||
- Node contract tests cover actual-event queue projection, retry/deduplication,
|
||||
bounds, expiry, unregister, reconnect, update-before-callback ordering,
|
||||
invalid data, public-artwork filtering, stale sessions, overlapping reads,
|
||||
failure clearing stale state and late replies after close. Nonempty app rows
|
||||
and launch/exit notifications in these tests are explicit synthetic fixtures.
|
||||
|
||||
The queue retains at most 64 real notifications per session; overflow can lose
|
||||
old callbacks. Polling runs about every two seconds with no overlap. At most four
|
||||
CEF sessions survive; unreachable cleanup expires after 15 seconds, checked
|
||||
every five seconds. The bridge does not implement private MobX internals,
|
||||
suspend/input events, game actions or closed-panel automation.
|
||||
|
||||
Discord shortcut detection, real IPC connection/activity, local custom artwork,
|
||||
actual running-game transitions, worn VR and physical controller/keyboard use
|
||||
remain unverified. None of the new fixture checks promotes a plugin to works.
|
||||
|
||||
After #301 merged, running polls and both close paths adopted its pinned SHA-256
|
||||
receipt guard. Local Python/Node tests verify the parent carries that receipt,
|
||||
missing/stale receipts are rejected before CEF after plugin replacement, and a
|
||||
reopened panel can poll and close with the new receipt. Rejected stale closes
|
||||
retain the tested finite-lease cleanup. This authorization-only follow-up did
|
||||
not repeat the device checks above; their recorded hardware scope is unchanged.
|
||||
@@ -1,9 +0,0 @@
|
||||
# v0.4.42 candidate gate, 26 October 2026
|
||||
|
||||
This branch bumps the app and lockfile to 0.4.42. It includes the [v0.4.41 release evidence](v0441-release-2026-10-26.md), the Framey panel lifecycle change in PR #299, the truthful optional-runtime smoke result in PR #300, the physical Intel Mac acceptance and host-test fixes in PR #303 (`fe1da43171883dc31161ff607c6b5a6ddf75f6ef`), and the receipt-bound API guard in PR #301 (`ad57662d40d3bbf6c850dd80ddc275280d389c86`). All 11 hosted checks passed on PR #301's exact head before its normal merge. The combined release candidate's own headset gates passed; exact-head CI and installer previews remain pending.
|
||||
|
||||
The exact v0.4.42 candidate at `27af46dd20d7af30dde4c3f954d7b57b94843594` ran `scripts/frame-smoke.sh` under its unique shared lock at 05:40 UTC. Locked preflight found standby activity 3, proximity 6.25 and battery 99%. It recorded **12 passed / 1 skipped / 0 failed** on Frame OS 0.5.6 build `20261022.6153909`; ARM64 and Proton test executables launched. The x86_64 launch was skipped because Steam Linux Runtime 4 was not installed. Cleanup passed and the command exited 0. The private detailed result is `tests/smoke/results/2026-10-26T054014Z-20261022.6153909.json` in the candidate checkout. The prior PR #300 result is documented [separately](frame-smoke-runtime-skip-2026-10-26.md).
|
||||
|
||||
The exact candidate then ran `scripts/framey-vr-check.py --exercise` under a fresh owned lock at 05:41 UTC. Preflight again found activity 3 and battery 99%. The real Decky sample setting saved `enabled=true` and survived a fresh iframe; the quick action read the same value. A trusted synthetic OpenVR pointer reached the panel; the sample slider reached 50 and CDP text entry after OpenVR focus reached `framey-ci`. Stereo compositor hashes changed from `38265343e13f5915869c9622ab1ceda7c31ab2e9b050f44564cfb9b6dc9f6e6d` to `64cc4c6f865bb8b2f756632c922d17dee12af40d480171586f2e9a51797b83e6`; the mirror capture hash was `165289dca9ad2bd895eb544f5be5d5fa2db7d2f635a927ac68a0c74267f69ee2`. The exercise exited 0 with `passed=true`, battery 99% afterward, original power values restored (`false` / `5.000000`), recovery timer removed, no cleanup errors and its own lock released. Its private full result is `/tmp/framey-v0442-exercise/result.json` on the test host. Synthetic scroll events left `scrollTop=0` and produced no wheel events. No person wore the Frame; physical controller interaction, official plugin VR panels, optical readability, VR keyboard and comfort remain unverified. The earlier [receipt-guard exercise](framey-receipt-live-2026-10-26.md) is separate evidence.
|
||||
|
||||
The release tag and published installers do not exist at this candidate stage. Publication remains gated on this combined head's exact-head CI, then tagged installer checksums, package checks and public updater readback. The 116 exact-version Decky catalog remains **3 works / 48 partial / 54 broken / 11 N/A**; the two additional works claims are restricted to tested per-app silent-stream volume control. The official v0.4.39 Intel installer ran on a physical MacBook Air, but no tagged v0.4.42 Mac launch or real OBS audio/video check is claimed.
|
||||
@@ -64,7 +64,9 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `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 |
|
||||
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
|
||||
| `SteamClient` subscriptions and `DFL.Router.MainRunningApp` | Explicit inert registration methods return disposable handles; no events or attached running game. Mutating Steam/power methods are unavailable. | Host contract tests; these are safe fallbacks, not live Steam functionality |
|
||||
| `SteamUIStore` / `DFL.Router.RunningApps`, `MainRunningApp` | Bounded detached Steam CEF reads while the panel is open; refresh about every two seconds; no MobX observation. Failed reads clear stale context. | Locked real Frame empty-list reads; nonempty local Chromium fixtures. Real running-game selection unverified. |
|
||||
| `GameSessions.RegisterForAppLifetimeNotifications` | Actual Steam callbacks, bounded queue and disposable `.unregister()` handle; no events inferred from snapshot changes; lease cleanup after lost panels | Real Frame registration/poll/close; launch/exit, retry, expiry and duplicate tests use explicit fixtures. Real game transitions unverified. |
|
||||
| Other `SteamClient` subscriptions | Explicit inert registration methods return disposable handles; no fabricated power/input/game-action events. Mutating Steam/power methods are unavailable. | Host contract tests; safe fallbacks only |
|
||||
| `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check |
|
||||
| Optional `settings`, `frameyQuickActions` | Separate settings tab when content also exists; settings-only plugins have no empty Panel tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e; real ARM64 Notebook save/reopen across backend restart and Simple Timer settings/recents persistence via local Chromium. No worn-VR claim. |
|
||||
|
||||
@@ -4,7 +4,7 @@ Snapshot: 2026-10-25T23:19:48.854504+00:00; 116 current visible official plugins
|
||||
|
||||
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
|
||||
|
||||
**Works: 3 · Partial: 48 · Broken: 54 · N/A: 11.**
|
||||
**Works: 3 · Partial: 49 · Broken: 53 · N/A: 11.**
|
||||
|
||||
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
|
||||
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
|
||||
@@ -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. Volume Boost stays partial: default-sink reads matched, but real sink writes were denied. Audible output, Bluetooth playback and worn VR remain unverified. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. Junk-Store loading/error views render, but its real backend cannot execute the extracted startup script, so it stays broken. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. TabMaster/Junk-Store ran in actual isolated amd64 Decky; no ARM64 or worn-VR result is claimed. Beyond the documented checks, official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, install folders and system information, plus IsThereAnyDeal's country dropdown in local Chromium. The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md).
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. Volume Boost stays partial: default-sink reads matched, but real sink writes were denied. 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. Junk-Store loading/error views render, but its real backend cannot execute the extracted startup script, so it stays broken. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. TabMaster/Junk-Store ran in actual isolated amd64 Decky; no ARM64 or worn-VR result is claimed. Beyond the documented checks, official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, install folders and system information, plus IsThereAnyDeal's country dropdown in local Chromium. The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md).
|
||||
|
||||
## Every current plugin
|
||||
|
||||
@@ -40,11 +40,11 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 10: vibrantDeck 2.0.1 | legacy / legacy | N/A | Not supported on Frame: Changes X11 gamescope color properties, not the Frame SteamVR compositor. | static archive, local browser, fake-Frame |
|
||||
| 11: Fantastic 0.5.1 | legacy / legacy | N/A | Not supported on Frame: Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. | static archive, local browser, fake-Frame |
|
||||
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Legacy media controls initialize; player discovery and playback are unverified. | static archive, local browser, fake-Frame |
|
||||
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings render locally with inert system-key subscriptions and empty detached Steam context. Live-game discovery, process pause/resume and suspend integration are unsupported/unverified; no backend actions ran. | static archive, local browser, fake-Frame |
|
||||
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied. | static archive, local browser, fake-Frame |
|
||||
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel. | static archive, local browser, fake-Frame |
|
||||
| 18: Bluetooth 3.1.1 | 1 / 2 | partial | Controls render with fixture replies; actual Bluetooth discovery, labels and connections remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 19: Notebook 0.1.5 | legacy / legacy | works | Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Per-game auto-selection and background artwork unavailable; worn-VR input unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 19: Notebook 0.1.5 | legacy / legacy | works | Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Running-game context is now supplied; per-game selection was attempted with a synthetic game and backend calls denied. Real per-game selection, background artwork and worn-VR input remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 21: PowerTools 2.0.3 | legacy / legacy | N/A | Not supported on Frame: Targets AMD Ryzen power controls; Frame uses an ARM64 SoC. | static archive, local browser, fake-Frame |
|
||||
| 22: Memory Deck 0.1.8 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: memory-deck/bin/libscanmem.so.1.0.0. | static archive, local browser, fake-Frame |
|
||||
| 23: Animation Changer 1.3.2 | legacy / legacy | partial | Initializer succeeds; forced backend failure prevents loading animations. Boot-video changes untested. | static archive, local browser, fake-Frame |
|
||||
@@ -56,7 +56,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 35: MoonDeck 1.13.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: moondeck/python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so. Frontend initializes; native CPython wheels and executables in the published archive are x86_64. | static archive, local browser, fake-Frame |
|
||||
| 36: SteamGridDB 1.8.1 | 1 / 2 | broken | Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel. | static archive, local browser, fake-Frame |
|
||||
| 37: Discord Status 1.5.0 | 1 / 2 | broken | Detached app overviews are now available, but RunningApps/lifetime observations and Steam artwork helpers still lack a live mapping. Discord activity integration remains unavailable/unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 37: Discord Status 1.5.0 | 1 / 2 | partial | Disconnected panel renders; running-activity construction accepts a synthetic game in local Chromium. Exact official is_connected returned false in real Decky 3.3.0 ARM64. Empty live running-list reads and lifetime registration/cleanup passed. Real game transitions, Discord shortcut detection/connection/activity, local custom artwork and worn VR remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | works | Per-app stream discovery and slider/keyboard volume changes pass in local Chromium through real Decky 3.3.0 ARM64 on the Frame audio session, with independent Pulse readback of a silent test stream. Bluetooth playback, audible output and worn-VR controls unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||
| 40: Controller Tools 2.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: ControllerTools/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 43: Tailscale Control 0.1.3 | legacy / legacy | partial | Settings render locally after the SuspensefulImage fallback. Tailscale daemon/state, connection and exit-node changes unverified; bundled localhost QR asset remains unavailable. No network settings were changed. | static archive, local browser, fake-Frame |
|
||||
@@ -76,7 +76,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 63: Free Loader 1.6.0 | 1 / 2 | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
|
||||
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. | static archive, local browser, fake-Frame |
|
||||
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
|
||||
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. ARM64 backend, live MobX/running-game observations and automatic recording remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 67: Battery Tracker 0.2.0 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 68: Wine Cellar 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-wine-cellar/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 70: MicroSDeck 0.11.2 | legacy / 2 | broken | Plugin contains a non-ARM64 Linux binary: MicroSDeck/bin/backend. | static archive, local browser, fake-Frame |
|
||||
|
||||
+34
-7
@@ -115,9 +115,9 @@ Its cleanup actions were not tested; they may remove local game saves.
|
||||
|
||||
Framey also supplies local tabs/sidebar pages, spinners, dialog/menu wrappers
|
||||
and progress bars. Its explicit SteamClient subscription fallbacks return
|
||||
disposable handles without emitting fabricated events. There is no attached
|
||||
running game in `DFL.Router`, and power, app launch and hardware mutation methods
|
||||
are not supplied. Plugins that need live Steam stores still require integration
|
||||
disposable handles without emitting fabricated events. Running-game reads and
|
||||
lifetime notifications use the scoped bridge described below; power, app launch
|
||||
and hardware mutation methods are not supplied. Other live Steam stores require integration
|
||||
work. IndexedDB is hidden because the browser exposes it but denies access in
|
||||
an opaque iframe; localForage can then select Framey's bounded localStorage.
|
||||
Direct IndexedDB users remain unsupported.
|
||||
@@ -125,13 +125,40 @@ Direct IndexedDB users remain unsupported.
|
||||
`SuspensefulImage` renders a native image. SteamClient `Storage.GetJSON` and
|
||||
`SetObject` use the same bounded per-plugin cache, including panel reopen
|
||||
persistence; they do not read Steam's account/global JSON store. Missing keys
|
||||
reject with `Not found`. Detached `SteamUIStore.RunningApps` is a frozen empty
|
||||
list, and the suspend store has no methods. Optional Steam app/account globals
|
||||
are undefined. These fallbacks let settings render without inventing live
|
||||
reject with `Not found`. The suspend store has no methods. Optional Steam app/account globals
|
||||
are undefined until a successful library read. These fallbacks let settings render without inventing live
|
||||
games or account identity. [The exact-bundle follow-up](evidence/framey-ui-fallbacks-2026-10-26.json)
|
||||
verifies Pause Games and Tailscale Control settings in local Chromium; their
|
||||
primary backend actions remain unverified. PlayTime still requires Steam's
|
||||
real app store and game observers.
|
||||
MobX game observers for automatic tracking.
|
||||
|
||||
Panels referencing running-game APIs now receive a detached, read-only
|
||||
`SteamUIStore.RunningApps` / `MainRunningApp` snapshot, also exposed through
|
||||
`DFL.Router`. The authenticated headset-only `/api/framey/running` endpoint
|
||||
checks installed store ownership; the parent checks the active panel session.
|
||||
Poll and close requests also carry the panel's pinned SHA-256 receipt. A missing
|
||||
or changed installed receipt is rejected before any Steam CEF request; a rejected
|
||||
stale close relies on the existing finite subscription lease for cleanup.
|
||||
Fixed bundled code runs in Steam CEF. It copies at most 64 running app overviews, a main app and
|
||||
public Steam CDN capsule URLs; it never copies local artwork paths, launch
|
||||
commands or Steam objects. The host refreshes about every two seconds without
|
||||
overlapping requests. Failure clears stale context and sets
|
||||
`SteamUIStore.FrameyRunningAvailable` to false.
|
||||
|
||||
`GameSessions.RegisterForAppLifetimeNotifications` relays actual Steam
|
||||
`{unAppID, bRunning}` callbacks through a bounded 64-event queue. Snapshot
|
||||
changes never fabricate launch/exit callbacks. Handles support `.unregister()`;
|
||||
panel close/replacement removes the CEF subscription, with a 15-second lease
|
||||
(checked every five seconds) if cleanup cannot reach Steam. At most four panel
|
||||
sessions are retained. These are open-panel observations, not closed-panel
|
||||
automation or a MobX implementation. Queue overflow can lose old callbacks.
|
||||
`appStore` capsule helpers expose only projected public URLs for running apps;
|
||||
unavailable or local custom artwork returns an empty value.
|
||||
|
||||
[Running-context evidence](evidence/framey-running-context-2026-10-26.md) separates
|
||||
real Frame empty-list/subscription/cleanup reads, the exact Discord Status
|
||||
ARM64 backend connection check, and synthetic nonempty/transition fixtures.
|
||||
Real game transitions, Discord activity and worn-VR operation remain unverified.
|
||||
|
||||
Three parameterless SteamClient reads use Steam's actual CEF context:
|
||||
`User.GetIPCountry`, `InstallFolder.GetInstallFolders` and `System.GetSystemInfo`.
|
||||
|
||||
@@ -36,7 +36,7 @@ Ready means initializer completion, not useful plugin behavior. No plugin backen
|
||||
<button id=run>Run catalog</button> <button id=download>Download observations</button><p id=progress></p>
|
||||
<iframe id=panel sandbox="allow-scripts" style="width:1100px;height:740px"></iframe>
|
||||
<script>
|
||||
const plugins=CATALOG_DATA, librarySnapshot=LIBRARY_DATA;
|
||||
const plugins=CATALOG_DATA, librarySnapshot=LIBRARY_DATA, runningSnapshot=RUNNING_DATA;
|
||||
window.results=[];let active=null,running=false;
|
||||
addEventListener('message',event=>{
|
||||
if(event.source!==panel.contentWindow||!active)return;
|
||||
@@ -45,6 +45,8 @@ addEventListener('message',event=>{
|
||||
if(data.type==='framey:error')active.error=String(data.message).slice(0,500);
|
||||
if(data.type==='framey:ready')active.ready=true;
|
||||
if(data.type==='framey:events')event.source.postMessage({type:'framey:reply',id:data.id,result:{events:[],cursor:0}},'*');
|
||||
if(data.type==='framey:running')event.source.postMessage({type:'framey:reply',id:data.id,
|
||||
...(runningSnapshot?{result:runningSnapshot}:{error:'Read-only compatibility audit: live running context blocked'})},'*');
|
||||
if(data.type==='framey:steam')event.source.postMessage({type:'framey:reply',id:data.id,
|
||||
...(data.method==='GetLibrarySnapshot'&&librarySnapshot?
|
||||
{result:librarySnapshot}:{error:'Read-only compatibility audit: live Steam API blocked'})},'*');
|
||||
@@ -53,13 +55,14 @@ addEventListener('message',event=>{
|
||||
event.source.postMessage({type:'framey:reply',id:data.id,error:'Read-only compatibility audit: backend method blocked'},'*');}
|
||||
if(data.type==='framey:cache:init')event.source.postMessage({type:'framey:cache:seed',session:data.session,entries:{}},'*');
|
||||
});
|
||||
window.runAudit=async(ids)=>{
|
||||
window.runAudit=async(ids,settleMs=1200)=>{
|
||||
if(!Number.isInteger(settleMs)||settleMs<1200||settleMs>10000)throw Error('Invalid audit settle time');
|
||||
if(running)throw Error('Audit already running');running=true;run.disabled=true;
|
||||
try{for(const plugin of plugins.filter(plugin=>!ids||ids.includes(plugin.id))){
|
||||
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],
|
||||
library:librarySnapshot?'snapshot replay':'unavailable'};
|
||||
library:librarySnapshot?'snapshot replay':'unavailable',running:runningSnapshot?'snapshot replay':'unavailable'};
|
||||
progress.textContent=plugin.id+' '+plugin.name;panel.src='/panel/'+plugin.id;
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));results.push(active);active=null;
|
||||
await new Promise(resolve=>setTimeout(resolve,settleMs));results.push(active);active=null;
|
||||
panel.src='about:blank';await new Promise(resolve=>setTimeout(resolve,30));
|
||||
}}finally{running=false;run.disabled=false;progress.textContent='Done: '+results.length;}
|
||||
return results;
|
||||
@@ -77,6 +80,8 @@ def main():
|
||||
parser.add_argument('--port', type=int, default=18765)
|
||||
parser.add_argument('--library-snapshot', type=Path,
|
||||
help='Optional detached Steam library JSON; replay only, not live Steam/backend evidence')
|
||||
parser.add_argument('--running-snapshot', type=Path,
|
||||
help='Optional detached running-app JSON; replay only, not live Steam/backend evidence')
|
||||
args = parser.parse_args()
|
||||
plugins = json.loads(args.catalog.read_text())['plugins']
|
||||
records = {p['id']: p for p in plugins}
|
||||
@@ -86,6 +91,12 @@ def main():
|
||||
if len(raw) > 2*1024*1024:
|
||||
parser.error('library snapshot exceeds 2 MiB')
|
||||
library = json.loads(raw)
|
||||
running = None
|
||||
if args.running_snapshot:
|
||||
raw = args.running_snapshot.read_bytes()
|
||||
if len(raw) > 256*1024:
|
||||
parser.error('running snapshot exceeds 256 KiB')
|
||||
running = json.loads(raw)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
@@ -99,7 +110,8 @@ def main():
|
||||
# Only names/IDs are needed by the UI; never embed raw executable source.
|
||||
catalog = json.dumps([{'id': p['id'], 'name': p['name']} for p in plugins]).replace('<', '\\u003c')
|
||||
body = PAGE.replace('CATALOG_DATA', catalog).replace(
|
||||
'LIBRARY_DATA', json.dumps(library).replace('<', '\\u003c')).encode()
|
||||
'LIBRARY_DATA', json.dumps(library).replace('<', '\\u003c')).replace(
|
||||
'RUNNING_DATA', json.dumps(running).replace('<', '\\u003c')).encode()
|
||||
elif path.startswith('/panel/'):
|
||||
try:
|
||||
record = records[int(path.split('/')[-1])]
|
||||
|
||||
@@ -55,6 +55,7 @@ def render(data):
|
||||
'through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, '
|
||||
'independent server readbacks and Mixer filter persistence across a runtime restart passed. '
|
||||
'An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. Volume Boost stays partial: default-sink reads matched, but real sink writes were denied. Audible output, Bluetooth playback and worn VR remain unverified. '
|
||||
'A [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. '
|
||||
|
||||
@@ -3,7 +3,7 @@ import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI; window.__library=setLibrarySnapshot;\n})();');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI; window.__library=setLibrarySnapshot; window.__running=setRunningSnapshot; window.__refreshRunning=refreshRunning;\n})();');
|
||||
assert.notEqual(testSource,source);
|
||||
const states=[];
|
||||
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
@@ -254,6 +254,7 @@ assert.equal(window.SteamClient.System.ShutdownPC,undefined);
|
||||
assert.equal(window.DFL.Router.MainRunningApp,undefined);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,0);
|
||||
assert.equal(window.DFL.Router.RunningApps,window.SteamUIStore.RunningApps);
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,false);
|
||||
assert.ok(Object.isFrozen(window.SteamUIStore));
|
||||
assert.ok(Object.isFrozen(window.SuspendResumeStore));
|
||||
assert.equal(window.SuspendResumeStore.InitiateSleep,undefined);
|
||||
@@ -283,6 +284,46 @@ for(const invalid of [null,{apps:[],gameIds:[10]},
|
||||
assert.throws(()=>window.__library(invalid),/Invalid Steam/);
|
||||
assert.equal(window.appStore.allApps[0].display_name,'Plugin-local patch');
|
||||
}
|
||||
const running={artwork:[[10,'https://shared.steamstatic.com/steam/apps/10/library.jpg',[]]],apps:[snapshot.apps[0]],runningIds:[10],mainAppId:10,events:[],cursor:0,lifetimeAvailable:true};
|
||||
const lifetimes=[];
|
||||
const lifetimeHandle=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(event=>{
|
||||
lifetimes.push({...event,running:Array.from(window.DFL.Router.RunningApps,app=>app.appid)});
|
||||
});
|
||||
window.__running(running);await Promise.resolve();
|
||||
assert.equal(lifetimes.length,0); // Initial snapshots are not launch events.
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,true);
|
||||
assert.equal(window.DFL.Router.MainRunningApp.display_name,'Game Ten');
|
||||
assert.equal(window.appStore.GetVerticalCapsuleURLForApp({appid:10}),'https://shared.steamstatic.com/steam/apps/10/library.jpg');
|
||||
assert.equal(window.appStore.GetCustomVerticalCapsuleURLs({appid:10}).length,0);
|
||||
assert.equal(window.DFL.Router.RunningApps,window.SteamUIStore.RunningApps);
|
||||
window.DFL.Router.MainRunningApp.display_name='Detached';
|
||||
assert.equal(running.apps[0][1],'Game Ten');
|
||||
const launch={...running,events:[{sequence:1,unAppID:10,bRunning:true}],cursor:1};
|
||||
window.__running(launch);await Promise.resolve();
|
||||
assert.equal(lifetimes.length,1);assert.deepEqual(lifetimes[0].running,[10]);
|
||||
window.__running(launch);await Promise.resolve();assert.equal(lifetimes.length,1);
|
||||
for(const invalid of [null,{...running,runningIds:[99]}, {...running,mainAppId:99},
|
||||
{...running,runningIds:[10,10]}, {...running,artwork:[[10,'file:///private',[]]]}, {...running,cursor:-1},
|
||||
{...running,events:[{sequence:1,unAppID:10,bRunning:'yes'}],cursor:1}]){
|
||||
assert.throws(()=>window.__running(invalid),/Invalid Steam/);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,1);
|
||||
}
|
||||
const exited={...running,artwork:[],apps:[],runningIds:[],mainAppId:null,
|
||||
events:[{sequence:2,unAppID:10,bRunning:false}],cursor:2};
|
||||
window.__running(exited);await Promise.resolve();
|
||||
assert.deepEqual(lifetimes[1].running,[]);assert.equal(lifetimes[1].bRunning,false);
|
||||
lifetimeHandle.unregister();lifetimeHandle();
|
||||
window.__running({...launch,cursor:3,events:[{sequence:3,unAppID:10,bRunning:true}]});
|
||||
await Promise.resolve();assert.equal(lifetimes.length,2);
|
||||
// A failed live read clears stale running context without inventing an exit.
|
||||
const refresh=window.__refreshRunning();
|
||||
const runningRequest=messages.at(-1);assert.equal(runningRequest.type,'framey:running');
|
||||
listeners.find(l=>l.type==='message').handler({source:parent,data:{type:'framey:reply',id:runningRequest.id,error:'offline'}});
|
||||
await refresh;
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,false);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,0);
|
||||
assert.equal(window.DFL.Router.MainRunningApp,undefined);
|
||||
assert.equal(lifetimes.length,2);
|
||||
for(const register of [window.SteamClient.System.UI.RegisterForSystemKeyEvents,
|
||||
window.SteamClient.Apps.RegisterForGameActionUserRequest]){
|
||||
const dispose=register(()=>eventCount++);assert.equal(dispose.unregister,dispose);dispose();
|
||||
@@ -359,3 +400,13 @@ assert.throws(()=>api.addEventListener('bad event',callback),/Invalid Decky even
|
||||
assert.throws(()=>api.addEventListener('tick',null),/Invalid Decky event/);
|
||||
listeners.find(x=>x.type==='unload').handler();
|
||||
assert.equal(messages.at(-1).type,'framey:events:close');
|
||||
const finalRefresh=window.__refreshRunning();
|
||||
assert.equal(window.__refreshRunning(),finalRefresh,'Running reads must not overlap');
|
||||
const finalRequest=messages.at(-1);
|
||||
listeners.find(x=>x.type==='pagehide').handler();
|
||||
assert.equal(messages.at(-1).type,'framey:running:close');
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:finalRequest.id,result:launch}});
|
||||
await finalRefresh;
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,false,'Closed panels ignore late snapshots');
|
||||
const messageCount=messages.length;
|
||||
await window.__refreshRunning();assert.equal(messages.length,messageCount);
|
||||
@@ -69,3 +69,30 @@ vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
const priorReplies=replies.length;
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
|
||||
await staleRead;assert.equal(replies.length,priorReplies);
|
||||
|
||||
// Running reads are tied to the current iframe and session, with no method path.
|
||||
session=vm.runInContext('frameySession',context);
|
||||
const running={type:'framey:running',id:20,session,args:[0]};
|
||||
const beforeRunning=requests.length;
|
||||
for(const change of [{session:'old'},{args:[-1]},{args:[true]},{args:[0,'extra']}])
|
||||
await send({...running,...change});
|
||||
await send(running,{});assert.equal(requests.length,beforeRunning);
|
||||
const pendingRunning=send(running);
|
||||
assert.equal(requests.at(-1).path,'/api/framey/running');
|
||||
assert.equal(requests.at(-1).body.active,true);
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
const oldRunning=requests.at(-1);
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
assert.equal(requests.at(-1).body.active,false);
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
assert.equal(requests.at(-1).body.session,session);
|
||||
const beforeStaleReply=replies.length;
|
||||
oldRunning.resolve({ok:true,json:async()=>({result:{apps:[],events:[],cursor:0}})});
|
||||
await pendingRunning;assert.equal(replies.length,beforeStaleReply);
|
||||
session=vm.runInContext('frameySession',context);
|
||||
const finalRunning=send({...running,id:21,session});
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:{apps:[],events:[],cursor:0}})});
|
||||
await finalRunning;
|
||||
await send({type:'framey:running:close',session});
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
assert.equal(requests.at(-1).body.active,false);
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
// Real Frame getter source and unregister-handle shape were probed over SSH.
|
||||
// Nonempty rows and launch/exit notifications here are explicit test fixtures.
|
||||
const source=readFileSync(new URL('../../ui/framey-running.js',import.meta.url),'utf8');
|
||||
const listeners=new Set(),timers=new Map();let now=1000,nextTimer=0;
|
||||
const api={RegisterForAppLifetimeNotifications(listener){
|
||||
listeners.add(listener);return {unregister:()=>listeners.delete(listener)};
|
||||
}};
|
||||
const app={appid:10,display_name:'Test game',sort_as:'test',app_type:1,gameid:'10',
|
||||
minutes_playtime_forever:30,minutes_playtime_last_two_weeks:5,rt_last_time_played:123,
|
||||
private_path:'PRIVATE',owner:'PRIVATE'};
|
||||
const window={SteamClient:{GameSessions:api},SteamUIStore:{RunningApps:[app],MainRunningApp:app}};
|
||||
window.appStore={GetVerticalCapsuleURLForApp:()=> 'https://shared.steamstatic.com/steam/apps/10/library.jpg?t=123',
|
||||
GetCustomVerticalCapsuleURLs:()=>['file:///PRIVATE/path','https://example.com/PRIVATE','https://shared.steamstatic.com/steam/apps/10/custom.jpg']};
|
||||
const context=vm.createContext({window,URL,Date:{now:()=>now},
|
||||
setInterval:fn=>{timers.set(++nextTimer,fn);return nextTimer;},clearInterval:id=>timers.delete(id)});
|
||||
const poll=(session='one',pluginId=13,active=true,cursor=0)=>
|
||||
JSON.parse(JSON.stringify(vm.runInContext(source,context)({session,pluginId,active,cursor})));
|
||||
const emit=event=>{for(const listener of listeners)listener(event);};
|
||||
let data=poll();
|
||||
assert.deepEqual(data.apps,[[10,'Test game','test',1,'10',30,5,123]]);
|
||||
assert.deepEqual(data.artwork,[[10,'https://shared.steamstatic.com/steam/apps/10/library.jpg',['https://shared.steamstatic.com/steam/apps/10/custom.jpg']]]);
|
||||
assert.deepEqual(data.runningIds,[10]);assert.equal(data.mainAppId,10);
|
||||
assert.equal(data.lifetimeAvailable,true);assert.deepEqual(data.events,[]);
|
||||
assert.equal(listeners.size,1);
|
||||
// A nonempty snapshot does not invent a game launch notification.
|
||||
assert.equal(data.cursor,0);assert.deepEqual(poll().events,[]);
|
||||
emit({unAppID:10,bRunning:true,private_token:'PRIVATE'});
|
||||
assert.deepEqual(poll().events,[{sequence:1,unAppID:10,bRunning:true}]);
|
||||
assert.deepEqual(poll('one',13,true,1).events,[]);
|
||||
assert.equal(poll().events.length,1); // Retry a dropped HTTP response.
|
||||
assert.equal(listeners.size,1); // Polls never duplicate registration.
|
||||
poll('two',37);assert.equal(listeners.size,2);
|
||||
assert.throws(()=>poll('one',37),/another plugin/);
|
||||
for(const event of [{unAppID:'10',bRunning:true},{unAppID:10,bRunning:1},
|
||||
{unAppID:NaN,bRunning:true},{unAppID:-1,bRunning:false}])emit(event);
|
||||
assert.equal(poll().cursor,1);
|
||||
for(let n=0;n<100;n++)emit({unAppID:10,bRunning:!!(n%2)});
|
||||
assert.equal(poll().events.length,64);
|
||||
assert.equal(poll().cursor,101);
|
||||
window.SteamUIStore.RunningApps=[];window.SteamUIStore.MainRunningApp=undefined;
|
||||
assert.deepEqual(poll().runningIds,[]);assert.equal(poll().mainAppId,null);
|
||||
assert.equal(poll().cursor,101); // Reading a closed game invents no exit event.
|
||||
assert.equal(poll('one',13,false),null);assert.equal(listeners.size,1);
|
||||
now+=16000;for(const timer of [...timers.values()])timer();
|
||||
assert.equal(listeners.size,0);assert.equal(timers.size,0);
|
||||
poll();assert.equal(listeners.size,1);
|
||||
window.SteamClient.GameSessions={...api};poll();assert.equal(listeners.size,1);
|
||||
poll('one',13,false);assert.equal(listeners.size,0);
|
||||
window.SteamUIStore.RunningApps=Array(65).fill(app);
|
||||
assert.throws(()=>poll(),/Too many/);assert.equal(listeners.size,0);
|
||||
window.SteamUIStore=undefined;
|
||||
assert.throws(()=>poll(),/unavailable/);assert.equal(listeners.size,0);
|
||||
@@ -22,6 +22,54 @@ RECEIPT = 'a' * 64
|
||||
|
||||
|
||||
class Panel(unittest.TestCase):
|
||||
def test_running_queue_and_host_contract(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_running.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_running_bridge_is_installed_scoped_bounded_and_headset_only(self):
|
||||
def fake_ssh(command, stdin, **_):
|
||||
self.assertEqual(command, 'python3 -')
|
||||
compile(stdin, '<running read>', 'exec')
|
||||
self.assertIn('RegisterForAppLifetimeNotifications', stdin)
|
||||
self.assertIn('262144', stdin)
|
||||
self.assertNotIn('__FRAMEY_STEAM_', stdin)
|
||||
return '{"result":{"apps":[],"events":[],"cursor":0}}'
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={'plugins':[{'storeId':13,'sha256':'a'*64}]}):
|
||||
self.assertEqual(framey_panel.poll_running(fake_ssh,13,'test-1',True,receipt='a'*64)['result']['cursor'],0)
|
||||
for plugin_id,session,active,cursor in [(True,'test',True,0),(0,'test',True,0),
|
||||
(14,'test',True,0),(13,'bad session',True,0),(13,'test',1,0),
|
||||
(13,'test',True,True),(13,'test',True,-1),(13,'test',True,2**53)]:
|
||||
with self.subTest(args=(plugin_id,session,active,cursor)):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.poll_running(lambda *a,**k:self.fail('Unexpected SSH'),
|
||||
plugin_id,session,active,cursor,receipt='a'*64)
|
||||
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.framey_running_action({'id':13,'session':'test','active':True})
|
||||
with patch.object(server, 'HEADSET_RUNTIME', True), patch.object(framey_panel, 'poll_running',return_value={'result':None}) as poll:
|
||||
self.assertEqual(server.framey_running_action({'id':13,'session':'test','active':False,'receipt':'a'*64}),{'result':None})
|
||||
poll.assert_called_once_with(server.ssh,13,'test',False,0,'a'*64)
|
||||
|
||||
def test_running_rejects_stale_or_missing_receipt_after_plugin_replacement(self):
|
||||
installed = {'storeId':13,'sha256':'a'*64}
|
||||
def safe_ssh(command, stdin, **_):
|
||||
self.assertEqual(command,'python3 -')
|
||||
compile(stdin,'<running read>','exec')
|
||||
return '{"result":null}'
|
||||
with patch.object(framey_panel.frame_decky,'status',side_effect=lambda _: {'plugins':[installed]}):
|
||||
framey_panel.poll_running(safe_ssh,13,'old-panel',True,receipt='a'*64)
|
||||
installed['sha256'] = 'b'*64 # Another manager replaces the installed plugin.
|
||||
for active in (True,False):
|
||||
for receipt in (None,'','bad','a'*64):
|
||||
with self.subTest(active=active,receipt=receipt):
|
||||
with self.assertRaisesRegex(framey_panel.PanelError,'receipt|changed'):
|
||||
framey_panel.poll_running(lambda *a,**k:self.fail('Stale running request reached CEF'),
|
||||
13,'old-panel',active,receipt=receipt)
|
||||
# A reopened panel uses the new receipt for both polling and cleanup.
|
||||
framey_panel.poll_running(safe_ssh,13,'new-panel',True,receipt='b'*64)
|
||||
framey_panel.poll_running(safe_ssh,13,'new-panel',False,receipt='b'*64)
|
||||
|
||||
def test_steam_reads_are_allowlisted_and_installed_only(self):
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck','sha256':RECEIPT}]}):
|
||||
@@ -281,7 +329,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
with tarfile.open(fileobj=io.BytesIO(gzip.decompress(packed))) as archive:
|
||||
names = set(archive.getnames())
|
||||
self.assertTrue({'ui/framey_panel.py','ui/framey-host.js',
|
||||
'ui/framey-events.js',
|
||||
'ui/framey-events.js','ui/framey-running.js',
|
||||
'ui/vendor/react-18.3.1.min.js','ui/vendor/react-dom-18.3.1.min.js',
|
||||
'ui/vendor/REACT-LICENSE.txt','ui/vendor/REACT-DOM-LICENSE.txt'} <= names)
|
||||
|
||||
|
||||
@@ -580,7 +580,7 @@
|
||||
"https://github.com/wynn1212/SDH-PauseGames.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Settings render locally with inert system-key subscriptions and empty detached Steam context. Live-game discovery, process pause/resume and suspend integration are unsupported/unverified; no backend actions ran.",
|
||||
"reason": "Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -613,6 +613,11 @@
|
||||
"sha256": "4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1006,7 +1011,7 @@
|
||||
"https://github.com/popsUlfr/SDH-Notebook.git"
|
||||
],
|
||||
"status": "works",
|
||||
"reason": "Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Per-game auto-selection and background artwork unavailable; worn-VR input unverified.",
|
||||
"reason": "Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Running-game context is now supplied; per-game selection was attempted with a synthetic game and backend calls denied. Real per-game selection, background artwork and worn-VR input remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -1080,6 +1085,11 @@
|
||||
"existingNotesTouched": false
|
||||
},
|
||||
"method": "local Chromium + real Frame SSH; private actual Decky 3.3.0 ARM64 service"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2870,8 +2880,8 @@
|
||||
"https://github.com/andrewburgess/steamdeck-discord-status",
|
||||
"https://github.com/andrewburgess/steamdeck-discord-status.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Detached app overviews are now available, but RunningApps/lifetime observations and Steam artwork helpers still lack a live mapping. Discord activity integration remains unavailable/unverified.",
|
||||
"status": "partial",
|
||||
"reason": "Disconnected panel renders; running-activity construction accepts a synthetic game in local Chromium. Exact official is_connected returned false in real Decky 3.3.0 ARM64. Empty live running-list reads and lifetime registration/cleanup passed. Real game transitions, Discord shortcut detection/connection/activity, local custom artwork and worn VR remain unverified/unsupported.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -2917,6 +2927,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3404,6 +3419,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3985,6 +4005,11 @@
|
||||
"sha256": "71dc21ce5f9f4ee0573bd927e9b444d45dc47327888ca78ed063c7bcc57bfac1",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4447,6 +4472,11 @@
|
||||
"sha256": "e08333859a32b0c177b8e9a7959c5eeeafe38f946578f7f1d161c721f0e05da5",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4964,7 +4994,7 @@
|
||||
"https://github.com/your-username/PlayTime"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. ARM64 backend, live MobX/running-game observations and automatic recording remain unverified/unsupported.",
|
||||
"reason": "Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -5016,6 +5046,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -6920,6 +6955,11 @@
|
||||
"sha256": "2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7289,6 +7329,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7719,6 +7764,11 @@
|
||||
"sha256": "49309a02fa028d26c77230cc9f70fec9bcce91d59e887bfe8f9d3e42f8a19139",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7906,6 +7956,11 @@
|
||||
"sha256": "b6c514d147cc46bad5331ca9da36628eb9c330976e13735687499a4a9f17f6d4",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -8607,6 +8662,11 @@
|
||||
"sha256": "8a69f45aadce763406b5c27bfef194761006d5e01c5e8841badeb13a27dd0d88",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -8716,6 +8776,11 @@
|
||||
"sha256": "65e021ebb37d2ebfbfbe3dbc8af95aef9556cf484730362c6b26c4ecc3e62ccd",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -9710,6 +9775,11 @@
|
||||
"sha256": "ec59fc8aceb4e1abe2066b3a0707ed6313cabc4421b7c17765397903e2f8fd26",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -10845,6 +10915,11 @@
|
||||
"sha256": "17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -11207,6 +11282,11 @@
|
||||
"sha256": "3def99993c412b669933376b6420e44c68cd1fe9d9baf14b77a1f231242bd7e8",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -11691,6 +11771,11 @@
|
||||
"sha256": "248294b5d6b9d5a5d635ce35f4b5185ce59d4e4fd59353e24df4b29d74c2ade4",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -13228,6 +13313,11 @@
|
||||
"sha256": "33fb674993c9b2bd119f0c6118c465e379f166ec1539c8183fcf7580f1d89577",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+100
-13
@@ -15,7 +15,14 @@
|
||||
// user identity or suspend state in this isolated document.
|
||||
for (const name of ['App','appStore','appInfoStore','collectionStore']) window[name] = undefined;
|
||||
window.SuspendResumeStore = Object.freeze({});
|
||||
window.SteamUIStore = Object.freeze({RunningApps:Object.freeze([]), MainRunningApp:undefined});
|
||||
let runningApps = Object.freeze([]), mainRunningApp, runningAvailable = false;
|
||||
let runningArtwork = new Map();
|
||||
const runningListeners = new Set();
|
||||
let runningCursor = 0, runningRequest = null, runningTimer = null, runningClosed = false;
|
||||
window.SteamUIStore = Object.freeze({
|
||||
get RunningApps() {return runningApps;}, get MainRunningApp() {return mainRunningApp;},
|
||||
get FrameyRunningAvailable() {return runningAvailable;},
|
||||
});
|
||||
// Steam normally fills these token maps. Official plugins can safely use
|
||||
// their own fallback strings when the isolated panel has no Steam tokens.
|
||||
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en'],
|
||||
@@ -383,8 +390,8 @@
|
||||
PlayAudioURL:()=>{throw Error('Steam UI sound playback is not supported in Framey');},
|
||||
})}),
|
||||
})}));
|
||||
// No Steam game is attached to an isolated panel. Subscriptions have a
|
||||
// disposable handle but emit no fabricated game, power, or input events.
|
||||
// Unsupported subscriptions remain inert. Lifetime callbacks below relay
|
||||
// actual Steam notifications; no power, input or launch events are invented.
|
||||
const subscription = () => {const dispose=()=>{};dispose.unregister=dispose;return dispose;};
|
||||
const register = names => Object.fromEntries(names.map(name=>[name,subscription]));
|
||||
const steamLanguage = () => {
|
||||
@@ -403,7 +410,14 @@
|
||||
...register(['RegisterForSettingsChanges'])},
|
||||
Apps:register(['RegisterForAppDetails','RegisterForGameActionTaskChange',
|
||||
'RegisterForGameActionStart','RegisterForGameActionEnd','RegisterForGameActionUserRequest']),
|
||||
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
|
||||
GameSessions:{...register(['RegisterForScreenshotNotification']),
|
||||
RegisterForAppLifetimeNotifications:listener=>{
|
||||
if (typeof listener !== 'function') throw Error('Invalid Steam lifetime listener');
|
||||
if (runningListeners.size >= 32) throw Error('Too many Steam lifetime listeners');
|
||||
runningListeners.add(listener);
|
||||
const dispose=()=>runningListeners.delete(listener);
|
||||
dispose.unregister=dispose; return dispose;
|
||||
}},
|
||||
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
|
||||
GetSystemInfo:()=>request('GetSystemInfo',[],false,'framey:steam'),
|
||||
UI:register(['RegisterForFocusChangeEvents','RegisterForSystemKeyEvents'])},
|
||||
@@ -428,7 +442,9 @@
|
||||
},
|
||||
},
|
||||
};
|
||||
DFL.Router = {...DFL.Navigation, ...window.SteamUIStore};
|
||||
DFL.Router = {...DFL.Navigation,
|
||||
get RunningApps() {return runningApps;}, get MainRunningApp() {return mainRunningApp;},
|
||||
};
|
||||
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
|
||||
const toaster = {toast: ({title, body, subtext, icon, logo, duration, showToast=true, onClick}={}) => {
|
||||
const data = {title, body, subtext, icon, logo};
|
||||
@@ -541,14 +557,9 @@
|
||||
},
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
};
|
||||
function setLibrarySnapshot(snapshot) {
|
||||
// These are detached plugin-local values. Patching an overview or its Map
|
||||
// must never mutate Steam's library. Live MobX observations are unsupported.
|
||||
if (!snapshot || !Array.isArray(snapshot.apps) || snapshot.apps.length > 10000 ||
|
||||
!Array.isArray(snapshot.gameIds) || snapshot.gameIds.length > 10000 ||
|
||||
(snapshot.steamId != null && (typeof snapshot.steamId !== 'string' || !/^\d{17}$/.test(snapshot.steamId))))
|
||||
throw Error('Invalid Steam library snapshot');
|
||||
const apps = snapshot.apps.map(row => {
|
||||
function projectApps(rows, limit) {
|
||||
if (!Array.isArray(rows) || rows.length > limit) throw Error('Invalid Steam app overview');
|
||||
return rows.map(row => {
|
||||
if (!Array.isArray(row) || row.length !== 8 || !Number.isSafeInteger(row[0]) || row[0] <= 0 ||
|
||||
typeof row[1] !== 'string' || row[1].length > 2048 ||
|
||||
(row[2] != null && typeof row[2] !== 'string') || !Number.isSafeInteger(row[3]) ||
|
||||
@@ -560,16 +571,90 @@
|
||||
return {appid,display_name,sort_as,app_type,gameid,
|
||||
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played};
|
||||
});
|
||||
}
|
||||
function setLibrarySnapshot(snapshot) {
|
||||
// These are detached plugin-local values. Patching an overview or its Map
|
||||
// must never mutate Steam's library. Live MobX observations are unsupported.
|
||||
if (!snapshot || !Array.isArray(snapshot.apps) || snapshot.apps.length > 10000 ||
|
||||
!Array.isArray(snapshot.gameIds) || snapshot.gameIds.length > 10000 ||
|
||||
(snapshot.steamId != null && (typeof snapshot.steamId !== 'string' || !/^\d{17}$/.test(snapshot.steamId))))
|
||||
throw Error('Invalid Steam library snapshot');
|
||||
const apps = projectApps(snapshot.apps, 10000);
|
||||
const map = new Map(apps.map(app=>[app.appid,app]));
|
||||
if (map.size !== apps.length || snapshot.gameIds.some(id=>!Number.isSafeInteger(id) || !map.has(id)))
|
||||
throw Error('Invalid Steam game collection');
|
||||
window.appStore = {allApps:apps, m_mapApps:map,
|
||||
GetAppOverviewByAppID:id=>map.get(Number(id)),
|
||||
GetAppOverviewByGameID:id=>apps.find(app=>app.gameid === String(id)),
|
||||
GetVerticalCapsuleURLForApp:app=>runningArtwork.get(Number(app?.appid))?.[0] || '',
|
||||
GetCustomVerticalCapsuleURLs:app=>[...(runningArtwork.get(Number(app?.appid))?.[1] || [])],
|
||||
};
|
||||
window.collectionStore = {allGamesCollection:{allApps:snapshot.gameIds.map(id=>map.get(id))}};
|
||||
window.App = snapshot.steamId ? {m_CurrentUser:{strSteamID:snapshot.steamId}} : undefined;
|
||||
}
|
||||
function setRunningSnapshot(data) {
|
||||
if (!data || !Array.isArray(data.runningIds) || data.runningIds.length > 64 ||
|
||||
!Array.isArray(data.events) || data.events.length > 64 ||
|
||||
!Array.isArray(data.artwork) || data.artwork.length > 65 ||
|
||||
!Number.isSafeInteger(data.cursor) || data.cursor < 0 || typeof data.lifetimeAvailable !== 'boolean')
|
||||
throw Error('Invalid Steam running snapshot');
|
||||
const apps = projectApps(data.apps, 65), map = new Map(apps.map(app=>[app.appid,app]));
|
||||
if (map.size !== apps.length || new Set(data.runningIds).size !== data.runningIds.length ||
|
||||
data.runningIds.some(id=>!Number.isSafeInteger(id) || !map.has(id)) ||
|
||||
(data.mainAppId !== null && (!Number.isSafeInteger(data.mainAppId) || !map.has(data.mainAppId))))
|
||||
throw Error('Invalid Steam running apps');
|
||||
let sequence = 0;
|
||||
const publicArt = value => typeof value === 'string' && value.length <= 2048 &&
|
||||
/^https:\/\/(?:[a-z0-9-]+\.)*steamstatic\.com\/steam\/apps\/[^?#\s\\]+$/.test(value);
|
||||
const artwork = new Map();
|
||||
for (const row of data.artwork) {
|
||||
if (!Array.isArray(row) || row.length !== 3 || !map.has(row[0]) || artwork.has(row[0]) ||
|
||||
(row[1] !== null && !publicArt(row[1])) || !Array.isArray(row[2]) || row[2].length > 8 ||
|
||||
row[2].some(url=>!publicArt(url))) throw Error('Invalid Steam public artwork');
|
||||
artwork.set(row[0],[row[1],[...row[2]]]);
|
||||
}
|
||||
for (const event of data.events) {
|
||||
if (!Number.isSafeInteger(event?.sequence) || event.sequence <= sequence || event.sequence > data.cursor ||
|
||||
!Number.isSafeInteger(event.unAppID) || event.unAppID <= 0 || typeof event.bRunning !== 'boolean')
|
||||
throw Error('Invalid Steam lifetime event');
|
||||
sequence = event.sequence;
|
||||
}
|
||||
// Update detached stores before callbacks read Router.RunningApps.
|
||||
runningApps = Object.freeze(data.runningIds.map(id=>map.get(id)));
|
||||
mainRunningApp = data.mainAppId === null ? undefined : map.get(data.mainAppId);
|
||||
runningAvailable = true;
|
||||
runningArtwork = artwork;
|
||||
if (window.appStore) for (const app of apps) {
|
||||
const existing = window.appStore.m_mapApps.get(app.appid);
|
||||
if (existing) Object.assign(existing,app);
|
||||
else {window.appStore.m_mapApps.set(app.appid,app);window.appStore.allApps.push(app);}
|
||||
}
|
||||
const previous = runningCursor;
|
||||
runningCursor = data.cursor;
|
||||
for (const event of data.events) if (event.sequence > previous)
|
||||
for (const listener of runningListeners)
|
||||
Promise.resolve().then(()=>runningListeners.has(listener) && listener({unAppID:event.unAppID,bRunning:event.bRunning}))
|
||||
.catch(failure=>console.warn('Steam lifetime listener failed',failure.message));
|
||||
}
|
||||
function refreshRunning() {
|
||||
if (runningClosed) return Promise.resolve();
|
||||
if (runningRequest) return runningRequest;
|
||||
runningRequest = request('running',[runningCursor],false,'framey:running')
|
||||
.then(data=>{if (!runningClosed) setRunningSnapshot(data);})
|
||||
.catch(failure=>{
|
||||
runningAvailable = false;
|
||||
runningApps = Object.freeze([]); mainRunningApp = undefined; runningArtwork = new Map();
|
||||
console.warn('Framey Steam running context unavailable',failure.message);
|
||||
}).finally(()=>{
|
||||
runningRequest = null;
|
||||
if (!runningClosed) runningTimer = setTimeout(refreshRunning,2000);
|
||||
});
|
||||
return runningRequest;
|
||||
}
|
||||
addEventListener('pagehide',()=>{
|
||||
runningClosed = true; clearTimeout(runningTimer); runningListeners.clear();
|
||||
parent.postMessage({type:'framey:running:close',session:cacheSession},'*');
|
||||
});
|
||||
async function start() {
|
||||
try {
|
||||
await seeded;
|
||||
@@ -582,6 +667,8 @@
|
||||
try {setLibrarySnapshot(await request('GetLibrarySnapshot',[],false,'framey:steam'));}
|
||||
catch (failure) {console.warn('Framey Steam library unavailable', failure.message);}
|
||||
}
|
||||
if (/\b(?:RunningApps|MainRunningApp|RegisterForAppLifetimeNotifications)\b/.test(bundleText))
|
||||
await refreshRunning();
|
||||
// Old Decky builds are expressions returning the initializer, rather than
|
||||
// ES modules. A module wrapper preserves the sandbox and avoids eval.
|
||||
const legacy = /^\s*\(function\s*\(/.test(bundleText);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/* Fixed, read-only projection executed in Steam CEF. No plugin source runs here. */
|
||||
(function(payload) {
|
||||
const api = window.SteamClient?.GameSessions;
|
||||
let bridge = window.__frameyRunningBridgeV1;
|
||||
if (bridge && bridge.api !== api) {
|
||||
for (const key of [...bridge.sessions.keys()]) bridge.remove(key);
|
||||
bridge = null;
|
||||
}
|
||||
if (!bridge) {
|
||||
const sessions = new Map();
|
||||
const remove = key => {
|
||||
const item = sessions.get(key);
|
||||
item?.handle?.unregister();
|
||||
sessions.delete(key);
|
||||
if (!sessions.size) {clearInterval(bridge.timer); bridge.timer = null;}
|
||||
};
|
||||
bridge = window.__frameyRunningBridgeV1 = {api, sessions, remove, timer:null};
|
||||
}
|
||||
let item = bridge.sessions.get(payload.session);
|
||||
if (item && item.pluginId !== payload.pluginId) throw Error('Framey running session belongs to another plugin');
|
||||
if (!payload.active) {bridge.remove(payload.session); return null;}
|
||||
const store = window.SteamUIStore;
|
||||
if (!Array.isArray(store?.RunningApps)) throw Error('Steam running apps are unavailable');
|
||||
const apps = store.RunningApps;
|
||||
if (apps.length > 64) throw Error('Too many running Steam apps');
|
||||
const main = store.MainRunningApp;
|
||||
const project = app => [app.appid, app.display_name, app.sort_as, app.app_type, String(app.gameid),
|
||||
app.minutes_playtime_forever, app.minutes_playtime_last_two_weeks, app.rt_last_time_played];
|
||||
// Include a selected main app even if Steam's list changes between the reads.
|
||||
const rows = apps.map(project);
|
||||
if (main && !apps.some(app => app.appid === main.appid)) rows.push(project(main));
|
||||
const publicArt = value => {
|
||||
if (typeof value !== 'string' || value.length > 2048) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port ||
|
||||
!/(^|\.)steamstatic\.com$/.test(url.hostname) || !url.pathname.startsWith('/steam/apps/')) return null;
|
||||
return url.origin + url.pathname; // Never copy local artwork paths or query data.
|
||||
} catch (_) {return null;}
|
||||
};
|
||||
const artwork = [...apps, ...(main && !apps.some(app=>app.appid === main.appid) ? [main] : [])].map(app => {
|
||||
const art = window.appStore;
|
||||
const vertical = publicArt(art?.GetVerticalCapsuleURLForApp?.(app));
|
||||
const custom = art?.GetCustomVerticalCapsuleURLs?.(app);
|
||||
return [app.appid, vertical, Array.isArray(custom) ? custom.slice(0,8).map(publicArt).filter(Boolean) : []];
|
||||
});
|
||||
if (!item) {
|
||||
if (bridge.sessions.size >= 4) throw Error('Too many Framey running sessions');
|
||||
item = {pluginId:payload.pluginId, queue:[], sequence:0, touched:Date.now(), handle:null};
|
||||
if (typeof api?.RegisterForAppLifetimeNotifications === 'function') {
|
||||
item.handle = api.RegisterForAppLifetimeNotifications(event => {
|
||||
// This queue contains actual Steam notifications, never snapshot diffs.
|
||||
if (!Number.isSafeInteger(event?.unAppID) || event.unAppID <= 0 || typeof event.bRunning !== 'boolean') return;
|
||||
item.queue.push({sequence:++item.sequence, unAppID:event.unAppID, bRunning:event.bRunning});
|
||||
if (item.queue.length > 64) item.queue.shift();
|
||||
});
|
||||
}
|
||||
bridge.sessions.set(payload.session, item);
|
||||
if (!bridge.timer) bridge.timer = setInterval(() => {
|
||||
for (const [key, state] of bridge.sessions)
|
||||
if (Date.now() - state.touched > 15000) bridge.remove(key);
|
||||
}, 5000);
|
||||
}
|
||||
item.touched = Date.now();
|
||||
return {apps:rows, artwork, runningIds:apps.map(app => app.appid), mainAppId:main?.appid ?? null,
|
||||
events:item.queue.filter(event => event.sequence > payload.cursor), cursor:item.sequence,
|
||||
lifetimeAvailable:!!item.handle};
|
||||
})
|
||||
@@ -244,3 +244,20 @@ def poll_events(ssh, plugin_id, session, events, cursor=0, receipt=None):
|
||||
source = Path(__file__).with_name('framey-events.js').read_text(encoding='utf-8')
|
||||
script = EVENT_SCRIPT.replace('__FRAMEY_EVENTS_SOURCE__', repr(source))
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)), stdin=script, timeout=25))
|
||||
|
||||
|
||||
def poll_running(ssh, plugin_id, session, active, cursor=0, receipt=None):
|
||||
"""Project running apps and actual lifetime events for one installed panel."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(session, str) or not SESSION.fullmatch(session):
|
||||
raise PanelError('Invalid Framey running session')
|
||||
if type(active) is not bool or type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
|
||||
raise PanelError('Invalid Framey running request')
|
||||
_current_plugin(ssh, plugin_id, receipt)
|
||||
payload = {'pluginId':plugin_id, 'session':session, 'active':active, 'cursor':cursor}
|
||||
source = Path(__file__).with_name('framey-running.js').read_text(encoding='utf-8')
|
||||
expression = '(' + source + ')(' + json.dumps(payload) + ')'
|
||||
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
|
||||
'__FRAMEY_STEAM_LIMIT__', str(256*1024))
|
||||
return json.loads(ssh('python3 -', stdin=script, timeout=25))
|
||||
+24
-1
@@ -237,8 +237,12 @@ function openFrameyPanel(id,name){
|
||||
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
|
||||
byId('frameyPanelStatus').textContent='No compatible panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
||||
}
|
||||
let frameyEventsActive=false;
|
||||
let frameyEventsActive=false,frameyRunningActive=false;
|
||||
function closeFrameyEvents(){
|
||||
if(frameyRunningActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/running',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,active:false}).catch(()=>{});
|
||||
frameyRunningActive=false;
|
||||
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/events',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
@@ -279,6 +283,25 @@ window.addEventListener('message',async event=>{
|
||||
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
|
||||
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
||||
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type==='framey:running:close'){
|
||||
if(req.session===frameySession&&frameyRunningActive){
|
||||
frameyRunningActive=false;
|
||||
api('/api/framey/running',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,active:false}).catch(()=>{});
|
||||
}return;
|
||||
}
|
||||
if(req.type==='framey:running'){
|
||||
if(req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||
|
||||
!Array.isArray(req.args)||req.args.length!==1||!Number.isSafeInteger(req.args[0])||req.args[0]<0)return;
|
||||
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
|
||||
reply={type:'framey:reply',id:req.id};
|
||||
frameyRunningActive=true;
|
||||
try{const data=await api('/api/framey/running',{id:pluginId,receipt,session,active:true,cursor:req.args[0]});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
|
||||
event.source.postMessage(reply,'*');
|
||||
return;
|
||||
}
|
||||
if(req.type==='framey:events:close'){
|
||||
if(req.session===frameySession)closeFrameyEvents();return;
|
||||
}
|
||||
|
||||
@@ -606,6 +606,16 @@ def framey_events_action(body):
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
def framey_running_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.poll_running(ssh, body.get('id'), body.get('session'),
|
||||
body.get('active'), body.get('cursor', 0), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
def framey_check_action(body):
|
||||
"""Record only a live, explicitly started worn-headset check."""
|
||||
if not HEADSET_RUNTIME or body.get('kind') not in ('input', 'panel'):
|
||||
@@ -3474,6 +3484,7 @@ POST = {
|
||||
"/api/framey/method": framey_method_action,
|
||||
"/api/framey/steam": framey_steam_action,
|
||||
"/api/framey/events": framey_events_action,
|
||||
"/api/framey/running": framey_running_action,
|
||||
"/api/framey/check": framey_check_action,
|
||||
"/api/headset": headset_action,
|
||||
"/api/headset/quick": headset_quick,
|
||||
|
||||
Reference in new issue
Block a user