mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 14:02:09 +01:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c1d941a1c | ||
|
|
c2f94c242d | ||
|
|
e6e42a93f0 | ||
|
|
fa354ce365 | ||
|
|
fcdd1f22f5 | ||
|
|
d43e760aef | ||
|
|
240288df65 | ||
|
|
a68c5584a9 |
No files matched your search
@@ -1,11 +1,11 @@
|
||||
# What we have tested
|
||||
|
||||
Release baseline: Frame Control **v0.4.40**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
Release baseline: Frame Control **v0.4.41**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG verified, installed and launched. Native UI, preference persistence across restart (original file restored), bundled Python logs, update check, offline OBS Start/Stop and 116-entry Decky store checked. A 40-byte relay upload/readback/delete passed under coordinator/headset locks | No | No quarantined Safari/Chrome download or Gatekeeper override test; native Open at Login/menu automation unavailable. Direct frame.local had no route and the app refused a stale SSH identity. Relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Quit exposed a server cleanup race; fix #284 shipped in v0.4.40 with physical-Mac source regression coverage, but the tagged Intel installer was not launched on physical Intel hardware | 26 Oct, official v0.4.39 tag `fd0b0a9`: [Intel release evidence](evidence/mac-v0439-2026-10-26.md); [earlier ARM64 failure](evidence/mac-desktop-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 ran the exact v0.4.40 tagged source against the real Frame; all 13 reserved native/Proton title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.40 tag `a4d8073`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0440-release-2026-10-26.md); [earlier candidate smoke](evidence/v0440-candidate-frame-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 ran the exact v0.4.41 tagged source against the real Frame; 13/13 reserved title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Optional Steam Linux Runtime 4 was absent, so the x86_64 test executable did not actually launch. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.41 tag `b5d50a8`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0441-release-2026-10-26.md); [earlier candidate smoke](evidence/v0441-candidate-frame-2026-10-26.md) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
@@ -24,7 +24,7 @@ Release baseline: Frame Control **v0.4.40**, 26 October 2026. Rows also identify
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.40: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [post-#292 unworn sample exercise](evidence/v0440-release-2026-10-26.md#post-tag-292-panel-regression), [earlier standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. v0.4.40 classifies the 116 exact-version plugins as 1 works (Notebook default flow only), 49 partial, 55 broken and 11 not applicable. Post-tag PRs #289 and #293 give 3 works / 48 partial / 54 broken / 11 N/A on current main: TabMaster is partial, and Volume Mixer and Mixer work for tested per-app silent-stream volume control only. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.40: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [ARM64 audio evidence](evidence/framey-audio-arm64-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. v0.4.41 classifies the 116 exact-version plugins as 3 works, 48 partial, 54 broken and 11 not applicable. Notebook works for its default flow; Volume Mixer and Mixer work for tested per-app silent-stream volume control only. TabMaster is partial. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.41: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [ARM64 audio evidence](evidence/framey-audio-arm64-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; v0.4.40 recorded 101 local Chromium initializers ready and 15 failed, up from 98/18 in v0.4.39. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.40: [post-#294 unworn sample and scroll evidence](evidence/framey-scroll-probe-2026-10-26.md#post-294-exact-main-sample-check), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [post-release sample exercise](evidence/v0439-release-2026-10-26.md#post-release-framey-library-change-282), [worn checklist](framey-vr-check.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Headset smoke: unavailable x86 runtime is a skip
|
||||
|
||||
The v0.4.41 tagged smoke result recorded 13/13 passed even though its x86_64 launch outcome was `runtime missing`: Steam had acknowledged the title but had not started it. This follow-up separates an unavailable optional runtime from a verified launch. It keeps the smoke usable on a Frame without Steam Linux Runtime 4 while making the missing coverage explicit in its console summary and saved JSON.
|
||||
|
||||
Focused local `test_smoke_cleanup.py` ran **22 tests successfully**. The new regression starts an x86_64 title cycle with a fresh missing-runtime Steam log, verifies `skipped=true` and `outcome=runtime missing`, and verifies that the title is still removed. A result test checks separate passed/skipped/failed counts and a zero exit code when an optional runtime is absent but nothing fails.
|
||||
|
||||
The revised `scripts/frame-smoke.sh` then ran on the real Frame from this branch. It acquired its unique owner in `/tmp/frame-test.lock` before any headset read. Locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, the result was **12 passed / 1 skipped / 0 failed**. ARM64 and Proton test executables ran; x86_64 was explicitly skipped because Steam Linux Runtime 4 was not installed. Both title cleanup steps passed. The command exited 0, including release of its owned shared lock. The detailed result remains private at `tests/smoke/results/2026-10-26T050711Z-20261022.6153909.json` in the test checkout.
|
||||
|
||||
The headset was in standby and nobody wore it for this test. Title smoke does not prove wearer-visible pixels, physical controller input, official Decky panels, audio output or comfort. It also does not prove x86_64 runtime execution on this Frame; that path needs the optional runtime present. The hourly worn check remains enabled.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Framey receipt guard, unworn Frame exercise (26 October 2026)
|
||||
|
||||
The bounded `scripts/framey-vr-check.py --exercise` runner acquired `/tmp/frame-test.lock` with its own random owner before reading the real Frame. Its 05:26 UTC run used source commit `c2f94c242dac670e18259193311a7375f5a87942`. Preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. No wearer was confirmed.
|
||||
|
||||
The temporary Frame Control dashboard overlay became visible to OpenVR. The stereo compositor image changed, and the `/dev/video99` mirror produced a frame. The sample settings panel displayed its stored off value, accepted a trusted synthetic OpenVR pointer event, saved on through the real Decky Python backend, read the setting from disk, and showed on after a fresh iframe reopen. The quick action read on. A native slider reached 50. Text entry used CDP after OpenVR focus. The runner exited 0 with `input.passed=true` and `compositorChanged=true`.
|
||||
|
||||
The private capture files remain outside the repository at `/tmp/frame-pr301-unworn-vr-fixed/`. The before/after stereo SHA-256 values were `1683bf1dab70c1a0a1e8f313c8317dc04f992070b5b0de232de9008700130711` and `684a27eecf7bcbbc5308092ddbb1517c4596fb283e061e73dd6bb7f025954e8b`; the mirror frame SHA-256 was `5939149ea9c85fd2d86d3f8879c348f006c421a386bd66a93fe`. Room pixels are not committed.
|
||||
|
||||
An earlier locked run at 05:24 UTC exposed a missing SHA-256 in the runner's temporary sample receipt: the new guard correctly asked to reopen the panel. Commit `c2f94c2` gives that sample a content digest; the second run passed. Both runs reported no cleanup errors, restored `power.pauseCompositorOnStandby=false` and `power.turnOffScreensTimeout=5`, removed their power recovery timers, and released only their own locks. Battery remained 99%.
|
||||
|
||||
This is an **unworn sample-plugin result**. It does not prove wearer-visible pixels, physical controller input, native scrolling (the attempted scroll offset stayed 0), VR keyboard behavior, comfort, or any official Decky plugin's VR panel or backend. The hourly worn check remains enabled.
|
||||
@@ -0,0 +1,23 @@
|
||||
# v0.4.41 tagged release, 26 October 2026
|
||||
|
||||
The annotated `v0.4.41` tag dereferences to main merge commit `b5d50a8d896f790a94baf4e937db914eab475ed9` from PR #297. The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40271174791) passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs. The platform jobs launched the built Linux AppImage, checked the Windows installation and bundled PC Python libraries, and checked Mac bundle architectures. These CI results do not establish a physical Windows or Mac launch.
|
||||
|
||||
## Real Frame, exact tag
|
||||
|
||||
From an exact tag checkout, `scripts/frame-smoke.sh` acquired its unique owner in `/tmp/frame-test.lock` before reading the headset. Preflight found SteamVR activity 3 (standby) and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, all **13/13 reserved title steps passed**; the command exited 0, including test title and process cleanup and release of its owned lock. ARM64 and Proton test executables launched. The x86_64 launch step recorded that optional Steam Linux Runtime 4 was **not installed**, so that executable did not actually launch. The private detailed result is `tests/smoke/results/2026-10-26T045140Z-20261022.6153909.json` in the exact-tag checkout.
|
||||
|
||||
No person wore the headset. This title smoke does not verify active passthrough pixels, wearer-visible panels, physical controller input, official Decky plugin panels, audio output, comfort or VR keyboard use. The separate [unworn Framey sample exercise](framey-scroll-probe-2026-10-26.md) established live sample setting persistence, stereo compositor changes and trusted synthetic OpenVR input; it did not establish a worn result. The hourly worn check remains enabled.
|
||||
|
||||
## Downloaded installer and checksum checks
|
||||
|
||||
The draft held ten installers plus `SHA256SUMS`, each with a GitHub SHA-256 digest. `scripts/publish-release.sh --dry-run v0.4.41` checked the exact tag and all ten installer digests, regenerated `SHA256SUMS` from those digests and matched its own GitHub digest. A fresh downloaded checksum list independently matched `e7555956aeb9ec3a464081aaafe1ed6762f65a17c04edaa6ea45286f10927109`.
|
||||
|
||||
A fresh Linux x86_64 AppImage download matched `5d1f50dc8527faf299f2058c7b5ed19057ec7c81c41449f6ccd44d68e6b4d212`. `scripts/check-appimage-launch.py` launched that exact file under isolated Xvfb: the bundled server answered HTTP 200 with `FrameControl/1`, then stopped. The first local attempt failed at Chromium sandbox startup inside the command sandbox; rerunning the same downloaded file with normal host permissions passed. The release workflow's independent Linux launch also passed.
|
||||
|
||||
Fresh Mac ZIP downloads matched GitHub digests: arm64 `84615ec8a0c62481a9f33bae1815d55ec902db9aa9d4bd767fcf9380c3d44d7a`, x64 `2800b749cda98d15207950eca4a6bd205d35f30551f16efd7720b41347edc7ca`. Both contain bundle version 0.4.41; the app executable Mach-O CPU types are arm64 and x86_64 respectively. The physical Mac was unreachable over SSH, so neither tagged ZIP was launched there. The physical Intel Air was also not tested with this tag.
|
||||
|
||||
## Publication and updater readback
|
||||
|
||||
The final dry run passed with the release notes and hardware limits in the draft. `scripts/publish-release.sh v0.4.41` published [v0.4.41](https://github.com/saphid/frame-control/releases/tag/v0.4.41) at 2026-10-26 05:01:47 UTC. GitHub's latest-release API read back `draft=false`, tag `v0.4.41`, ten installers, `SHA256SUMS` and `update.json`. A fresh public latest-download of `update.json` parsed as version 0.4.41 with the stable release page, ten SHA-256 digests and the unworn-test limits in its notes. The release contains the tagged `b5d50a8` commit; no post-tag changes are implied.
|
||||
|
||||
The exact-version official Decky catalog in this release is **3 works / 48 partial / 54 broken / 11 N/A**. Volume Mixer and Mixer are works only for the tested per-app silent-stream volume scope on real ARM64 Decky; [audio evidence](framey-audio-arm64-2026-10-26.md) describes the independent Pulse readbacks. Volume Boost remains partial. Audible playback, Bluetooth, other mixer controls and official-plugin worn VR remain unverified.
|
||||
@@ -177,6 +177,15 @@ the store and review the new version before retrying. If Frame Control restarts,
|
||||
job result. The panel clears that job ID and asks you to refresh the installed
|
||||
list before making another change.
|
||||
|
||||
An open plugin panel closes before its own Update or Remove starts, so its old
|
||||
frontend cannot call the changing backend. Open panel is unavailable while a
|
||||
store job is pending. A later installed-status refresh also closes an open panel
|
||||
if Decky stopped, its store receipt changed, or the status read failed. Reopen
|
||||
the plugin after a successful refresh to use its current frontend.
|
||||
The headset also includes the panel's store SHA-256 receipt with each backend
|
||||
call, Steam read, and event poll. It rejects a request if the installed receipt
|
||||
changed before the next status refresh; reopen the panel to use the new version.
|
||||
|
||||
## For plugin authors
|
||||
|
||||
Start with the [Framey settings sample](../examples/framey-settings/README.md)
|
||||
|
||||
+12
-16
@@ -176,21 +176,19 @@ switch while SSH is down:
|
||||
|
||||
## Headset smoke test
|
||||
|
||||
**Documented shared-device procedure:** before any headset read or a test that
|
||||
installs, launches or stops an application, atomically create
|
||||
`/tmp/frame-test.lock` on the Frame and write a unique token to its `owner`
|
||||
file. If creation fails, leave that lock alone and continue offline work. Only
|
||||
the owner that verifies its token may remove its `owner` file and lock directory
|
||||
after cleanup. Keep each device session to a few minutes.
|
||||
**Documented shared-device procedure:** before a test installs, launches or
|
||||
stops an application, acquire `ssh frame 'mkdir /tmp/frame-test.lock'`. If it
|
||||
fails, leave that lock alone and continue offline work. Only the thread that
|
||||
acquired it releases it with `ssh frame 'rmdir /tmp/frame-test.lock'`, after
|
||||
cleanup. Keep each device session to a few minutes.
|
||||
|
||||
Check battery capacity and charging state under `/sys/class/power_supply`
|
||||
before and after; keep capacity above 20%. Stop only processes started by the
|
||||
test, remove temporary installs and profiles, and restore the prior dashboard
|
||||
state. Leave Steam and SteamVR running. Do not reboot or change global settings.
|
||||
Record the build, actual interaction results, cleanup and any unworn-headset
|
||||
limits alongside screenshots or logs. The smoke script below acquires and
|
||||
releases its own tokenized shared lock; other device tests follow this
|
||||
procedure themselves.
|
||||
limits alongside screenshots or logs. These are caller responsibilities; the
|
||||
smoke script below does not acquire this shared lock itself.
|
||||
|
||||
[Packaged Desktop Capture evidence, 2026-10-24](evidence/desktop-capture-packaged-2026-10-24.md)
|
||||
records v0.4.17 GUI button captures of a real mapped nested desktop on SteamOS
|
||||
@@ -222,19 +220,17 @@ removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||
an x86-64 `.exe` that exits at once). A launch passes only with fresh evidence:
|
||||
the ARM64 program running, the `.exe` started (its process or Steam's log),
|
||||
and the x86-64 program running. If Steam logs that the optional x86-64 runtime
|
||||
is not installed, that launch is **skipped**, with the reason and log saved in
|
||||
the result. It does not prove that the x86-64 title ran. Steam's log lines about
|
||||
each title are kept.
|
||||
and the x86-64 program running or Steam logging that its runtime isn't
|
||||
installed, which is what the Frame does today. Steam's log lines about each
|
||||
title are kept.
|
||||
|
||||
Everything it installs is removed again, also after a failure: the titles and
|
||||
their Steam shortcuts, a paired key, and `~/devkit-utils` if it wasn't there
|
||||
before (if it was, it stays, synced to this checkout as Frame Control always
|
||||
does). A cleanup that fails counts as a failed step. Results go to
|
||||
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
|
||||
screen. The summary counts passed, skipped and failed steps separately. It
|
||||
exits 0 when no step failed (including a skipped optional runtime), 1 if a step
|
||||
failed, or 2 if the headset isn't reachable.
|
||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||
isn't reachable.
|
||||
|
||||
Smoke process cleanup runs before and after title removal and during the
|
||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||
|
||||
@@ -210,7 +210,7 @@ def main(argv=None):
|
||||
files = {name: base64.b64encode((ROOT/'examples/framey-settings'/name).read_bytes()).decode()
|
||||
for name in ('plugin.json', 'main.py', 'dist/index.js')}
|
||||
source = '''
|
||||
import base64,json,pathlib
|
||||
import base64,hashlib,json,pathlib
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky'
|
||||
folder=%r
|
||||
p=base/'plugins'/folder
|
||||
@@ -219,7 +219,10 @@ p.mkdir(parents=True); (p/'dist').mkdir()
|
||||
for name,data in %r.items(): (p/name).write_bytes(base64.b64decode(data))
|
||||
manifest=json.loads((p/'plugin.json').read_text());manifest['name']='Framey VR Check '+folder
|
||||
(p/'plugin.json').write_text(json.dumps(manifest))
|
||||
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check'}))
|
||||
digest=hashlib.sha256()
|
||||
for name in ('plugin.json','main.py','dist/index.js'):
|
||||
digest.update(name.encode()+b'\\0'+(p/name).read_bytes())
|
||||
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check','sha256':digest.hexdigest()}))
|
||||
print(json.dumps({'created':True}))
|
||||
''' % (folder, files)
|
||||
remote.python(source)
|
||||
|
||||
@@ -112,25 +112,28 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
|
||||
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
|
||||
receipt = HASHES[0]
|
||||
def framey_call(body):
|
||||
return ok('POST', '/api/framey/method', dict(body, receipt=receipt))
|
||||
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'get_setting','args':[]},
|
||||
headers={'X-Frame-UI':''})
|
||||
self.assertEqual(status, 403)
|
||||
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'x;alert(1)','args':[]})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
|
||||
self.assertTrue(ok('POST', '/api/framey/method',
|
||||
self.assertTrue(framey_call(
|
||||
{'id':999,'method':'set_setting','args':['theme','light']})['result'])
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}],
|
||||
'legacy':True})['result'], {'success':True,'result':'light'})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}],
|
||||
'legacy':True})['result'], {'success':True,'result':True})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved')
|
||||
|
||||
StoreFixture.release = 1
|
||||
@@ -138,7 +141,12 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
{'action':'update','id':999,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
status, stale_reply, _ = api('POST', '/api/framey/method',
|
||||
{'id':999,'receipt':receipt,'method':'get_setting','args':['theme','dark']})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn('changed', str(stale_reply))
|
||||
receipt = HASHES[1]
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':999,'acknowledged':True}))
|
||||
@@ -151,7 +159,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'install','id':999,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
stale = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':999,'sha256':HASHES[0],
|
||||
|
||||
@@ -19,7 +19,7 @@ const parent=vm.createContext({document:{getElementById:el,createElement:()=>({}
|
||||
window:{addEventListener:(type,fn)=>parentListeners.push({type,fn})},
|
||||
setTimeout(){},setInterval(){},fetch(){throw Error('No network expected');},console});
|
||||
vm.runInContext(parentSource,parent);
|
||||
vm.runInContext('deckyState={plugins:[{storeId:14}]};openFrameyPanel(14,"Steam Status")',parent);
|
||||
vm.runInContext('deckyState={running:true,plugins:[{storeId:14,sha256:"a",version:"1"}]};openFrameyPanel(14,"Steam Status")',parent);
|
||||
let session=vm.runInContext('frameySession',parent);
|
||||
const firstURL=new URL(el('frameyFrame').src,'http://127.0.0.1');
|
||||
const onParent=parentListeners.find(x=>x.type==='message').fn;
|
||||
|
||||
@@ -15,7 +15,7 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
|
||||
setTimeout(){},setInterval(){},console,
|
||||
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
|
||||
vm.runInContext(source,context);
|
||||
vm.runInContext('deckyState={plugins:[{storeId:96}]};openFrameyPanel(96,"Timer")',context);
|
||||
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};openFrameyPanel(96,"Timer")',context);
|
||||
let session=vm.runInContext('frameySession',context);
|
||||
const onMessage=listeners.find(x=>x.type==='message').fn;
|
||||
const send=(data,origin=frame)=>onMessage({source:origin,data});
|
||||
@@ -28,6 +28,7 @@ const first=send(subscription);
|
||||
assert.equal(requests.length,1);
|
||||
assert.equal(requests[0].path,'/api/framey/events');
|
||||
assert.equal(requests[0].body.id,96);
|
||||
assert.equal(requests[0].body.receipt,'a'.repeat(64));
|
||||
assert.equal(requests[0].body.session,session);
|
||||
// WindowProxy survives navigation: stale same-plugin replies need a session check.
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
@@ -35,6 +36,7 @@ const secondSession=vm.runInContext('frameySession',context);
|
||||
assert.notEqual(secondSession,session);
|
||||
assert.equal(requests[1].body.events.length,0); // Closes only the original subscription.
|
||||
assert.equal(requests[1].body.session,session);
|
||||
assert.equal(requests[1].body.receipt,'a'.repeat(64));
|
||||
requests[0].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[42]}],cursor:1})});
|
||||
await first;assert.equal(replies.length,0);
|
||||
await send({type:'framey:call',id:2,session,method:'cancel_timer',args:[]});
|
||||
@@ -59,6 +61,7 @@ await send(steamRead,{});
|
||||
assert.equal(requests.length,initialCount);
|
||||
const read=send(steamRead);
|
||||
assert.equal(requests.at(-1).path,'/api/framey/steam');assert.equal(requests.at(-1).body.id,96);
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
|
||||
await read;assert.equal(replies.at(-1).result,'AU');
|
||||
const staleRead=send({...steamRead,id:11});
|
||||
|
||||
@@ -68,6 +68,7 @@ ctx.mockApi=async path => path==='/api/decky' ?
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,true);
|
||||
el('deckySearch').value='';el('deckyStoreFilter').value='all';
|
||||
vm.runInContext('showDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
|
||||
@@ -97,6 +98,7 @@ await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".re
|
||||
assert.equal(changes,1);
|
||||
assert.equal(session.has('frameyPluginJob'),true);
|
||||
assert.equal(el('deckyJobCheck').hidden,false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,true);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,true);
|
||||
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
|
||||
await vm.runInContext('deckyChange("remove",{id:2,name:"Plugin 2"})',ctx);
|
||||
@@ -111,6 +113,7 @@ ctx.mockApi=async path => {
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('checkDeckyJob()',ctx);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
|
||||
assert.equal(el('deckyStoreList').children[1].children[1].disabled,false);
|
||||
|
||||
@@ -196,3 +199,48 @@ await staleFailure;
|
||||
assert.match(el('deckyInstalled').children[0].children[0].textContent,/Current/);
|
||||
assert.doesNotMatch(el('deckyState').textContent,/offline/);
|
||||
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
|
||||
|
||||
// A panel must not keep an old frontend after the installed receipt changes.
|
||||
let installedHash='a'.repeat(64);
|
||||
ctx.mockApi=async path => {
|
||||
assert.equal(path,'/api/decky');
|
||||
return {managed:true,running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0',sha256:installedHash}]};
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,false);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,false,'unchanged receipt keeps the current panel');
|
||||
installedHash='b'.repeat(64);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
assert.equal(el('frameyFrame').src,undefined);
|
||||
assert.match(el('deckyState').textContent,/old plugin panel was closed/);
|
||||
|
||||
// The panel closes before updating its own backend, and stale Open handlers
|
||||
// cannot recreate it while a job is active.
|
||||
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,false);
|
||||
ctx.mockApi=async (path,body) => {
|
||||
if(path==='/api/decky/plugin'){
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
return {job:'job-update'};
|
||||
}
|
||||
if(path.startsWith('/api/job'))return {done:true,message:'Updated Plugin 2'};
|
||||
if(path==='/api/decky')return {managed:true,running:true,
|
||||
plugins:[{storeId:2,name:'Plugin 2',version:'2.0',sha256:'c'.repeat(64)}]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"c".repeat(64)})',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
assert.match(el('deckyJobStatus').textContent,/Old plugin panel closed; reopen it/);
|
||||
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
|
||||
ctx.mockApi=async()=>{throw Error('offline');};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
assert.match(el('deckyState').textContent,/panel was closed until status can be refreshed/);
|
||||
+10
-26
@@ -27,8 +27,7 @@ rechecked around deletion; shared Proton services are never stopped by name.
|
||||
|
||||
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
||||
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
|
||||
Exit status: 0 no step failed (optional runtime may skip a launch), 1 a step
|
||||
failed, 2 the headset isn't reachable.
|
||||
Exit status: 0 all passed, 1 a step failed, 2 the headset isn't reachable.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@@ -126,32 +125,21 @@ class Smoke:
|
||||
self.installed = set()
|
||||
|
||||
def step(self, name, fn, *args):
|
||||
"""Run one step; record passed/skipped/failed and how long it took."""
|
||||
"""Run one step; record ok/failed, its detail and how long it took."""
|
||||
t0 = time.monotonic()
|
||||
rec = {'step': name}
|
||||
try:
|
||||
detail = fn(*args)
|
||||
rec.update(ok=True, detail=detail)
|
||||
except Skipped as e:
|
||||
rec.update(ok=False, skipped=True, reason=str(e), detail=e.detail)
|
||||
except Exception as e: # a failed step is a result, not the end of the run
|
||||
rec.update(ok=False, error=f'{type(e).__name__}: {e}', trace=traceback.format_exc(limit=3))
|
||||
rec['seconds'] = round(time.monotonic() - t0, 1)
|
||||
self.steps.append(rec)
|
||||
label = 'ok ' if rec['ok'] else 'SKIP' if rec.get('skipped') else 'FAIL'
|
||||
message = '' if rec['ok'] else f": {rec.get('reason') or rec['error']}"
|
||||
print(f" {label} {name} ({rec['seconds']} s){message}", flush=True)
|
||||
print(f" {'ok ' if rec['ok'] else 'FAIL'} {name} ({rec['seconds']} s)"
|
||||
+ ('' if rec['ok'] else f": {rec['error']}"), flush=True)
|
||||
return rec
|
||||
|
||||
|
||||
class Skipped(Exception):
|
||||
"""A prerequisite was unavailable; preserve its evidence without claiming a launch."""
|
||||
|
||||
def __init__(self, reason, detail):
|
||||
super().__init__(reason)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def ssh(cmd, timeout=60):
|
||||
return frame_android.ssh(cmd, timeout=timeout)
|
||||
|
||||
@@ -206,8 +194,8 @@ def steam_log_lines(*patterns):
|
||||
|
||||
# What a launch must show, per kind. The x86-64 runtime isn't installed on the
|
||||
# Frame, so Steam acknowledges that launch and logs "... is not installed"
|
||||
# instead (docs/sideloading.md, 2026-09-26): record a skip, not a launch pass.
|
||||
EXPECTED = {'arm64': ('running',), 'x86_64': ('running',), 'exe': ('running', 'started')}
|
||||
# instead (docs/sideloading.md, 2026-09-26): recorded, not a failure.
|
||||
EXPECTED = {'arm64': ('running',), 'x86_64': ('running', 'runtime missing'), 'exe': ('running', 'started')}
|
||||
|
||||
|
||||
def title_cycle(smoke, kind, folder):
|
||||
@@ -241,12 +229,10 @@ def title_cycle(smoke, kind, folder):
|
||||
outcome, new = 'runtime missing', new + missing
|
||||
elif any('started devkit game' in line or 'chdir' in line for line in new):
|
||||
outcome = 'started'
|
||||
if outcome in EXPECTED[kind] or (kind == 'x86_64' and outcome == 'runtime missing'):
|
||||
if outcome in EXPECTED[kind]: # else keep looking: the log can come before the process
|
||||
break
|
||||
time.sleep(0.5)
|
||||
detail = {'outcome': outcome, 'processes': procs, 'steam_log': new[-12:]}
|
||||
if kind == 'x86_64' and outcome == 'runtime missing':
|
||||
raise Skipped('Steam Linux Runtime 4 is not installed; x86_64 title did not launch', detail)
|
||||
if outcome not in EXPECTED[kind]:
|
||||
raise AssertionError(f"Steam acknowledged the launch, but {outcome} (expected "
|
||||
f"{' or '.join(EXPECTED[kind])}): {json.dumps(detail)[:400]}")
|
||||
@@ -410,16 +396,14 @@ def run_smoke(args, before):
|
||||
'rm -rf ~/devkit-utils ~/.devkit-utils.frame-control && echo removed')
|
||||
subprocess.run(['rm', '-rf', folder])
|
||||
passed = sum(s['ok'] for s in smoke.steps)
|
||||
skipped = sum(s.get('skipped', False) for s in smoke.steps)
|
||||
failed = len(smoke.steps) - passed - skipped
|
||||
RESULTS.mkdir(exist_ok=True)
|
||||
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
|
||||
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'preflight': before,
|
||||
'os_release': release, 'paired': args.pair,
|
||||
'passed': passed, 'skipped': skipped, 'failed': failed,
|
||||
'passed': passed, 'failed': len(smoke.steps) - passed,
|
||||
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
|
||||
print(f'==> {passed} passed, {skipped} skipped, {failed} failed on build {build}; results in {out}')
|
||||
return 0 if failed == 0 else 1
|
||||
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out}')
|
||||
return 0 if passed == len(smoke.steps) else 1
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
+32
-20
@@ -18,11 +18,13 @@ import framey_panel
|
||||
import frame_headset
|
||||
import server
|
||||
|
||||
RECEIPT = 'a' * 64
|
||||
|
||||
|
||||
class Panel(unittest.TestCase):
|
||||
def test_steam_reads_are_allowlisted_and_installed_only(self):
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck'}]}):
|
||||
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck','sha256':RECEIPT}]}):
|
||||
for method, expression in framey_panel.STEAM_READS.items():
|
||||
def fake_ssh(command, stdin, **_):
|
||||
self.assertEqual(command, 'python3 -')
|
||||
@@ -30,13 +32,16 @@ class Panel(unittest.TestCase):
|
||||
self.assertIn(expression.replace('\n', r'\n'), stdin)
|
||||
self.assertNotIn('__FRAMEY_STEAM_READ__', stdin)
|
||||
return '{"result":"actual read"}'
|
||||
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method)['result'], 'actual read')
|
||||
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method,RECEIPT)['result'], 'actual read')
|
||||
for plugin_id, method in [(True,'GetIPCountry'), (0,'GetIPCountry'),
|
||||
(82,'GetIPCountry'), (81,'ShutdownPC'), (81,'User.GetIPCountry'),
|
||||
(81,[]), (81,'GetIPCountry();alert(1)')]:
|
||||
with self.subTest(plugin_id=plugin_id, method=method):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method)
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method,RECEIPT)
|
||||
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Stale read reached SSH'),
|
||||
81,'GetIPCountry','b'*64)
|
||||
|
||||
def test_steam_read_response_limit_and_socket_cleanup(self):
|
||||
from types import SimpleNamespace
|
||||
@@ -86,19 +91,19 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertEqual(json.loads(ast.literal_eval(assignment.value.args[0]))['args'],args)
|
||||
return '{"result":null}'
|
||||
with patch.object(framey_panel.frame_decky,'status',return_value={
|
||||
'plugins':[{'storeId':53,'name':'Shotty'}]}):
|
||||
'plugins':[{'storeId':53,'name':'Shotty','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,
|
||||
53,'set_id_map_fronend',args,True),{'result':None})
|
||||
53,'set_id_map_fronend',args,True,RECEIPT),{'result':None})
|
||||
with self.assertRaisesRegex(framey_panel.PanelError,'too large'):
|
||||
framey_panel.call_method(lambda *a,**k:self.fail('Unexpected SSH'),
|
||||
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True)
|
||||
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True,RECEIPT)
|
||||
|
||||
def test_steam_reads_have_the_headset_service_boundary(self):
|
||||
with patch.object(server,'HEADSET_RUNTIME',False), patch.dict(server.os.environ,{'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):server.framey_steam_action({'id':81,'method':'GetIPCountry'})
|
||||
with patch.object(server,'HEADSET_RUNTIME',True), patch.object(framey_panel,'read_steam',return_value={'result':'AU'}) as read:
|
||||
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry'}),{'result':'AU'})
|
||||
read.assert_called_once_with(server.ssh,81,'GetIPCountry')
|
||||
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry','receipt':RECEIPT}),{'result':'AU'})
|
||||
read.assert_called_once_with(server.ssh,81,'GetIPCountry',RECEIPT)
|
||||
|
||||
def test_decky_event_queue_scope_bounds_expiry_and_reconnect(self):
|
||||
for name in ('framey_events.mjs', 'framey_event_parent.mjs'):
|
||||
@@ -117,8 +122,11 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertIn('loader/plugin_event', stdin)
|
||||
return json.dumps({'events':[{'event':'timer:tick', 'args':[42]}], 'cursor':6})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':96,'name':'Simple Timer'}]}):
|
||||
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5)['cursor'], 6)
|
||||
'plugins':[{'storeId':96,'name':'Simple Timer','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5, RECEIPT)['cursor'], 6)
|
||||
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
|
||||
framey_panel.poll_events(lambda *a,**k:self.fail('Stale poll reached SSH'),
|
||||
96,'test-123.45',['timer:tick'],5,'b'*64)
|
||||
for plugin_id, session, events, cursor in [
|
||||
(True,'test',['tick'],0), (96,'bad session',['tick'],0),
|
||||
(96,'test',['a']*33,0), (96,'test',['bad event'],0),
|
||||
@@ -129,7 +137,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
with self.subTest(plugin_id=plugin_id, session=session, events=events, cursor=cursor):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.poll_events(lambda *a, **k:self.fail('Unexpected SSH'),
|
||||
plugin_id, session, events, cursor)
|
||||
plugin_id, session, events, cursor, RECEIPT)
|
||||
|
||||
def test_event_action_has_same_headset_boundary_as_methods(self):
|
||||
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
|
||||
@@ -137,8 +145,8 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
server.framey_events_action({'id':96,'session':'test','events':['tick']})
|
||||
with patch.object(server, 'HEADSET_RUNTIME', True), \
|
||||
patch.object(framey_panel, 'poll_events', return_value={'events':[], 'cursor':0}) as poll:
|
||||
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick']})['events'], [])
|
||||
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0)
|
||||
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick'],'receipt':RECEIPT})['events'], [])
|
||||
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0, RECEIPT)
|
||||
|
||||
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
|
||||
@@ -220,12 +228,12 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertIn('loader/call_legacy_plugin_method', stdin)
|
||||
return json.dumps({'result':{'success':True, 'result':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'Legacy'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True),
|
||||
'plugins':[{'storeId':14,'name':'Legacy','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True, RECEIPT),
|
||||
{'result':{'success':True, 'result':'dark'}})
|
||||
for args, legacy in [([],True), ([[]],True), ([{},{}],True), ([{}],'true')]:
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy)
|
||||
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy, RECEIPT)
|
||||
|
||||
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
|
||||
manifest = self.folder/'plugin.json'
|
||||
@@ -255,11 +263,15 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertNotIn('__FRAMEY_METHOD_PAYLOAD__',stdin)
|
||||
return json.dumps({'result':{'theme':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'ProtonDB Badges'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark']),
|
||||
'plugins':[{'storeId':14,'name':'ProtonDB Badges','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark'],receipt=RECEIPT),
|
||||
{'result':{'theme':'dark'}})
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[])
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[])
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[],receipt=RECEIPT)
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[],receipt=RECEIPT)
|
||||
for stale in (None, 'b' * 64, 'invalid'):
|
||||
with self.subTest(stale=stale), self.assertRaisesRegex(framey_panel.PanelError, 'receipt|changed'):
|
||||
framey_panel.call_method(lambda *a, **k:self.fail('Stale receipt reached SSH'),
|
||||
14,'get_setting',[],receipt=stale)
|
||||
compile(framey_panel.METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__',repr('{}')), '<remote method>', 'exec')
|
||||
|
||||
def test_headset_bundle_contains_runtime_and_licenses(self):
|
||||
|
||||
@@ -95,60 +95,6 @@ class RealProcesses(unittest.TestCase):
|
||||
|
||||
|
||||
class Safety(unittest.TestCase):
|
||||
def test_missing_x86_runtime_skips_launch_but_still_removes_title(self):
|
||||
smoke = frame_smoke.Smoke()
|
||||
gid = 'fc_smoke_x86_64'
|
||||
with mock.patch.object(frame_smoke.tiny_programs, 'write', return_value=Path('/tmp/tiny')), \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'install', return_value={
|
||||
'runtime': 'SteamLinuxRuntime_4'}), \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'list_titles', side_effect=[
|
||||
[{'id': gid}], []]), \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'launch') as launch, \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'remove') as remove, \
|
||||
mock.patch.object(frame_smoke, 'steam_log_lines', side_effect=[
|
||||
[], [], [], ['Tool 4183110 Steam Linux Runtime 4.0 is found, but is not installed']]), \
|
||||
mock.patch.object(frame_smoke, 'ssh', return_value=''), \
|
||||
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': []}):
|
||||
frame_smoke.title_cycle(smoke, 'x86_64', '/tmp')
|
||||
self.assertEqual([step['step'] for step in smoke.steps],
|
||||
['x86_64: install', 'x86_64: launch', 'x86_64: remove'])
|
||||
self.assertTrue(smoke.steps[0]['ok'])
|
||||
self.assertFalse(smoke.steps[1]['ok'])
|
||||
self.assertTrue(smoke.steps[1]['skipped'])
|
||||
self.assertEqual(smoke.steps[1]['detail']['outcome'], 'runtime missing')
|
||||
self.assertTrue(smoke.steps[2]['ok'])
|
||||
self.assertNotIn(gid, smoke.installed)
|
||||
launch.assert_called_once_with(gid)
|
||||
remove.assert_called_once_with(gid)
|
||||
|
||||
def test_optional_runtime_skip_is_counted_separately_in_saved_result(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
output = Path(root) / 'results'
|
||||
folder = Path(root) / 'titles'
|
||||
folder.mkdir()
|
||||
def cycle(smoke, kind, _folder):
|
||||
if kind == 'x86_64':
|
||||
def missing():
|
||||
raise frame_smoke.Skipped('optional runtime absent', {'outcome': 'runtime missing'})
|
||||
smoke.step(f'{kind}: launch', missing)
|
||||
else:
|
||||
smoke.step(f'{kind}: launch', lambda: {'outcome': 'running'})
|
||||
with mock.patch.object(frame_smoke, 'RESULTS', output), \
|
||||
mock.patch.object(frame_smoke, 'os_release', return_value={
|
||||
'BUILD_ID': 'fake', 'VERSION_ID': 'fake', 'VARIANT_ID': 'fake'}), \
|
||||
mock.patch.object(frame_smoke, 'ssh', return_value='yes'), \
|
||||
mock.patch.object(frame_smoke, 'cleanup', return_value={}), \
|
||||
mock.patch.object(frame_smoke, 'properties', return_value={}), \
|
||||
mock.patch.object(frame_smoke, 'status', return_value={}), \
|
||||
mock.patch.object(frame_smoke, 'title_cycle', side_effect=cycle), \
|
||||
mock.patch.object(frame_smoke.tempfile, 'mkdtemp', return_value=str(folder)):
|
||||
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
|
||||
{'activity': 3, 'proximity': [], 'battery': [100]}), 0)
|
||||
result = json.loads(next(output.glob('*-fake.json')).read_text())
|
||||
self.assertEqual((result['passed'], result['skipped'], result['failed']), (6, 1, 0))
|
||||
self.assertEqual(next(s for s in result['steps'] if s['step'] == 'x86_64: launch')
|
||||
['detail']['outcome'], 'runtime missing')
|
||||
|
||||
def test_non_test_ids_rejected_before_scanning(self):
|
||||
for ids in ([], ['portal2'], ['fc_smoke_exe/../portal2'], ['fc_smoke_custom']):
|
||||
with self.subTest(ids=ids), self.assertRaises(ValueError):
|
||||
|
||||
+20
-14
@@ -11,6 +11,7 @@ import frame_decky
|
||||
METHOD = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,63}\Z')
|
||||
EVENT = re.compile(r'[A-Za-z0-9_:/.-]{1,128}\Z')
|
||||
SESSION = re.compile(r'[A-Za-z0-9_.-]{1,100}\Z')
|
||||
RECEIPT = re.compile(r'[0-9a-f]{64}\Z')
|
||||
MODULE_PATH = re.compile(r'[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*/[A-Za-z0-9_.-]+\.js\Z|index\.js\Z')
|
||||
PLUGINS = Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
|
||||
@@ -139,7 +140,18 @@ finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
def _current_plugin(ssh, plugin_id, receipt):
|
||||
if not isinstance(receipt, str) or not RECEIPT.fullmatch(receipt):
|
||||
raise PanelError('Reopen the plugin panel to refresh its store receipt')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
if found[0].get('sha256') != receipt:
|
||||
raise PanelError('The plugin changed; close and reopen its panel')
|
||||
return found[0]
|
||||
|
||||
|
||||
def call_method(ssh, plugin_id, method, args, legacy=False, receipt=None):
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(method, str) or not METHOD.fullmatch(method):
|
||||
@@ -148,10 +160,8 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
raise PanelError('Plugin method arguments are too large')
|
||||
if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))):
|
||||
raise PanelError('Legacy plugin methods need one keyword argument object')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
plugin = _current_plugin(ssh, plugin_id, receipt)
|
||||
payload = {'name':plugin['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
# Library-sized arguments can exceed the OS limit for a single SSH command
|
||||
# argument. Send them as a quoted Python literal on stdin instead.
|
||||
script = METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__', repr(json.dumps(payload)))
|
||||
@@ -192,15 +202,13 @@ finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def read_steam(ssh, plugin_id, method):
|
||||
def read_steam(ssh, plugin_id, method, receipt=None):
|
||||
"""Read one explicitly supported Steam API for an installed store panel."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(method, str) or method not in STEAM_READS:
|
||||
raise PanelError('This Steam API is unavailable in Framey')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
_current_plugin(ssh, plugin_id, receipt)
|
||||
expression = '(async()=>await ' + STEAM_READS[method] + ')()'
|
||||
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
|
||||
'__FRAMEY_STEAM_LIMIT__', str(2*1024*1024 if method == 'GetLibrarySnapshot' else 256*1024))
|
||||
@@ -219,7 +227,7 @@ finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def poll_events(ssh, plugin_id, session, events, cursor=0):
|
||||
def poll_events(ssh, plugin_id, session, events, cursor=0, receipt=None):
|
||||
"""Read only this store-owned plugin's subscribed events; never trust its claimed name."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
@@ -230,10 +238,8 @@ def poll_events(ssh, plugin_id, session, events, cursor=0):
|
||||
raise PanelError('Invalid Decky event names')
|
||||
if type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
|
||||
raise PanelError('Invalid Decky event cursor')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'session':session, 'events':events, 'cursor':cursor}
|
||||
plugin = _current_plugin(ssh, plugin_id, receipt)
|
||||
payload = {'name':plugin['name'], 'session':session, 'events':events, 'cursor':cursor}
|
||||
# Embed only our bundled source as a Python string; plugin data stays in JSON.
|
||||
source = Path(__file__).with_name('framey-events.js').read_text(encoding='utf-8')
|
||||
script = EVENT_SCRIPT.replace('__FRAMEY_EVENTS_SOURCE__', repr(source))
|
||||
|
||||
+33
-10
@@ -108,25 +108,34 @@ function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.
|
||||
function deckyJobPending(){return !!sessionStorage.getItem('frameyPluginJob');}
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
|
||||
deckyStatusRequest=0,
|
||||
activeFrameyId=null, frameySession=null, deckyBusy=false;
|
||||
activeFrameyId=null, activeFrameyReceipt=null, frameySession=null, deckyBusy=false;
|
||||
async function refreshDecky(){const request=++deckyStatusRequest;try{const state=await api('/api/decky');
|
||||
if(request!==deckyStatusRequest)return;
|
||||
deckyState=state;
|
||||
const active=(state.plugins||[]).find(p=>p.storeId===activeFrameyId);
|
||||
const panelStale=activeFrameyId!==null&&(!state.running||!active||
|
||||
active.sha256!==activeFrameyReceipt?.sha256||active.version!==activeFrameyReceipt?.version);
|
||||
if(panelStale)closeFrameyPanel();
|
||||
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
|
||||
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
|
||||
'Decky is not running. Install it from Frame Control on your computer.';
|
||||
if(panelStale)byId('deckyState').textContent+=' The old plugin panel was closed; reopen it after refreshing.';
|
||||
const list=byId('deckyInstalled');list.replaceChildren();
|
||||
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
|
||||
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
|
||||
const note=document.createElement('p');note.textContent=compatibilityText(plugin);row.append(note);
|
||||
if(plugin.storeId){const open=document.createElement('button');open.className='secondary';open.textContent='Open panel';
|
||||
open.disabled=deckyBusy||deckyJobPending()||!deckyState.running;
|
||||
open.onclick=()=>openFrameyPanel(plugin.storeId,plugin.name);row.append(open);
|
||||
const button=document.createElement('button');button.className='danger';button.textContent='Remove';
|
||||
button.disabled=deckyBusy||deckyJobPending();button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
|
||||
list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.';
|
||||
showDeckyStore();}catch(error){if(request!==deckyStatusRequest)return;
|
||||
const panelClosed=activeFrameyId!==null;
|
||||
if(panelClosed)closeFrameyPanel();
|
||||
deckyState=null;
|
||||
byId('deckyState').textContent=`Installed plugins unavailable: ${error.message}`;
|
||||
if(panelClosed)byId('deckyState').textContent+=' The plugin panel was closed until status can be refreshed.';
|
||||
byId('deckyInstalled').replaceChildren();
|
||||
byId('deckyInstalled').textContent='Refresh to check installed plugins.';
|
||||
showDeckyStore();}}
|
||||
@@ -191,14 +200,20 @@ async function deckyChange(action,plugin){if(deckyBusy)return;
|
||||
}
|
||||
if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
|
||||
deckyBusy=true;showDeckyStore();
|
||||
deckyJobView(`${action} ${plugin.name}`,'Starting…',0);
|
||||
const panelClosed=(action==='update'||action==='remove')&&activeFrameyId===plugin.id;
|
||||
const panelNote=panelClosed?(action==='remove'?' Plugin panel closed after removal.':
|
||||
' Old plugin panel closed; reopen it after refreshing installed plugins.') : '';
|
||||
if(panelClosed)closeFrameyPanel();
|
||||
deckyJobView(`${action} ${plugin.name}`,
|
||||
panelClosed?'Starting… Old plugin panel closed until the change finishes.':'Starting…',0);
|
||||
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
|
||||
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
|
||||
if(!started.job)throw Error('The server did not return a job ID');
|
||||
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
|
||||
await followDeckyJob(started.job,action,plugin.name);
|
||||
sessionStorage.removeItem('frameyPluginJob');
|
||||
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}`,null,true);}
|
||||
if(panelNote)byId('deckyJobStatus').textContent+=panelNote;
|
||||
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
|
||||
finally{deckyBusy=false;await refreshDecky();}
|
||||
}
|
||||
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
|
||||
@@ -210,10 +225,12 @@ async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.get
|
||||
finally{deckyBusy=false;await refreshDecky();}}
|
||||
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;
|
||||
byId('frameyCompatibility').textContent=compatibilityText(installed);
|
||||
closeFrameyEvents();
|
||||
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
|
||||
activeFrameyId=id;activeFrameyReceipt={sha256:installed.sha256,version:installed.version};
|
||||
frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
|
||||
byId('frameyPanelStatus').textContent='Loading plugin panel…';
|
||||
byId('frameyFrame').src=`/framey/panel/${id}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
|
||||
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
|
||||
@@ -223,10 +240,13 @@ function openFrameyPanel(id,name){
|
||||
let frameyEventsActive=false;
|
||||
function closeFrameyEvents(){
|
||||
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
api('/api/framey/events',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
frameyEventsActive=false;
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{closeFrameyEvents();activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
function closeFrameyPanel(){closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
|
||||
byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;}
|
||||
byId('frameyClose').onclick=closeFrameyPanel;
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
function frameyCacheRead(id){
|
||||
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
|
||||
@@ -267,9 +287,11 @@ window.addEventListener('message',async event=>{
|
||||
req.args.length!==2||!Array.isArray(req.args[0])||req.args[0].length>32||
|
||||
req.args[0].some(name=>typeof name!=='string'||!/^[A-Za-z0-9_:/.-]{1,128}$/.test(name))||
|
||||
!Number.isSafeInteger(req.args[1])||req.args[1]<0)return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
|
||||
reply={type:'framey:reply',id:req.id};
|
||||
frameyEventsActive=req.args[0].length>0;
|
||||
try{reply.result=await api('/api/framey/events',{id:pluginId,session,events:req.args[0],cursor:req.args[1]});}
|
||||
try{reply.result=await api('/api/framey/events',{id:pluginId,receipt,session,
|
||||
events:req.args[0],cursor:req.args[1]});}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
|
||||
event.source.postMessage(reply,'*');
|
||||
@@ -278,9 +300,10 @@ window.addEventListener('message',async event=>{
|
||||
if(!['framey:call','framey:steam'].includes(req.type)||req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const steamRead=req.type==='framey:steam';
|
||||
if(steamRead&&(req.args.length!==0||!['GetIPCountry','GetInstallFolders','GetSystemInfo','GetLibrarySnapshot'].includes(req.method)))return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
|
||||
reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api(steamRead?'/api/framey/steam':'/api/framey/method',
|
||||
{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
{id:pluginId,receipt,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
});
|
||||
|
||||
+4
-3
@@ -581,7 +581,8 @@ def framey_method_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'), body.get('legacy', False))
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'),
|
||||
body.get('legacy', False), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
@@ -590,7 +591,7 @@ def framey_steam_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.read_steam(ssh, body.get('id'), body.get('method'))
|
||||
return framey_panel.read_steam(ssh, body.get('id'), body.get('method'), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
@@ -600,7 +601,7 @@ def framey_events_action(body):
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.poll_events(ssh, body.get('id'), body.get('session'),
|
||||
body.get('events'), body.get('cursor', 0))
|
||||
body.get('events'), body.get('cursor', 0), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
Reference in new issue
Block a user