Compare commits

..
Author SHA1 Message Date
SAPHID 36da680e14 Merge remote-tracking branch 'origin/main' into test/framey-e2e-isolation 2026-10-28 21:46:58 +00:00
SAPHID 841c0b84be Clear managed Decky state between fake Frame tests 2026-10-28 21:46:41 +00:00
Alex Southwell 68f46bdd60 Merge pull request #389 from saphid/fix/framey-fileserver-url
Show DeckyFileServer HTTPS address in Framey
2026-10-28 21:46:05 +00:00
Alex Southwell 7eaa878827 Merge branch 'main' into fix/framey-fileserver-url 2026-10-28 21:28:27 +00:00
Alex Southwell c8577804df Merge pull request #388 from saphid/docs/freeloader-native-accuracy
Align Free Loader guide with native Decky evidence
2026-10-28 21:27:57 +00:00
SAPHID eda0a563c0 Expect adapted DeckyFileServer bytes in fake Frame e2e 2026-10-28 21:19:32 +00:00
SAPHID 4f1072cb4d Cover DeckyFileServer address shim in native Decky gate 2026-10-28 21:09:22 +00:00
Alex Southwell 38dbd4cc0b Merge branch 'main' into docs/freeloader-native-accuracy 2026-10-28 21:04:55 +00:00
SAPHID 326078cc24 Show DeckyFileServer HTTPS address in Framey 2026-10-28 21:02:07 +00:00
Alex Southwell 0c0df7a243 Merge pull request #387 from saphid/fix/frame-smoke-skip-exit
Fail Frame smoke when a launch is skipped
2026-10-28 20:51:24 +00:00
SAPHID 04ccdda050 Correct Free Loader native Decky verification summary 2026-10-28 20:44:36 +00:00
SAPHID 6c37fbbd37 Fail Frame smoke when any launch is skipped 2026-10-28 20:35:06 +00:00
Alex Southwell e7b037ce88 Merge pull request #386 from saphid/docs/support-matrix-v0453
Align current support matrix with v0.4.53 evidence
2026-10-28 20:25:46 +00:00
SAPHID f1c8017873 Align current support matrix with v0.4.53 evidence 2026-10-28 20:13:02 +00:00
Alex Southwell defa6f49f0 Merge pull request #385 from saphid/test/framey-freeloader-native
Test official Free Loader with native Decky settings and empty deals
2026-10-28 20:08:51 +00:00
SAPHID 89e5ee610d Merge remote-tracking branch 'origin/main' into test/framey-freeloader-native 2026-10-28 19:58:54 +00:00
SAPHID 2b8836929e Test official Free Loader in native Decky runtime 2026-10-28 19:58:54 +00:00
Alex Southwell 248e5b3382 Merge pull request #384 from saphid/fix/mac-updater-preserve-quarantine
Preserve macOS quarantine metadata during auto-update
2026-10-28 19:58:16 +00:00
SAPHID 3dcba52e91 Preserve macOS quarantine metadata during auto-update 2026-10-28 19:41:56 +00:00
Alex Southwell efda14776d Merge pull request #383 from saphid/ci/audit-published-update-manifest
Verify the published update manifest in release audits
2026-10-28 19:25:21 +00:00
SAPHID 6bcce9cbf7 Gate publication on uploaded manifest readback 2026-10-28 19:14:10 +00:00
SAPHID 8f1a811e1d Verify published update manifest in release asset audit 2026-10-28 19:08:59 +00:00
Alex Southwell 9658f38382 Merge pull request #382 from saphid/ci/reusable-release-assets-audit
Make release installer checks repeatable
2026-10-28 18:55:14 +00:00
SAPHID eb77fe1d2f Make release installer checks repeatable 2026-10-28 18:47:40 +00:00
Alex Southwell c733fab6ae Merge pull request #381 from saphid/docs/v0453-release-verification
Record v0.4.53 release verification
2026-10-28 18:28:40 +00:00
SAPHID 2fcf50e59a docs: record v0.4.53 release verification 2026-10-28 18:16:29 +00:00
Alex Southwell e4ce91d686 Merge pull request #380 from saphid/release/v0.4.53
Prepare Frame Control v0.4.53 candidate
2026-10-28 17:47:40 +00:00
SAPHID a1a376b4ee docs: confirm v0.4.53 source after support matrix merge 2026-10-28 17:23:18 +00:00
SAPHID fe8d94b00b Merge remote-tracking branch 'origin/main' into release/v0.4.53 2026-10-28 17:22:47 +00:00
Alex Southwell 05be1e2c13 Merge pull request #379 from saphid/docs/support-matrix-v0452
docs: align support matrix with v0.4.52 evidence
2026-10-28 17:22:17 +00:00
SAPHID e0bdaebe4f docs: compare candidate source with locked smoke 2026-10-28 17:09:57 +00:00
SAPHID d977bc3d54 Merge remote-tracking branch 'origin/docs/support-matrix-v0452' into release/v0.4.53 2026-10-28 17:09:17 +00:00
SAPHID d12071a1e7 docs: record v0.4.53 candidate smoke 2026-10-28 17:07:36 +00:00
SAPHID 2aa77b30d6 Prepare Frame Control v0.4.53 candidate 2026-10-28 16:59:05 +00:00
33 changed files with 1010 additions and 42 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.52",
"version": "0.4.53",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.52",
"version": "0.4.53",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.52",
"version": "0.4.53",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
-1
View File
@@ -181,7 +181,6 @@ old="$stage/old.app"
if mv "$app" "$old"; then
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
fi
xattr -dr com.apple.quarantine "$app" 2>/dev/null
rm -rf "$stage"
open "$app"
`;
+4 -4
View File
@@ -1,11 +1,11 @@
# What we have tested
Current published release: Frame Control **v0.4.52**, 28 October 2026. Rows identify the last direct check of each feature; an older row does not mean the latest installer was retested on that hardware. “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. See each link for the exact procedure and limits.
Current published release: Frame Control **v0.4.53**, 28 October 2026. Rows identify the last direct check of each feature; an older row does not mean the latest installer was retested on that hardware. “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. 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 and ZIP hashes/architectures verified; clean DMG app installed and launched. Native UI, preferences across restart, update discovery of 0.4.40, offline OBS Start/Stop and 116-entry Decky store checked. Actual Open at Login menu toggled on, persisted across Quit/relaunch, then restored off. A 54-byte relay upload/readback/delete passed under coordinator/headset locks | No | Ordinary browser quarantine, right-click Open and Open Anyway remain unverified; the synthetic quarantine probe was inconclusive. Actual login startup untested. Direct frame.local had no route and app SSH trust failed; relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Earlier shutdown race fix #284 shipped in v0.4.40, whose Intel installer was not tested here | 26 Oct, official v0.4.39 tag `fd0b0a9`: [repeat Intel acceptance evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); [earlier release test](evidence/mac-v0439-2026-10-26.md) |
| Core title smoke test | A Linux host ran the v0.4.52 versioned source candidate, whose tree matches the tagged release commit, against the real Frame under a unique lock: 12 steps passed, one optional x86_64 launch was skipped, zero failed; a separate locked cleanup audit passed | No | The headset was in standby and unworn. Steam Linux Runtime 4 was absent, so the x86_64 test executable did not launch. Worn rendering and physical-controller input were not checked | 28 Oct, v0.4.52 tag `v0.4.52`, Frame OS 0.5.6 build `20261022.6153909`: [release verification](evidence/v0452-release-2026-10-28.md), [candidate smoke](evidence/v0452-candidate-2026-10-28.md) |
| Core title smoke test | A Linux host ran the v0.4.53 versioned source candidate against the real Frame under a unique lock; only documentation changed before the tagged release, leaving its executable source the same: 12 steps passed, one optional x86_64 launch was skipped, zero failed; a separate locked cleanup audit passed | No | The headset was in standby and unworn. Steam Linux Runtime 4 was absent, so the x86_64 test executable did not launch. Worn rendering and physical-controller input were not checked | 28 Oct, v0.4.53 tag `v0.4.53`, Frame OS 0.5.6 build `20261022.6153909`: [release verification](evidence/v0453-release-2026-10-28.md), [candidate smoke](evidence/v0453-candidate-2026-10-28.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 @@ Current published release: Frame Control **v0.4.52**, 28 October 2026. Rows iden
| Open at login | No Frame needed; official v0.4.39 on the physical Intel Air toggled the actual native menu on, retained its checkmark after Quit/relaunch and restored it off. Linux autostart file previously toggled on a physical Linux host with an isolated config directory | No | A full OS login session and physical Windows host login remain untested | 26 Oct, v0.4.39: [native Intel toggle evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); Linux 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 | Earlier real Frame checks passed opt-in ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove. The current exact-version catalog records six scoped works: Notebook default drawing, Volume Mixer and Mixer silent per-app volume, Volume Boost muted sink controls, DeckySpy metrics, and Simple Timer silent in-panel timers | No | v0.4.52 classifies 116 exact-version plugins as **6 works / 54 partial / 45 broken / 11 N/A**. “Works” applies only to each documented behavior, never worn VR. Network Sentinel and Battery Tracker are partial; their managed real-Frame ARM64 behavior is unverified. Most other backends, audible output, physical-controller use and wearer-visible official panels remain unverified | 28 Oct, v0.4.52: [exact-version table](framey-compatibility.md), [release verification](evidence/v0452-release-2026-10-28.md), [DeckySpy evidence](evidence/framey-deckyspy-2026-10-26.md), [Simple Timer evidence](evidence/framey-simple-timer-2026-10-26.md), [audio evidence](evidence/framey-audio-arm64-2026-10-26.md) |
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 exercise showed the repository sample panel in stereo compositor and mirror captures. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file, and confirmed it after a fresh iframe reopen; temporary services were removed and original power settings restored | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. Fake-Frame install/remove and local Chromium plugin initialization do not prove an official plugin is visible or usable in worn VR | 28 Oct, v0.4.52 catalog with earlier 25–26 Oct VR evidence: [unattended sample evidence](evidence/framey-vr-2026-10-25/README.md), [compatibility table](framey-compatibility.md), [worn checklist](framey-vr-check.md) |
| Experimental Decky Loader manager | Earlier real Frame checks passed opt-in ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove. The current exact-version catalog records six scoped works: Notebook default drawing, Volume Mixer and Mixer silent per-app volume, Volume Boost muted sink controls, DeckySpy metrics, and Simple Timer silent in-panel timers | No | v0.4.53 classifies 116 exact-version plugins as **6 works / 54 partial / 45 broken / 11 N/A**. “Works” applies only to each documented behavior, never worn VR. Network Sentinel and Battery Tracker are partial; their managed real-Frame ARM64 behavior is unverified. Most other backends, audible output, physical-controller use and wearer-visible official panels remain unverified | 28 Oct, v0.4.53: [exact-version table](framey-compatibility.md), [release verification](evidence/v0453-release-2026-10-28.md), [DeckySpy evidence](evidence/framey-deckyspy-2026-10-26.md), [Simple Timer evidence](evidence/framey-simple-timer-2026-10-26.md), [audio evidence](evidence/framey-audio-arm64-2026-10-26.md) |
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 exercise showed the repository sample panel in stereo compositor and mirror captures. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file, and confirmed it after a fresh iframe reopen; temporary services were removed and original power settings restored | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. Fake-Frame install/remove and local Chromium plugin initialization do not prove an official plugin is visible or usable in worn VR | 28 Oct, v0.4.53 catalog with earlier 25–26 Oct VR evidence: [unattended sample evidence](evidence/framey-vr-2026-10-25/README.md), [compatibility table](framey-compatibility.md), [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.
@@ -0,0 +1,29 @@
# DeckyFileServer 1.2.0 address correction, 28 October 2026
Official store ID 60 and ZIP SHA-256
`7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0`.
The exact bundled frontend `dist/index.js` has SHA-256
`791015d8b3f234fad89ce422b09fb32bbad05e7c867d501706cfbcf9f7c6187f`.
Only that pinned managed archive receives this change. A changed archive keeps
the normal native/root rejection.
The [pinned corresponding Go source](../../ui/vendor/deckyfileserver-1.2.0/README.md)
calls `ListenAndServeTLS` for every server start. The official frontend instead
constructs `http://steamdeck:<port>` when uploads are off, and toggles the
second address between HTTP and HTTPS using the upload setting. Neither rule
matches the server. The `steamdeck` hostname also has no verified resolution
on Frame. Earlier [real Frame backend evidence](framey-fileserver-arm64-2026-10-27.md)
confirmed private HTTPS listing and download with uploads disabled.
The managed installer now checks the original frontend hash and replaces the
two displayed addresses with one `https://<backend IP>:<port>` address. A
rendered-element check executes the installed official bundle and verifies
that URL with uploads off and on; it also checks that no `steamdeck` address
remains. Receipts add `deckyfileserver-https-address`. Older managed copies
without it display an update or reinstall notice; the compatibility status
stays **partial**.
This check does not prove a live browser can reach every returned IP, or that
the folder picker works in Framey. Use a preconfigured directory. Uploads,
generated thumbnails, worn VR, physical controllers and wearer readability
remain unverified. This correction did not touch the real headset.
@@ -0,0 +1,25 @@
# Free Loader 1.6.0: private Decky backend check
The official store archive for ID 63 was downloaded and matched SHA-256
`05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a`.
The published Decky 3.3.0 amd64 loader matched SHA-256
`e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181`.
An unprivileged, disposable Debian container ran that loader and the exact
official plugin with no network, host devices, host session or writable host
mount. Decky discovered Free Loader as enabled. Its API 1 methods returned 14
default settings and an empty local deals map. A private `larger_text` setting
changed to `true` and read back; startup created only the container's private
settings file, and `read_deals` created an empty private deals cache. No feed,
notification, Steam store or external account method was called. The local
probe exited successfully; its container was removed. The committed native
Decky runtime case repeats these calls and also checks persistence after a
loader restart on the CI runner's native architecture. The local x86_64 run
passed all 14 runtime cases, including Free Loader and the existing negative
regressions. The runner removed its temporary containers and image.
This strengthens the **partial** label. It does not verify a live feed refresh,
notification delivery, real Frame ARM64 service, rendered deal cards, worn VR
or controller interaction. The earlier opaque Chromium check had denied backend
and network calls, so it could not establish that deal cards render with live
data. No headset was touched for this check.
@@ -0,0 +1,35 @@
# Repeatable installer audit command, 28 October 2026
The independent installer check used for v0.4.53 was previously a local
one-off script. The new script at scripts/audit-release-assets.py makes that
check repeatable before future releases are published. It reads the release
by exact tag or the draft's exact version title, requires ten installers plus
SHA256SUMS, downloads each by asset ID, checks size and SHA-256 against GitHub
metadata, matches the downloaded checksum file byte for byte, and inspects
package formats and versions. On an x86_64 Linux host, its optional flag also
launches the tagged x86_64 AppImage under isolated Xvfb.
The command writes a JSON report only after all checks pass and refuses to
overwrite an existing report. It performs no headset operation and makes no
GitHub changes. It does not prove physical Mac or Windows installer launch.
## Verification
- Eight focused offline tests passed, including incomplete metadata, wrong
Mac bundle version, wrong AppImage architecture, a bad downloaded checksum
file, and refusal to overwrite a report. Three existing checksum-generator
tests passed.
- After v0.4.53 publication, the new command audited its public release with
the tag v0.4.53, report path /tmp/frame-v0453-reusable-audit.json, and the
--launch-x86-appimage option. It exited successfully after independently
downloading all ten installers. Their names, sizes, hashes and package
checks matched the [prepublication asset audit](v0453-assets-2026-10-28.json),
and the downloaded SHA256SUMS had the same SHA-256:
813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c.
The x86_64 AppImage's bundled server returned HTTP 200 with FrameControl/1
and stopped after app exit. The local JSON report had SHA-256
ad8d7feaf6c0398b216f43c00206dd01d6efc714e3b341048bece96c0e5be376.
This postpublication repeat verifies the new command; it does not replace
the prepublication v0.4.53 release gates recorded in
[release verification](v0453-release-2026-10-28.md).
@@ -0,0 +1,76 @@
{
"tag": "v0.4.53",
"releaseId": 427006429,
"tagNameAtAudit": "v0.4.53",
"draftAtAudit": true,
"sha256sumsSha256": "813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c",
"assets": [
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 177629060,
"sha256": "8ae712108aa3fdfb5f40a2f205db65ffd7694914bab2ac0af7a85dafb1c8c31e",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 177452293,
"sha256": "f6f683e43b397d51432a8651d0b54d5d96cfd3e9d75e0fe4a9769cdd10ccf33b",
"zipCrc": "ok",
"members": 1673,
"bundleVersion": "0.4.53"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 184700330,
"sha256": "7c25a0d6633aa5312c5e7a1abf8d1abd656e109cc78093a2c5bd29bf790d8e03",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 184538726,
"sha256": "fe7c5b46ea718a5e8f5d8cc511813d420fdbdfea4858e4c4c20f92b7aaaed529",
"zipCrc": "ok",
"members": 1669,
"bundleVersion": "0.4.53"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 151885488,
"sha256": "9bdbafc00fd170a4e31dbaafc73ce89c952c85a1cbad9198822ea2c473489597",
"peMachine": 332
},
{
"name": "Frame-Control-win-x64.zip",
"size": 198840858,
"sha256": "8360ea2427a84374a2106267d0440dbf5e4d9b22f9ef44e465d31f81c7ebfaff",
"zipCrc": "ok",
"members": 1517
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 179997230,
"sha256": "7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b",
"elfMachine": 62
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 171579370,
"sha256": "985b0a1c3e04af5cc1a41e2e7ad9e8481aac45762b369044bf314972e894dc64",
"elfMachine": 183
},
{
"name": "Frame-Control-linux-amd64.deb",
"size": 146412012,
"sha256": "2adbef7c1bec3e9e40244984598a2e2c9816eb2c21fdb21d6996f9d30f2d0fec",
"debVersion": "0.4.53",
"architecture": "amd64"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 133498848,
"sha256": "0625dfdb1959f8eab52ac3f6c4bf909b7de3966c9ca959c391e43630516ff9c7",
"debVersion": "0.4.53",
"architecture": "arm64"
}
]
}
@@ -0,0 +1,58 @@
# v0.4.53 candidate smoke, 28 October 2026
The versioned source candidate `2aa77b30d684ee8dc9a3384648bcd9bfd5c634e5`
bumps only `app/package.json` and its lockfile from 0.4.52 to 0.4.53 on
main commit `978af2f2879d80a22a72ea5a6992884424ed3480`. That main commit
contains [PR #377](https://github.com/saphid/frame-control/pull/377), which
copies desktop-selected files directly rather than staging them through the
8 GiB HTTP upload route. Browser uploads keep the 8 GiB limit. A physical
26.5 GB USB-to-Frame transfer has **not** been verified.
## Locked real Frame smoke
At 17:04:22 UTC, `scripts/frame-smoke.sh` ran from that candidate on lxso2.
Before any status read or title change, it acquired `/tmp/frame-test.lock`
atomically with its unique owner token. Its read-only preflight found
SteamVR activity **3 (standby)**, proximity raw **2.3125**, and battery
**100%**. It did not request pairing or VR input.
On Frame OS 0.5.6 build `20261022.6153909`, ARM64 and Windows/Proton tiny
test titles installed, launched and were removed. The x86_64 title installed
and was removed; its optional launch was **skipped** because Steam Linux
Runtime 4 was absent. The result was **12 passed, one optional skip, zero
failed**. The runner's cleanup passed and it released only its own lock.
The private JSON result is
`/tmp/frame-control-v0453-smoke-results/2026-10-28T170422Z-20261022.6153909.json`
with SHA-256
`5a5bc8b1da1ef405235d660a2587c84204afd0816c80f8d9dfd9577dac72d547`.
A separate read-only audit acquired and released a fresh unique-owner lock.
It found activity **3**, proximity raw **2.3125**, battery **100%**, no
reserved smoke title files or metadata, and no processes owned by those
title paths. Its private JSON result is
`/tmp/frame-v0453-cleanup-audit.json` with SHA-256
`6ab884b6adffb14c4643e30ee2e5fe247b2ebba7a3ad5424515927cd40146e79`.
No power or security setting was changed. Nobody was confirmed wearing the
Frame; wearer-visible VR, physical-controller use and the optional x86_64
launch remain unverified. The separate hourly worn check stays enabled.
After this smoke, the local release branch merged the exact green head
`a1bc084400058908132e6937322bc97e71ed5d68` of support-matrix PR #379.
Compared with the smoked commit `2aa77b3`, only this evidence file and
`docs/current-support-matrix.md` changed. A diff over `app`, `ui`, `frame`,
`scripts`, `tests`, `desktop`, `mac` and `ios` was empty; the executable
source tested by smoke remains the same. The branch then merged PR #379's
main commit `05be1e2c1341ad9f36c6d5bbda27e7eaaf84ef59`; the executable
diff remained empty. This does not replace final-commit CI or installer
verification.
## Gates still required
This smoke is evidence for the versioned source candidate, not a release.
The final candidate must pass its own exact-head CI, e2e, native Decky
runtime and four installer
previews. Its final merged main commit must then pass exact-commit CI, e2e
and native runtime. The tag must build all four installer jobs and checksums
without failed, cancelled, skipped or overridden gates. Independent package
checks, release verification and a successful publisher dry run are required
before publication. Any unfinished or failed gate holds v0.4.53 as a draft.
+73
View File
@@ -0,0 +1,73 @@
# v0.4.53 release verification, 28 October 2026
The annotated `v0.4.53` tag dereferences to merged main commit
`e4ce91d6868665e6689a4aa17a27a46bf9abd089`. Its tree matches reviewed
[candidate PR #380](https://github.com/saphid/frame-control/pull/380) head
`a1a376b4ee9464ac9d97426cce21e662e3a9bf1e`; the diff is empty.
## Exact commit gates
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40739079630)
passed all seven jobs on the first attempt, including e2e, iOS, Windows,
macOS, Linux and aggregate checks.
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40739079822)
passed both published Decky 3.3.0 AMD64 and ARM64 jobs on the first attempt.
- Tagged installer and checksum workflow:
[run 40740373224](https://github.com/saphid/frame-control/actions/runs/40740373224).
All six jobs passed on the first attempt: draft, Linux, Windows, Intel Mac,
Apple Silicon Mac and checksums. No tagged job failed, was cancelled,
skipped or overridden.
Before publication the draft held exactly ten installers and `SHA256SUMS`.
Every installer was independently downloaded by GitHub asset ID; each size
and SHA-256 matched the asset metadata. The downloaded `SHA256SUMS` matched
all ten entries byte for byte and had SHA-256
`813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c`.
The [sanitized asset audit](v0453-assets-2026-10-28.json) records names,
sizes, hashes and package checks. It verified both Mac ZIP CRCs and bundle
version `0.4.53`, both DMG UDIF trailers, Windows ZIP CRC and PE header,
both AppImage ELF architectures, and both DEB version and architecture fields.
## Real Frame and package checks
The [candidate smoke evidence](v0453-candidate-2026-10-28.md)
records a unique-lock source smoke: 12 passed, one optional x86_64 launch
skipped because Steam Linux Runtime 4 was absent, zero failed. Independent
fresh-lock cleanup found no reserved test titles or owned processes. The
source tree changed only in documentation after the smoke, and the merged
release tree matches the PR head. SteamVR was in standby; nobody was confirmed
wearing the Frame, and no VR input was sent.
The private smoke result had SHA-256
`5a5bc8b1da1ef405235d660a2587c84204afd0816c80f8d9dfd9577dac72d547`;
the fresh-lock cleanup audit had SHA-256
`6ab884b6adffb14c4643e30ee2e5fe247b2ebba7a3ad5424515927cd40146e79`.
The tagged x86_64 AppImage downloaded independently by asset ID had SHA-256
`7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b`.
It launched under isolated Xvfb with a temporary profile, telemetry and update
checks disabled; its bundled server returned HTTP 200 with `FrameControl/1`
and stopped after app exit. No physical Mac or Windows launch of these exact
installers was performed.
## Publication
`scripts/publish-release.sh --dry-run v0.4.53` passed exact-main, native
runtime and tagged release jobs, all ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update-manifest checks. Its log had SHA-256
`ddad938f25d4321a8d24b29ff895a20431ec6d1c2c6faf8fd42f939100c97bff`.
The publisher repeated those gates, attached `update.json`, and published
[v0.4.53](https://github.com/saphid/frame-control/releases/tag/v0.4.53) at
**18:14:22 UTC**. Its log had SHA-256
`b54cd7f87c0ba858cc61086fc6293287bdb7555c6a49d3f1c9b8216b39d0dcf3`.
GitHub's latest-release API returned `draft=false`, `prerelease=false`, the
correct tag, ten installers, `SHA256SUMS` and `update.json`. A fresh download
from the public `releases/latest/download/update.json` route reported version
`0.4.53`, the correct release page, all ten names, sizes and hashes matching
the independent downloads, and the explicit 26.5 GB transfer limit. The
public manifest had SHA-256
`b873a3422e60e2fec2ff4a08f0a1fe610b9df18eb94a1a72e259beefb763bb38`.
The exact-version Decky catalog remains **6 works / 54 partial / 45 broken /
11 N/A**. A physical 26.5 GB transfer, wearer-visible VR, physical-controller
use and the optional x86_64 title launch are unverified. The separate hourly
worn check remains enabled.
@@ -0,0 +1,31 @@
# v0.4.54 candidate hold, 28 October 2026
The local, unpushed `release/v0.4.54` candidate is commit
`3eefedd8d4dd2ce8db7d62d0dd2d7cff1fe2089b`, based on main
`e7b037ce883e45990bf239aee29b05434a39a399`. It changes only the
version in `app/package.json` and `app/package-lock.json`. It has not been
tagged or published. The latest public release remains
[v0.4.53](https://github.com/saphid/frame-control/releases/tag/v0.4.53).
The locked real Frame smoke at 20:28 UTC read SteamVR activity 3 (standby),
proximity 2.3125 and battery 100% on Frame OS build `20261022.6153909`.
ARM64 and Windows/Proton test titles launched. The x86_64 test title did
**not** launch: Steam reported that Steam Linux Runtime 4.0 was found but
not installed. The result has **12 passed, 1 skipped, 0 failed**. Its final
cleanup step passed and its unique lock was released. The private JSON result
is `/tmp/frame-v0454-smoke-results/2026-10-28T202826Z-20261022.6153909.json`
with SHA-256 `d86edaec0b86faf130c21aa3d7113cc1ac5b2ae0869dbd15f811bf3bb5b76b88`.
A separate read-only audit acquired and released a fresh unique headset lock.
It read activity 3, proximity 2.3125 and battery 100%, and found no reserved
smoke title directories or owned test processes. It did not wake the Frame or
send input. Nobody confirmed wearing the Frame, so no wearer-visible or
physical-controller result follows.
**Release decision: hold v0.4.54.** The smoke script at the candidate commit
returned exit 0 even though it recorded a skipped launch. Under the current
release rule, a skip cannot satisfy the smoke gate. The accompanying change
makes a skipped smoke return nonzero while preserving the explicit skipped
step and cleanup record. Exact release-commit CI, release verification, tagged
installer builds, checksums and publisher dry run have not been performed for
this local candidate.
+4 -4
View File
@@ -25,7 +25,7 @@ python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives
Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.
A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.
A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. A later [native Decky 3.3.0 check](evidence/framey-free-loader-native-2026-10-28.md) verified plugin discovery, default settings, empty local deals and private setting persistence after restart in a network-isolated amd64 container. Live feed updates, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified.
A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck's advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.
@@ -78,10 +78,10 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 56: MangoPeel 0.0.7 | 1 / 2 | partial | Initializer succeeds; gamescope/MangoHud actions and backend are unverified. | static archive, local browser, fake-Frame |
| 58: Volume Boost 0.0.7 | legacy / legacy | works | Default-sink controls pass in local Chromium through real Decky 3.3.0 ARM64: linked slider/keyboard and numeric writes, separate channels, re-link and reopen, with independent Pulse readback including 125%/150% while muted. Exact original volume/mute restored. Audible output, Bluetooth sinks and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
| 59: Web Browser 1.4.3 | legacy / legacy | broken | The exact frontend now initializes with a null Steam React-root lookup. The browser itself still requires Steam GamepadUI CDP injection and a backend WebSocket unavailable in Framey; no working browser tab was verified. | static archive, local browser, fake-Frame, focused local browser |
| 60: DeckyFileServer 1.2.0 | 1 / 2 | partial | Managed install supplies a native ARM64 helper and removes the exact archive's unnecessary root request. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. Frontend address/picker limitations remain; use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame, real Decky 3.3.0 amd64, real Frame SSH |
| 60: DeckyFileServer 1.2.0 | 1 / 2 | partial | Managed install supplies a native ARM64 helper, removes the exact archive's unnecessary root request, and displays the backend-returned IP with HTTPS. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. The Steam folder picker remains unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Existing managed copies need update or reinstall for the address fix. | static archive, local browser, fake-Frame, real Decky 3.3.0 amd64, real Frame SSH |
| 61: TabMaster 2.15.1 | 1 / 2 | partial | Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified. | static archive, local browser, fake-Frame |
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend |
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's byte-identical duplicate ZIP members pass guarded install/remove. Actual Decky 3.3.0 in a private network-isolated container discovers the plugin, reads default settings and empty local deals, and retains a settings change after restart. Live deal-feed refresh, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified; no feed request was made. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend, isolated Linux backend |
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
@@ -287,7 +287,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. The three Windows DLLs belong to Decky-Framegen. Except for the exact managed DeckyFileServer and Decky Localsend adaptations below, the installer still rejects these archives. Apart from LocalSend and DeckyFileServer, ARM64 builds were not attempted: the constraints below remain unresolved.
A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. The plugin moves from broken to partial: its frontend advertises HTTP/steamdeck URLs and needs a Steam folder picker. Use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.
A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. The plugin remains partial. A [frontend URL follow-up](evidence/framey-fileserver-url-2026-10-28.md) changes only the exact managed bundle to display the backend-returned IP with HTTPS, matching the helper. Existing managed copies need update or reinstall. The Steam folder picker is still unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.
[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) covers an isolated real-Frame protocol 2.0 info response from an earlier helper build. [Exact official source matching](evidence/framey-localsend-source-match-2026-10-27.md) now identifies the official helper source and a reproducible ARM64 build from that revision. The exact official x86 backend passed one local private-namespace transfer, and the rebuilt ARM64 helper passed one locked real-Frame private marker transfer. Published Decky 3.3.0 AMD64/ARM64 runtime checks discovered the adapted plugin and read its stopped status and configuration. Only the exact managed 0.4.5 archive can replace its x86 helper on Frame. The actual managed Decky service on Frame, user-facing transfer, local discovery and worn VR remain unverified ([managed adaptation evidence](evidence/framey-localsend-managed-2026-10-27.md)).
+25 -3
View File
@@ -27,8 +27,22 @@ count. So a build reaches people only when you publish it, after testing it.
temporary profile. This CI test does not test a physical display, FUSE mount
or a headset.
2. Download the draft's installers and test them. An installed copy of the
previous version won't offer the draft, so install it directly.
2. Independently download and inspect the draft's installers on an x86_64
Linux host with `gh`, `dpkg-deb` and `xvfb-run` installed:
```sh
scripts/audit-release-assets.py v0.4.0 \
--report /tmp/frame-v0.4.0-assets.json --launch-x86-appimage
```
The command downloads each of the ten installers by asset ID, verifies its
size and SHA-256 against GitHub metadata, matches the downloaded
`SHA256SUMS` byte for byte, checks package formats and versions, and starts
the x86_64 AppImage with a temporary profile and isolated Xvfb display. It
writes the report only after all checks pass. This does not prove that the
Mac or Windows installers launch on physical machines. Test those packages
separately when the required machines are available. An installed copy of
the previous version won't offer the draft, so install it directly.
3. Write the release notes on the draft. The update banner links to them.
@@ -58,7 +72,9 @@ count. So a build reaches people only when you publish it, after testing it.
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
installer digests. It attaches
`update.json` (the version, the notes, the release page and each
installer's digest), then publishes the release and marks it latest. It
installer's digest), verifies the uploaded asset's SHA-256 and downloads
its bytes for an exact readback before publishing the release and marking
it latest. It
refuses an already published release before replacing its live update
manifest; use a new tested release for changes after publication. It
uses only the REST API: `gh release view` can't find a draft whose
@@ -70,6 +86,12 @@ count. So a build reaches people only when you publish it, after testing it.
seconds after starting, then every 6 hours, and anyone can use **Check for
Updates…** (the app menu on macOS, the Help menu elsewhere).
After publication, rerun `audit-release-assets.py` with a new report path.
For a published release it also downloads `update.json`, verifies its
GitHub SHA-256, and checks that the offered version, release page, notes,
installer names, sizes, and digests match that release. This checks the
updater channel's published bytes, which the draft audit cannot inspect.
To pull a bad release, mark the previous one as latest
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
Copies that already updated stay on it. Nothing downgrades them.
+3 -2
View File
@@ -233,8 +233,9 @@ 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.
exits 0 only when every step passed, 1 if a step failed or was skipped, or 2 if
the headset isn't reachable. A skipped x86-64 launch blocks a release smoke
gate even though it remains separately labeled in the result.
Smoke process cleanup runs before and after title removal and during the
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Independently download and inspect every installer in a GitHub release.
Run on Linux after the tagged workflow finishes and before publishing its
draft. The JSON report is written only after every check passes.
"""
import argparse
import hashlib
import json
import platform
import plistlib
import re
import runpy
import shutil
import struct
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
REPO = "saphid/frame-control"
SCRIPTS = Path(__file__).resolve().parent
CHECKSUMS = runpy.run_path(str(SCRIPTS / "release-checksums.py"))
INSTALLERS = CHECKSUMS["INSTALLERS"]
class AuditError(Exception):
"""Release asset metadata or downloaded bytes failed a check."""
def github_json(path):
result = subprocess.run(
["gh", "api", f"repos/{REPO}/{path}"],
capture_output=True, text=True, check=False, timeout=60,
)
if result.returncode:
raise AuditError(f"GitHub read failed for {path}: {result.stderr.strip()}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as error:
raise AuditError(f"invalid GitHub JSON for {path}: {error}") from error
def find_release(tag):
releases = []
for page in range(1, 21):
batch = github_json(f"releases?per_page=100&page={page}")
if not isinstance(batch, list):
raise AuditError("GitHub release list is not an array")
releases.extend(batch)
try:
return CHECKSUMS["find_release"](
(json.dumps(item) for item in releases), tag
)
except ValueError as error:
if "found 0" not in str(error):
raise AuditError(str(error)) from error
if len(batch) < 100:
break
raise AuditError(f"no unique release found for {tag}")
def validate_assets(release):
assets = release.get("assets")
expected = set(INSTALLERS) | {"SHA256SUMS"}
if release.get("draft") is False:
expected.add("update.json")
if not isinstance(assets, list) or len(assets) != len(expected):
raise AuditError(f"expected exactly {len(expected)} release assets")
by_name = {asset.get("name"): asset for asset in assets if isinstance(asset, dict)}
if set(by_name) != expected or len(by_name) != len(assets):
raise AuditError("release has missing, duplicate, or unexpected assets")
for name, asset in by_name.items():
if (not isinstance(asset.get("id"), int) or asset["id"] <= 0
or not isinstance(asset.get("size"), int) or asset["size"] <= 0
or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", asset.get("digest") or "")):
raise AuditError(f"{name} has no valid ID, size, or SHA-256 digest")
try:
expected_sums = CHECKSUMS["checksums"](release).encode("ascii")
except ValueError as error:
raise AuditError(str(error)) from error
return by_name, expected_sums
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def download_asset(asset, path):
with path.open("wb") as target:
result = subprocess.run(
["gh", "api", "-H", "Accept: application/octet-stream",
f"repos/{REPO}/releases/assets/{asset['id']}"],
stdout=target, stderr=subprocess.PIPE, check=False, timeout=1800,
)
if result.returncode:
raise AuditError(f"download failed for {asset['name']}: "
f"{result.stderr.decode(errors='replace').strip()}")
if path.stat().st_size != asset["size"]:
raise AuditError(f"{asset['name']} size differs from GitHub metadata")
digest = sha256(path)
if digest != asset["digest"][7:].lower():
raise AuditError(f"{asset['name']} SHA-256 differs from GitHub metadata")
return digest
def validate_update_manifest(path, release):
"""Check the bytes running copies read against the published release."""
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except (UnicodeError, json.JSONDecodeError) as error:
raise AuditError(f"update.json is not valid UTF-8 JSON: {error}") from error
if not isinstance(manifest, dict):
raise AuditError("update.json is not an object")
tag = release.get("tag_name")
if not isinstance(tag, str) or not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
raise AuditError("published release has an invalid tag")
if manifest.get("version") != tag[1:]:
raise AuditError("update.json version differs from the release tag")
page = f"https://github.com/{REPO}/releases/tag/{tag}"
if manifest.get("page") != page:
raise AuditError("update.json release page differs from the tag page")
if manifest.get("notes") != (release.get("body") or "")[:4000]:
raise AuditError("update.json notes differ from the release notes")
expected = {asset["name"]: {key: asset[key] for key in ("name", "size", "digest")}
for asset in release["assets"] if asset["name"] in INSTALLERS}
assets = manifest.get("assets")
if not isinstance(assets, list) or len(assets) != len(expected):
raise AuditError("update.json has an incomplete installer list")
names = [asset.get("name") for asset in assets if isinstance(asset, dict)]
if (len(names) != len(assets) or not all(isinstance(name, str) for name in names)
or len(names) != len(set(names))):
raise AuditError("update.json has invalid or duplicate installer entries")
if {asset["name"]: asset for asset in assets} != expected:
raise AuditError("update.json installers differ from the release assets")
return {"version": manifest["version"], "installers": len(assets)}
def inspect_package(path, name, version):
if name.endswith(".zip"):
with zipfile.ZipFile(path) as archive:
bad = archive.testzip()
if bad is not None:
raise AuditError(f"{name} has a bad ZIP member: {bad}")
detail = {"zipCrc": "ok", "members": len(archive.infolist())}
if "-mac-" in name:
matches = [entry for entry in archive.namelist()
if entry.endswith("Frame Control.app/Contents/Info.plist")]
if len(matches) != 1:
raise AuditError(f"{name} has {len(matches)} app Info.plist files")
found = plistlib.loads(archive.read(matches[0])).get("CFBundleShortVersionString")
if found != version:
raise AuditError(f"{name} bundle version {found!r} is not {version}")
detail["bundleVersion"] = found
return detail
if name.endswith(".deb"):
result = subprocess.run(
["dpkg-deb", "-f", str(path), "Version", "Architecture"],
capture_output=True, text=True, check=False, timeout=60,
)
fields = [line.partition(":")[2].strip() for line in result.stdout.splitlines()]
architecture = "arm64" if "arm64" in name else "amd64"
if result.returncode or fields != [version, architecture]:
raise AuditError(f"{name} DEB version/architecture mismatch: {fields}")
return {"debVersion": fields[0], "architecture": fields[1]}
if name.endswith(".AppImage"):
with path.open("rb") as source:
head = source.read(20)
machine = struct.unpack("<H", head[18:20])[0] if len(head) >= 20 else None
expected = 183 if "arm64" in name else 62
if head[:4] != b"\x7fELF" or machine != expected:
raise AuditError(f"{name} ELF machine {machine!r} is not {expected}")
return {"elfMachine": machine}
if name.endswith(".dmg"):
if path.stat().st_size < 512:
raise AuditError(f"{name} is too small for a UDIF trailer")
with path.open("rb") as source:
source.seek(-512, 2)
trailer = source.read(512)
if trailer[:4] != b"koly":
raise AuditError(f"{name} has no UDIF trailer")
return {"udifTrailer": "ok"}
if name.endswith(".exe"):
with path.open("rb") as source:
head = source.read(64)
if len(head) < 64 or head[:2] != b"MZ":
raise AuditError(f"{name} has no MZ header")
source.seek(struct.unpack("<I", head[60:64])[0])
pe = source.read(6)
machine = struct.unpack("<H", pe[4:6])[0] if len(pe) == 6 else None
if pe[:4] != b"PE\x00\x00" or machine not in (0x14C, 0x8664):
raise AuditError(f"{name} has an invalid PE header or machine")
return {"peMachine": machine}
raise AuditError(f"no package inspection for {name}")
def audit(tag, report_path, launch_x86_appimage=False):
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
raise AuditError("expected a version tag such as v0.4.54")
if report_path.exists():
raise AuditError(f"report already exists; choose a new path: {report_path}")
if not report_path.parent.is_dir():
raise AuditError(f"report directory does not exist: {report_path.parent}")
if not shutil.which("gh") or not shutil.which("dpkg-deb"):
raise AuditError("gh and dpkg-deb are required on the audit host")
if launch_x86_appimage and not shutil.which("xvfb-run"):
raise AuditError("xvfb-run is required for --launch-x86-appimage")
if launch_x86_appimage and platform.machine().lower() not in ("x86_64", "amd64"):
raise AuditError("--launch-x86-appimage requires an x86_64 audit host")
release = find_release(tag)
by_name, expected_sums = validate_assets(release)
with tempfile.TemporaryDirectory(prefix=f"frame-{tag}-assets-") as directory:
scratch = Path(directory)
sums_path = scratch / "SHA256SUMS"
sums_hash = download_asset(by_name["SHA256SUMS"], sums_path)
if sums_path.read_bytes() != expected_sums:
raise AuditError("SHA256SUMS bytes differ from the ten GitHub asset digests")
manifest_detail = None
if not release.get("draft"):
manifest_path = scratch / "update.json"
manifest_digest = download_asset(by_name["update.json"], manifest_path)
manifest_detail = validate_update_manifest(manifest_path, release)
manifest_detail["sha256"] = manifest_digest
rows = []
for name in INSTALLERS:
path = scratch / name
asset = by_name[name]
digest = download_asset(asset, path)
detail = inspect_package(path, name, tag[1:])
if launch_x86_appimage and name == "Frame-Control-linux-x86_64.AppImage":
path.chmod(0o755)
result = subprocess.run(
[sys.executable, str(SCRIPTS / "check-appimage-launch.py"), str(path)],
capture_output=True, text=True, check=False, timeout=120,
)
if result.returncode or digest not in result.stdout:
raise AuditError(f"isolated AppImage launch failed: {result.stderr.strip()}")
detail["appimageLaunch"] = "HTTP 200; FrameControl/1; server stopped"
rows.append({"name": name, "size": asset["size"], "sha256": digest, **detail})
path.unlink()
print(f"verified {name}", flush=True)
report = {"tag": tag, "releaseId": release["id"],
"tagNameAtAudit": release.get("tag_name"), "draftAtAudit": release.get("draft"),
"sha256sumsSha256": sums_hash, "updateManifest": manifest_detail,
"assets": rows}
with report_path.open("x", encoding="utf-8") as output:
json.dump(report, output, indent=2)
output.write("\n")
print(f"verified {len(rows)} installer downloads; report {report_path}")
return report
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("tag", help="release tag, for example v0.4.54")
parser.add_argument("--report", required=True, type=Path, help="new JSON report path")
parser.add_argument("--launch-x86-appimage", action="store_true",
help="also launch the tagged x86_64 AppImage in isolated Xvfb")
args = parser.parse_args()
try:
audit(args.tag, args.report, args.launch_x86_appimage)
except (AuditError, OSError, subprocess.TimeoutExpired,
zipfile.BadZipFile, plistlib.InvalidFileException) as error:
parser.exit(1, f"asset audit failed: {error}\n")
if __name__ == "__main__":
main()
+2 -2
View File
@@ -40,7 +40,7 @@ def render(data):
'python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives',
'```', '',
'Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.', '',
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.', '',
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. A later [native Decky 3.3.0 check](evidence/framey-free-loader-native-2026-10-28.md) verified plugin discovery, default settings, empty local deals and private setting persistence after restart in a network-isolated amd64 container. Live feed updates, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified.', '',
'A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck\'s advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.', '',
'A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.', '',
'A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.', '',
@@ -131,7 +131,7 @@ def render(data):
'A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. '
'Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. '
'Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. '
'The plugin moves from broken to partial: its frontend advertises HTTP/steamdeck URLs and needs a Steam folder picker. Use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.', '',
'The plugin remains partial. A [frontend URL follow-up](evidence/framey-fileserver-url-2026-10-28.md) changes only the exact managed bundle to display the backend-returned IP with HTTPS, matching the helper. Existing managed copies need update or reinstall. The Steam folder picker is still unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.', '',
'[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) '
'covers an isolated real-Frame protocol 2.0 info response from an earlier helper build. '
'[Exact official source matching](evidence/framey-localsend-source-match-2026-10-27.md) '
+2 -1
View File
@@ -41,7 +41,8 @@ def check_coverage():
shims.add(node.args[0].value)
cases = json.loads((ROOT/'tests/decky-runtime/cases.json').read_text())
# Frontend-only compatibility probes need no archive/backend adaptation.
if shims != {case['shim'] for case in cases if case['shim'] is not None}:
covered = {shim for case in cases for shim in ([case['shim']] if case['shim'] else []) + case.get('additionalShims', [])}
if shims != covered:
raise ValueError('Every managed shim needs an actual Decky runtime case')
return cases
+17 -1
View File
@@ -139,7 +139,23 @@ for asset in $old; do
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
done
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
-H "Content-Type: application/json" --input "$tmp/update.json" > "$tmp/uploaded-manifest.json"
asset_id=$(python3 - "$tmp/uploaded-manifest.json" "$tmp/update.json" <<'EOF'
import hashlib, json, pathlib, sys
asset = json.load(open(sys.argv[1], encoding="utf-8"))
body = pathlib.Path(sys.argv[2]).read_bytes()
digest = "sha256:" + hashlib.sha256(body).hexdigest()
if (asset.get("name") != "update.json" or not isinstance(asset.get("id"), int)
or asset["id"] <= 0 or asset.get("size") != len(body)
or asset.get("digest") != digest):
sys.exit("not publishing: uploaded update.json metadata differs from its source bytes")
print(asset["id"])
EOF
)
gh api -H "Accept: application/octet-stream" "repos/$repo/releases/assets/$asset_id" > "$tmp/update-readback.json"
cmp -s "$tmp/update.json" "$tmp/update-readback.json" || {
echo "not publishing: downloaded update.json differs from its source bytes" >&2; exit 1;
}
echo "ok update.json"
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -F draft=false -F prerelease=false \
+9 -1
View File
@@ -14,6 +14,11 @@
"shim": null,
"probe": "toggle-settings"
},
{
"id": 63,
"shim": null,
"probe": "free-loader"
},
{
"id": 12,
"shim": "music-control-system-dbus-env",
@@ -42,7 +47,10 @@
{
"id": 60,
"shim": "deckyfileserver-user-native",
"probe": "fileserver"
"probe": "fileserver",
"additionalShims": [
"deckyfileserver-https-address"
]
},
{
"id": 125,
+26 -1
View File
@@ -90,7 +90,8 @@ def main():
plugin = base/'plugins'/record['folder']
receipt = json.loads((plugin/'.frame-control-store.json').read_text())
if not args.reproduce_timer_regression:
assert receipt.get('compatShims', []) == ([case['shim']] if case['shim'] else []), receipt
expected_shims = ([case['shim']] if case['shim'] else []) + case.get('additionalShims', [])
assert receipt.get('compatShims', []) == expected_shims, receipt
else:
assert record['id'] == 96
with zipfile.ZipFile(io.BytesIO(data)) as original:
@@ -240,6 +241,30 @@ def main():
report['result'] = ('original empty argument bug reproduced: thumbnail policy ignored'
if args.reproduce_fileserver_empty_flag_regression else
'discovery, API1 settings, native HTTPS listing/download, thumbnail policy and stop passed; uploads disabled')
elif case['probe'] == 'free-loader':
settings = current('settings_read')
assert settings['notify_on_free_games'] is True, settings
assert settings['larger_text'] is False, settings
assert current('read_deals') == {}
# This plugin commits defaults on startup and writes an empty
# deals cache on first read. Both paths are private to this
# disposable container; no feed, notification or store call.
settings_dir = base/'settings'/record['folder']
assert (settings_dir/'settings.json').is_file()
assert (settings_dir/'deal_db.json').is_file()
current('settings_setSetting', 'larger_text', True)
assert current('settings_read')['larger_text'] is True
os.killpg(backend.pid, signal.SIGTERM)
backend.wait(timeout=8)
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log,
start_new_session=True)
processes.append(backend)
until_ready(lambda: rpc('loader/get_plugins'), 'restarted Decky discovery')
assert until_ready(lambda: current('settings_read')['larger_text'],
'private Free Loader setting after restart') is True
assert current('read_deals') == {}
report['result'] = ('discovery, default settings, empty local deals, '
'private setting write/read and restart persistence passed; feed, notifications and VR unverified')
elif case['probe'] == 'toggle-settings':
def call(method, *values):
result = rpc('loader/call_plugin_method', record['name'], method, *values)
+14 -3
View File
@@ -9,7 +9,8 @@ import subprocess
import threading
import zipfile
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, reset, ssh
import frame_decky_store
from framey_official import LEGACY_PLUGINS, PLUGINS as PANEL_PLUGINS, archive as official_archive
OFFICIAL_PLUGINS = PANEL_PLUGINS + LEGACY_PLUGINS
@@ -99,6 +100,13 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
input=source, capture_output=True, timeout=20)
self.assertEqual(copy.returncode, 0, copy.stderr)
def test_fake_frame_reset_clears_managed_decky_state(self):
ssh('mkdir -p ~/.local/share/frame-control/decky/plugins/test-leftover ~/.config/systemd/user')
ssh('touch ~/.config/systemd/user/frame-control-decky.service')
reset()
self.assertEqual(ssh('test -e ~/.local/share/frame-control/decky && echo yes || echo no').strip(), 'no')
self.assertEqual(ssh('test -e ~/.config/systemd/user/frame-control-decky.service && echo yes || echo no').strip(), 'no')
def test_store_install_update_remove_and_guards(self):
self.backend()
status, _, _ = api('GET', '/api/decky/store', headers={'X-Frame-UI':''})
@@ -190,6 +198,9 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
installed = f"~/.local/share/frame-control/decky/plugins/{plugin['folder']}"
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
if plugin['id'] == 60:
# The pinned managed bundle rewrites the two incorrect addresses.
expected = '05819ddd0ee9c3ce7eeb184977a2f7905d7c8f5cfaec8bdff837d7935985a0e7'
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
if plugin['id'] == 96:
@@ -205,8 +216,8 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
if plugin['id'] == 60:
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], [])
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['deckyfileserver-user-native'])
import frame_decky_store
self.assertEqual(receipt['compatShims'],
['deckyfileserver-user-native', 'deckyfileserver-https-address'])
arch = ssh('uname -m').strip()
self.assertEqual(ssh(f'sha256sum {installed}/bin/backend').split()[0],
frame_decky_store.FILESERVER_BINARIES[arch])
@@ -304,6 +304,8 @@ def clean_home():
pass
for pattern in (fs.DEVKIT_GAMES + '/*', fs.DEVKIT_GAMES + '/.[!.]*', HOME + '/devkit-utils',
HOME + '/.devkit-utils.frame-control', HOME + '/Applications', HOME + '/Downloads/*',
HOME + '/.local/share/frame-control',
HOME + '/.config/systemd/user/frame-control-decky.service',
fs.STEAM_ROOT + '/steamapps/compatdata', fs.STEAM_ROOT + '/steamapps/shadercache',
fs.STEAM_ROOT + '/logs', '/var/log/fakeframe/devkit-*', '/var/log/fakeframe/shortcut-*'):
for p in glob.glob(pattern):
+11 -3
View File
@@ -9,6 +9,7 @@ $FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's li
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
(raw strings, compact JSON)."""
import json
import hashlib
import os
import shutil
import subprocess
@@ -120,10 +121,17 @@ elif method == "POST" and endpoint == repo + "releases":
answer(new, json.dumps(new))
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
pass
elif method == "GET" and endpoint == repo + "releases/assets/901":
data = open(os.environ["FAKEGH_UPLOAD"], "rb").read()
if os.environ.get("FAKEGH_CORRUPT_MANIFEST_READBACK") == "1":
data += b" "
sys.stdout.buffer.write(data)
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
dst.write(src.read())
print("{}")
data = open(inp, "rb").read()
with open(os.environ["FAKEGH_UPLOAD"], "wb") as dst:
dst.write(data)
print(json.dumps({"id": 901, "name": "update.json", "size": len(data),
"digest": "sha256:" + hashlib.sha256(data).hexdigest()}))
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
rid = endpoint[len(repo + "releases/"):]
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(process.argv[2], 'utf8');
const testSource = source.replace(/export \{ index as default \};/, 'globalThis.__Content = Content;');
assert.notEqual(testSource, source, 'official module export must be present');
const DFL = new Proxy({
definePlugin: factory => factory(),
staticClasses: {Title: 'title'},
}, {get(target, name) { return target[name] ?? name; }});
const createElement = (type, props, ...children) => ({type, props: props ?? {}, children});
for (const allow_uploads of [false, true]) {
const SP_REACT = {
createElement,
createContext: value => ({value}),
useState: initial => [{...initial, server_running: true,
ip_address: '192.0.2.5', port: 8421, allow_uploads}, () => {}],
useEffect() {},
};
const window = {SP_REACT,
__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit: {
connect: () => ({_version: 2, callable: () => async () => ({}), openFilePicker: async () => ({})}),
},
};
const context = vm.createContext({window, SP_REACT, DFL, setInterval() {}, clearInterval() {}});
vm.runInContext(testSource, context);
const tree = context.__Content();
const fields = [];
function visit(value) {
if (!value || typeof value !== 'object') return;
if (value.type === DFL.Field) fields.push(value);
for (const child of value.children ?? []) visit(child);
}
visit(tree);
const addresses = fields.filter(field => field.props.label === 'Server Address');
assert.equal(addresses.length, 1);
assert.equal(addresses[0].children.join(''), 'https://192.0.2.5:8421');
assert.equal(fields.filter(field => field.children.join('').includes('steamdeck')).length, 0);
}
+3 -3
View File
@@ -28,8 +28,8 @@ 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 every step passed, 1 a step failed or was skipped, 2 the
headset isn't reachable. A skipped launch is not a release smoke pass.
"""
import argparse
import json
@@ -425,7 +425,7 @@ def run_smoke(args, before):
'passed': passed, 'skipped': skipped, 'failed': failed,
'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
return 0 if failed == 0 and skipped == 0 else 1
def main():
+172
View File
@@ -0,0 +1,172 @@
"""Independent release audit rejects incomplete metadata and wrong package versions."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import hashlib
import importlib.util
import json
import plistlib
import struct
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "audit-release-assets.py"
spec = importlib.util.spec_from_file_location("audit_release_assets", SCRIPT)
audit = importlib.util.module_from_spec(spec)
spec.loader.exec_module(audit)
def release_assets():
assets = []
for number, name in enumerate(audit.INSTALLERS, 1):
assets.append({
"id": number, "name": name, "size": number,
"digest": "sha256:" + hashlib.sha256(str(number).encode()).hexdigest(),
})
sums = audit.CHECKSUMS["checksums"]({"assets": assets}).encode("ascii")
assets.append({
"id": 99, "name": "SHA256SUMS", "size": len(sums),
"digest": "sha256:" + hashlib.sha256(sums).hexdigest(),
})
return assets, sums
class AssetAudit(unittest.TestCase):
def test_published_manifest_matches_release_bytes(self):
assets, _ = release_assets()
release = {"tag_name": "v9.8.7", "body": "Release notes", "assets": assets}
manifest = {
"version": "9.8.7",
"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7",
"notes": "Release notes",
"assets": [{key: asset[key] for key in ("name", "size", "digest")}
for asset in assets if asset["name"] in audit.INSTALLERS],
}
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "update.json"
path.write_text(json.dumps(manifest), encoding="utf-8")
self.assertEqual(audit.validate_update_manifest(path, release),
{"version": "9.8.7", "installers": 10})
for change in (
{"version": "9.8.6"},
{"page": "https://github.com/elsewhere/releases/tag/v9.8.7"},
{"notes": "stale notes"},
{"assets": manifest["assets"][:-1]},
{"assets": [dict(manifest["assets"][0], digest="sha256:" + "0" * 64),
*manifest["assets"][1:]]},
):
with self.subTest(change=change):
path.write_text(json.dumps({**manifest, **change}), encoding="utf-8")
with self.assertRaises(audit.AuditError):
audit.validate_update_manifest(path, release)
def test_complete_metadata_uses_release_checksum_order(self):
assets, sums = release_assets()
by_name, expected = audit.validate_assets({"assets": assets})
self.assertEqual(len(by_name), 11)
self.assertEqual(expected, sums)
def test_published_release_requires_update_manifest(self):
assets, sums = release_assets()
manifest = {"id": 100, "name": "update.json", "size": 20,
"digest": "sha256:" + "a" * 64}
with self.assertRaises(audit.AuditError):
audit.validate_assets({"draft": False, "assets": assets})
by_name, expected = audit.validate_assets({"draft": False, "assets": [*assets, manifest]})
self.assertEqual(len(by_name), 12)
self.assertEqual(expected, sums)
def test_missing_duplicate_and_unhashed_assets_fail(self):
assets, _ = release_assets()
variants = (
assets[:-1],
[*assets, assets[0]],
[dict(assets[0], digest="sha256:bad"), *assets[1:]],
[dict(assets[0], size=0), *assets[1:]],
)
for changed in variants:
with self.subTest(changed=changed[0]), self.assertRaises(audit.AuditError):
audit.validate_assets({"assets": changed})
def test_mac_zip_bundle_version_must_match_tag(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "mac.zip"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(
"Frame Control.app/Contents/Info.plist",
plistlib.dumps({"CFBundleShortVersionString": "9.8.7"}),
)
self.assertEqual(
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.7")["bundleVersion"],
"9.8.7",
)
with self.assertRaises(audit.AuditError):
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.8")
def test_appimage_machine_must_match_name(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "image"
path.write_bytes(b"\x7fELF" + bytes(14) + struct.pack("<H", 183))
self.assertEqual(
audit.inspect_package(path, "Frame-Control-linux-arm64.AppImage", "9.8.7"),
{"elfMachine": 183},
)
with self.assertRaises(audit.AuditError):
audit.inspect_package(path, "Frame-Control-linux-x86_64.AppImage", "9.8.7")
def test_existing_report_is_never_overwritten(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "report.json"
path.write_text("keep")
with self.assertRaisesRegex(audit.AuditError, "already exists"):
audit.audit("v9.8.7", path)
self.assertEqual(path.read_text(), "keep")
def test_missing_report_directory_fails_before_download(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "missing" / "report.json"
with self.assertRaisesRegex(audit.AuditError, "directory does not exist"):
audit.audit("v9.8.7", path)
def test_bad_downloaded_checksum_file_creates_no_report(self):
assets, _ = release_assets()
release = {"id": 42, "tag_name": "v9.8.7", "draft": True, "assets": assets}
def bad_download(asset, path):
path.write_bytes(b"wrong checksum file\n")
return asset["digest"][7:]
with tempfile.TemporaryDirectory() as directory:
report = Path(directory) / "report.json"
with mock.patch.object(audit, "find_release", return_value=release), \
mock.patch.object(audit, "download_asset", side_effect=bad_download), \
mock.patch.object(audit.shutil, "which", return_value="/fake/tool"):
with self.assertRaisesRegex(audit.AuditError, "SHA256SUMS bytes differ"):
audit.audit("v9.8.7", report)
self.assertFalse(report.exists())
def test_bad_published_manifest_creates_no_report(self):
assets, sums = release_assets()
assets.append({"id": 100, "name": "update.json", "size": 2,
"digest": "sha256:" + hashlib.sha256(b"{}").hexdigest()})
release = {"id": 42, "tag_name": "v9.8.7", "draft": False,
"body": "Release notes", "assets": assets}
def download(asset, path):
path.write_bytes(sums if asset["name"] == "SHA256SUMS" else b"{}")
return asset["digest"][7:]
with tempfile.TemporaryDirectory() as directory:
report = Path(directory) / "report.json"
with mock.patch.object(audit, "find_release", return_value=release), \
mock.patch.object(audit, "download_asset", side_effect=download), \
mock.patch.object(audit.shutil, "which", return_value="/fake/tool"):
with self.assertRaisesRegex(audit.AuditError, "version differs"):
audit.audit("v9.8.7", report)
self.assertFalse(report.exists())
if __name__ == "__main__":
unittest.main()
+5 -1
View File
@@ -228,12 +228,16 @@ class StoreTransaction(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
plugin = self.folder.parent/record['folder']
self.assertEqual(json.loads((plugin/'plugin.json').read_text())['flags'], [])
self.assertEqual(json.loads((plugin/'.frame-control-store.json').read_text())['compatShims'], ['deckyfileserver-user-native'])
self.assertEqual(json.loads((plugin/'.frame-control-store.json').read_text())['compatShims'],
['deckyfileserver-user-native', 'deckyfileserver-https-address'])
helper = plugin/'bin/backend'
self.assertEqual(hashlib.sha256(helper.read_bytes()).hexdigest(), frame_decky_store.FILESERVER_BINARIES[platform.machine()])
self.assertEqual(helper.stat().st_mode & 0o777, 0o700)
compile((plugin/'main.py').read_text(), str(plugin/'main.py'), 'exec')
self.assertIn('if arg]', (plugin/'main.py').read_text())
rendered = subprocess.run(['node', str(Path(__file__).parent/'page/framey_fileserver_url.mjs'),
str(plugin/'dist/index.js')], capture_output=True, text=True, timeout=20)
self.assertEqual(rendered.returncode, 0, rendered.stderr)
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux helper architecture')
def test_exact_localsend_gets_verified_native_helper_and_preserves_source(self):
+10
View File
@@ -80,6 +80,16 @@ class Compatibility(unittest.TestCase):
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
self.assertEqual(result[1]['compatibility']['status'], 'works')
def test_fileserver_old_copy_requires_https_address_update(self):
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 60)
old = dict(storeId=60, version=plugin['version'], sha256=plugin['sha256'],
compatShims=['deckyfileserver-user-native'])
updated = dict(old, compatShims=['deckyfileserver-user-native', 'deckyfileserver-https-address'])
result = frame_decky.status(lambda *a, **k: json.dumps({'plugins': [old, updated]}))['plugins']
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
self.assertNotIn('Update or reinstall', result[1]['compatibility']['reason'])
self.assertEqual([p['compatibility']['status'] for p in result], ['partial', 'partial'])
def test_missing_audit_data_is_unverified(self):
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
self.assertEqual(compatibility.lookup(12, 'a'*64, '1.0')['status'], 'unverified')
+1 -1
View File
@@ -21,7 +21,7 @@ class RuntimeCoverage(unittest.TestCase):
self.assertIn(case['id'], official)
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
'toggle-settings', 'fileserver', 'localsend', 'network-sentinel',
'battery-tracker'))
'battery-tracker', 'free-loader'))
def test_new_shim_without_runtime_case_fails_coverage(self):
import frame_decky_store
+14 -2
View File
@@ -37,14 +37,16 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
def run_script(self, releases, *args, tags="v9.8.7", gates=None,
corrupt_manifest_readback=False):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
(d / "gates.json").write_text(json.dumps(gates or {}))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
FAKEGH_GATES=str(d / "gates.json"))
FAKEGH_GATES=str(d / "gates.json"),
FAKEGH_CORRUPT_MANIFEST_READBACK="1" if corrupt_manifest_readback else "0")
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
@@ -60,11 +62,21 @@ class PublishRelease(unittest.TestCase):
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
patch = next(c for c in log if "PATCH" in c)
readback = next(c for c in log if any("releases/assets/901" in arg for arg in c))
self.assertLess(log.index(readback), log.index(patch))
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
self.assertIn(f, patch)
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
def test_refuses_mismatched_manifest_readback_before_publication(self):
p, log, upload = self.run_script([draft()], "v9.8.7",
corrupt_manifest_readback=True)
self.assertNotEqual(p.returncode, 0)
self.assertIn("downloaded update.json differs", p.stderr)
self.assertIsNotNone(upload)
self.assertFalse(any("PATCH" in call for call in log))
def test_an_untagged_draft_may_carry_a_subtitle(self):
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
+2 -2
View File
@@ -134,7 +134,7 @@ class Safety(unittest.TestCase):
launch.assert_called_once_with(gid)
remove.assert_called_once_with(gid)
def test_optional_runtime_skip_is_counted_separately_in_saved_result(self):
def test_optional_runtime_skip_fails_gate_and_is_counted_in_saved_result(self):
with tempfile.TemporaryDirectory() as root:
output = Path(root) / 'results'
folder = Path(root) / 'titles'
@@ -156,7 +156,7 @@ class Safety(unittest.TestCase):
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)
{'activity': 3, 'proximity': [], 'battery': [100]}), 1)
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')
+31
View File
@@ -385,7 +385,38 @@ try:
new=' if "disable_thumbnails" in status:\n await Plugin.set_disable_thumbnails(self, status["disable_thumbnails"])\n if "server_running" in status:\n await Plugin.set_server_running(self, status["server_running"])'
if old not in text: raise RuntimeError('DeckyFileServer start configuration changed')
source.write_text(text.replace(old,new))
# The helper always calls ListenAndServeTLS. The published panel
# prints HTTP when uploads are disabled and hard-codes a Deck
# hostname that the Frame does not provide. Keep only the
# actual address returned by this backend.
frontend=unpacked/'dist/index.js'
original=frontend.read_bytes()
if hashlib.sha256(original).hexdigest()!='791015d8b3f234fad89ce422b09fb32bbad05e7c867d501706cfbcf9f7c6187f':
raise RuntimeError('DeckyFileServer frontend source changed')
bundle=original.decode('utf-8')
old_url=""" window.SP_REACT.createElement(DFL.Field, { inlineWrap: "shift-children-below", label: "Server Address", bottomSeparator: "none" },
"http",
state.allow_uploads ? "s" : "",
"://steamdeck:",
state.port),
window.SP_REACT.createElement(DFL.Field, { inlineWrap: "shift-children-below" },
"http",
state.allow_uploads ? "s" : "",
"://",
state.ip_address,
":",
state.port),
"""
new_url=""" window.SP_REACT.createElement(DFL.Field, { inlineWrap: "shift-children-below", label: "Server Address", bottomSeparator: "none" },
"https://",
state.ip_address,
":",
state.port),
"""
if bundle.count(old_url)!=1: raise RuntimeError('DeckyFileServer address UI changed')
frontend.write_text(bundle.replace(old_url,new_url),encoding='utf-8')
compat_shims.append('deckyfileserver-user-native')
compat_shims.append('deckyfileserver-https-address')
# Simple Timer 1.0.0 has annotation-only task state, orphaned
# replacement tasks and canceled deadlines that resume on restart.
# Preserve its API1 instance convention and upstream license.
+6 -4
View File
@@ -4349,7 +4349,7 @@
"https://github.com/grimkor/DeckyFileServer"
],
"status": "partial",
"reason": "Managed install supplies a native ARM64 helper and removes the exact archive's unnecessary root request. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. Frontend address/picker limitations remain; use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Shared Toggle value rendering is repaired; other listed blockers remain.",
"reason": "Managed install supplies a native ARM64 helper, removes the exact archive's unnecessary root request, and displays the backend-returned IP with HTTPS. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. The Steam folder picker remains unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Existing managed copies need update or reinstall for the address fix.",
"blockInstall": false,
"verification": [
"static archive",
@@ -4385,7 +4385,8 @@
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"requiredShims": [
"deckyfileserver-user-native"
"deckyfileserver-user-native",
"deckyfileserver-https-address"
],
"managedNative": {
"shim": "deckyfileserver-user-native",
@@ -4703,14 +4704,15 @@
"https://github.com/jwhitlow45/free-loader.git"
],
"status": "partial",
"reason": "The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified.",
"reason": "The exact archive's byte-identical duplicate ZIP members pass guarded install/remove. Actual Decky 3.3.0 in a private network-isolated container discovers the plugin, reads default settings and empty local deals, and retains a settings change after restart. Live deal-feed refresh, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified; no feed request was made.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame pinned install/remove",
"local exact-archive transaction",
"isolated Python backend"
"isolated Python backend",
"isolated Linux backend"
],
"browser": {
"id": 63,