mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-29 00:02:03 +01:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb77fe1d2f | ||
|
|
c733fab6ae | ||
|
|
2fcf50e59a | ||
|
|
e4ce91d686 | ||
|
|
a1a376b4ee | ||
|
|
fe8d94b00b | ||
|
|
05be1e2c13 | ||
|
|
e0bdaebe4f | ||
|
|
d977bc3d54 | ||
|
|
d12071a1e7 | ||
|
|
2aa77b30d6 | ||
|
|
a1bc084400 | ||
|
|
978af2f287 | ||
|
|
fbeb24d754 | ||
|
|
dc139633a9 | ||
|
|
85c0bf542c | ||
|
|
0f421bfd2d | ||
|
|
27b5bf27f3 | ||
|
|
c33dc0ccb6 | ||
|
|
78634c09a9 | ||
|
|
5a83989138 | ||
|
|
b5bbcec1a0 | ||
|
|
bd36929053 |
No files matched your search
Generated
+2
-2
@@ -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
@@ -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,11 +1,11 @@
|
||||
# What we have tested
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
| 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 | 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) |
|
||||
| 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) |
|
||||
| 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.41**, 26 October 2026. Rows also identify
|
||||
| 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 | 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) |
|
||||
| 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) |
|
||||
|
||||
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,31 @@
|
||||
# Large Send to Frame files: source and offline verification
|
||||
|
||||
[Issue #373](https://github.com/saphid/frame-control/issues/373) reports that
|
||||
a 26.5 GB file selected from external USB media failed with “empty or
|
||||
too-large upload” in Frame Control 0.4.8. Current main still imposed an
|
||||
8 GiB `POST /api/upload` body cap for **Send to Frame**, even in the desktop
|
||||
app. The desktop already knows the selected local file path, but previously
|
||||
sent the whole file through HTTP into another temporary copy before SSH.
|
||||
|
||||
The desktop page now passes that path to `POST /api/transfer`, which is
|
||||
enabled in the app-started server. It checks for an absolute, nonempty
|
||||
regular file and copies it directly by the
|
||||
existing rsync/scp route to `~/Downloads`. This avoids both the 8 GiB HTTP
|
||||
cap and a duplicate temporary file on the computer. The endpoint preserves
|
||||
the selected-headset work guard. Large copies have a finite timeout that
|
||||
scales with source size. Plain browser uploads still have an 8 GiB cap and
|
||||
now state it before sending bytes; the page says which route applies.
|
||||
|
||||
Offline checks use a mocked 27 GiB file size without allocating that space.
|
||||
They verify the direct server copy path, a timeout longer than one hour, the
|
||||
page's desktop-versus-browser choice, and early rejection of an oversized
|
||||
browser upload. Loopback tests confirm the browser cannot call the direct
|
||||
route, reject a mismatched selected headset with HTTP 409, and accept the
|
||||
matching desktop request with HTTP 200. Unit tests reject missing, relative,
|
||||
directory, hidden and empty source paths before any SSH. The full Python
|
||||
suite on the final branch passed **1,690 tests, 16 skipped**. Hosted CI is a
|
||||
separate merge gate.
|
||||
|
||||
An actual 26.5 GB transfer from USB media to a Frame, end-to-end throughput,
|
||||
resume after interruption and free-space behavior remain unverified. No real
|
||||
headset or user file was touched for this change.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Main Windows test gate, 28 October 2026
|
||||
|
||||
Exact main commit `b5bbcec1a0cc3f8b3d25ef2686fd8315e209f167`
|
||||
([checks run 40723931638](https://github.com/saphid/frame-control/actions/runs/40723931638))
|
||||
failed in `server-tests (windows-latest, 3.12)`. Its server test step had one
|
||||
error: `test_settings_need_and_accept_the_ui_key` received Windows
|
||||
`ConnectionAbortedError [WinError 10053]` while reading an unauthorized
|
||||
POST response. The test sent a JSON body, but the missing-key guard rejects
|
||||
the request before reading it. Closing a connection with an unread body can
|
||||
reset it on Windows; that is the likely cause, rather than a settings write
|
||||
failure. The other Windows tests completed, and the exact-main native Decky
|
||||
runtime run passed.
|
||||
|
||||
The test now sends an empty POST when checking that a missing key is refused.
|
||||
Its subsequent authorized POST still sends a settings body and verifies the
|
||||
saved value. The local `tests/test_server.py` module passed 45 tests after
|
||||
this change. Hosted Windows CI on the repair commit and green exact-main CI
|
||||
remain required before further merges.
|
||||
@@ -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.52",
|
||||
"releaseId": 426854020,
|
||||
"tagNameAtAudit": "v0.4.52",
|
||||
"draftAtAudit": true,
|
||||
"sha256sumsSha256": "71306c21818a8842f60633540a16457e962e9ffc7fac01a547a5a2fd17f32904",
|
||||
"assets": [
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.dmg",
|
||||
"size": 177628410,
|
||||
"sha256": "2dad765390965eaa01ab9a1737bffddcb6c8d481e4d255322e663bcdb7ad1e30",
|
||||
"udifTrailer": "ok"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.zip",
|
||||
"size": 177451653,
|
||||
"sha256": "abb17c3e3de2ada967d10b7e277c9f122fb731108efc6d70fed9b87e92e66106",
|
||||
"zipCrc": "ok",
|
||||
"members": 1673,
|
||||
"bundleVersion": "0.4.52"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.dmg",
|
||||
"size": 184701118,
|
||||
"sha256": "1317ee7b30de46bc69a19d42d68245dbf55ca02fbc6ceee5761534ddb8706bab",
|
||||
"udifTrailer": "ok"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.zip",
|
||||
"size": 184538083,
|
||||
"sha256": "4d0fbf1904073a7b28d0e968013bd7ca34600536538db32d00b03cf6aaf5b3b4",
|
||||
"zipCrc": "ok",
|
||||
"members": 1669,
|
||||
"bundleVersion": "0.4.52"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-Setup-x64.exe",
|
||||
"size": 151885028,
|
||||
"sha256": "bb190c20abb2fae1878b732819ae64c214c0cfd627c3a6279b25e9d9c0517740",
|
||||
"peMachine": 332
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-win-x64.zip",
|
||||
"size": 198840260,
|
||||
"sha256": "ef7d248967bda011951bd703972f3a1cb00a43e484af0daf58a5f754e2f2486c",
|
||||
"zipCrc": "ok",
|
||||
"members": 1517
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-x86_64.AppImage",
|
||||
"size": 179997222,
|
||||
"sha256": "6241e068222a2c05f41946e94dedb1456dce25ae94d4857bcb7a4d1891feac7d",
|
||||
"elfMachine": 62
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.AppImage",
|
||||
"size": 171579364,
|
||||
"sha256": "27da455144f8c88ba6a56d6eed90e1806c098d5f6806790089513ba77ec79f52",
|
||||
"elfMachine": 183
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-amd64.deb",
|
||||
"size": 146749772,
|
||||
"sha256": "97425cbd4d460460b9f0574519bbf11afa1b8cc8003e1b784d6c002244da4075",
|
||||
"debVersion": "0.4.52",
|
||||
"architecture": "amd64"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.deb",
|
||||
"size": 133832104,
|
||||
"sha256": "58c98ed203caca60ce022c90232c13591d11c72ff5b28c5860aa7b221ec3e918",
|
||||
"debVersion": "0.4.52",
|
||||
"architecture": "arm64"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# v0.4.52 tagged release verification, 28 October 2026
|
||||
|
||||
The annotated `v0.4.52` tag dereferences to merged main commit
|
||||
`ac04407bcc799e9e88ce31c3f602020e9f3b1cee`. Its tree
|
||||
`864fde2ec006afe00eb886279af9361a6e3b657e` exactly matches reviewed
|
||||
[candidate PR #375](https://github.com/saphid/frame-control/pull/375) head
|
||||
`432b29f9618d1e300abdf8437a2bd8360d54cffb`. The candidate's locked
|
||||
source smoke and cleanup are recorded in [its evidence](v0452-candidate-2026-10-28.md).
|
||||
|
||||
## Exact commit and tagged build gates
|
||||
|
||||
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40714882563)
|
||||
passed on the tag commit: all seven jobs on the first attempt, including
|
||||
e2e, iOS, Windows, macOS, Linux and the aggregate check.
|
||||
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40714882617)
|
||||
passed on the tag commit: both published Decky 3.3.0 AMD64 and ARM64 jobs
|
||||
on the first attempt.
|
||||
- The [exact-tag release workflow](https://github.com/saphid/frame-control/actions/runs/40717151326)
|
||||
passed all six jobs 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 unpublished draft held exactly ten installers and
|
||||
`SHA256SUMS`. Every installer was independently downloaded by GitHub asset
|
||||
ID. Each downloaded size and SHA-256 matched the asset metadata; the
|
||||
downloaded `SHA256SUMS` matched all ten entries byte for byte. Its SHA-256 was
|
||||
`71306c21818a8842f60633540a16457e962e9ffc7fac01a547a5a2fd17f32904`.
|
||||
The [sanitized asset audit](v0452-assets-2026-10-28.json) records all ten
|
||||
names, sizes, hashes and package checks. It checked both Mac ZIP CRCs and
|
||||
bundle version `0.4.52`, both DMG UDIF trailers, Windows ZIP CRC and PE
|
||||
installer header, both AppImage ELF architectures, and both DEB version and
|
||||
architecture fields.
|
||||
|
||||
The independently downloaded Linux x86_64 AppImage had SHA-256
|
||||
`6241e068222a2c05f41946e94dedb1456dce25ae94d4857bcb7a4d1891feac7d`.
|
||||
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.
|
||||
|
||||
## Real Frame and publication
|
||||
|
||||
The [candidate evidence](v0452-candidate-2026-10-28.md) records a unique-lock
|
||||
real Frame smoke on the version-bumped source: **12 passed, one optional
|
||||
x86_64 launch skipped, zero failed**. Steam Linux Runtime 4 was absent, so
|
||||
no x86_64 launch is claimed. A separate fresh-lock audit found no reserved
|
||||
smoke title files, metadata or owned processes; both locks were released.
|
||||
SteamVR activity was 3 (standby), proximity raw 2.3125 and battery 100%.
|
||||
There was no wearer confirmation, wake command or worn interaction test.
|
||||
The private smoke result's SHA-256 is
|
||||
`42ea66740d001cc097ce9e871be6b01be1a10f184ab4480019b0a306851cfc57`.
|
||||
|
||||
`scripts/publish-release.sh --dry-run v0.4.52` passed exact-main, native
|
||||
runtime and tagged release jobs, all ten installer hashes, regenerated
|
||||
`SHA256SUMS`, draft notes and update-manifest checks. Its local log had
|
||||
SHA-256 `d4264b5ad3106387b63d3fc800f558c912ff36b546ea83d792a5a0e8a4949cc6`.
|
||||
The real publisher repeated those gates, attached `update.json`, and published
|
||||
[v0.4.52](https://github.com/saphid/frame-control/releases/tag/v0.4.52) at
|
||||
**15:52:29 UTC**. GitHub's latest-release API returned `draft=false`, ten
|
||||
installers, `SHA256SUMS` and `update.json`. A fresh public latest-download of
|
||||
`update.json` reported version `0.4.52`, the correct release page, ten
|
||||
names, sizes and hashes matching the independent downloads, and the explicit
|
||||
limit that no person wore the headset. The public manifest's SHA-256 was
|
||||
`486bafc8ae4f1feed0f3a1db28b5034498eed78af2a11083be48175489e6d461`.
|
||||
|
||||
The exact-version Decky catalog is **6 works / 54 partial / 45 broken /
|
||||
11 N/A**. Network Sentinel and Battery Tracker remain **partial**. Their
|
||||
real managed ARM64 operation and wearer-visible behavior are unverified.
|
||||
Worn VR visibility and physical-controller interaction also remain
|
||||
unverified. The separate hourly worn check remains enabled.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+16
-2
@@ -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.
|
||||
|
||||
|
||||
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/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 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")
|
||||
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, "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()
|
||||
@@ -0,0 +1,33 @@
|
||||
// Exercise the real page functions for desktop direct copy and browser size guard.
|
||||
import { readFileSync } from "fs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
|
||||
const lines = src.split("\n");
|
||||
function grab(name) {
|
||||
const start = lines.findIndex(line => line.startsWith(`function ${name}(`));
|
||||
assert.notEqual(start, -1, `${name} exists`);
|
||||
let depth = 0;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
|
||||
if (depth === 0) return lines.slice(start, i + 1).join("\n");
|
||||
}
|
||||
throw new Error(`${name} is unbalanced`);
|
||||
}
|
||||
|
||||
const calls = [];
|
||||
const copy = new Function("api", "upload", grab("copyToDownloads") + "\nreturn copyToDownloads;")(
|
||||
async (...args) => { calls.push(["api", ...args]); return { message: "sent" }; },
|
||||
async (...args) => { calls.push(["upload", ...args]); return { message: "uploaded" }; });
|
||||
const large = { name: "usb-file.bin", size: 27 * 1024 ** 3 };
|
||||
assert.deepEqual(await copy(large, "/media/usb/usb-file.bin", "frame-a"), { message: "sent" });
|
||||
assert.deepEqual(calls.pop(), ["api", "/api/transfer", { action: "copy-local", path: "/media/usb/usb-file.bin" }, "frame-a"]);
|
||||
assert.deepEqual(await copy({ name: "small.bin", size: 100 }, "", "frame-a"), { message: "uploaded" });
|
||||
assert.deepEqual(calls.pop(), ["upload", { name: "small.bin", size: 100 }, "push", "frame-a"]);
|
||||
|
||||
let opened = false;
|
||||
const browserUpload = new Function("XMLHttpRequest", grab("upload") + "\nreturn upload;")(
|
||||
class { constructor() { opened = true; } });
|
||||
await assert.rejects(browserUpload(large, "push", "frame-a"), /limited to 8 GiB/);
|
||||
assert.equal(opened, false, "oversized browser file is refused before creating a request");
|
||||
console.log("ok");
|
||||
@@ -0,0 +1,123 @@
|
||||
"""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 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_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())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Large desktop file copies use the selected local path, not an 8 GiB HTTP upload."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import server
|
||||
|
||||
|
||||
class LargeTransfer(unittest.TestCase):
|
||||
def test_desktop_http_copy_stays_on_selected_headset(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, server.LoopbackServer(("127.0.0.1", 0), server.Handler) as httpd:
|
||||
source = Path(tmp) / "usb-file.bin"
|
||||
source.write_bytes(b"a small stand-in for a large file")
|
||||
worker = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-a"}
|
||||
body = json.dumps({"action": "copy-local", "path": str(source)})
|
||||
|
||||
def copy(device):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", httpd.server_port, timeout=5)
|
||||
try:
|
||||
conn.request("POST", "/api/transfer", body, {"X-Frame-UI": server.UI_KEY,
|
||||
"X-Frame-Device": device, "Content-Type": "application/json"})
|
||||
response = conn.getresponse()
|
||||
return response.status, json.loads(response.read())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_APP": "1"}), \
|
||||
mock.patch.object(server, "LINK", link), \
|
||||
mock.patch.object(server, "push_file", return_value="Sent usb-file.bin to ~/Downloads/") as push:
|
||||
self.assertEqual(copy("frame-b")[0], 409)
|
||||
push.assert_not_called()
|
||||
self.assertEqual(copy("frame-a"), (200, {"message": "Sent usb-file.bin to ~/Downloads/"}))
|
||||
push.assert_called_once_with(source)
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
worker.join(timeout=5)
|
||||
|
||||
def test_27_gib_local_file_reaches_direct_copy(self):
|
||||
source = Path(os.path.abspath("large-file.bin"))
|
||||
size = 27 * 1024 ** 3
|
||||
info = SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_size=size)
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_APP": "1"}), \
|
||||
mock.patch.object(Path, "stat", return_value=info), \
|
||||
mock.patch.object(server, "push_file", return_value="Sent large-file.bin to ~/Downloads/") as push:
|
||||
self.assertEqual(server.transfer_local({"action": "copy-local", "path": str(source)}),
|
||||
{"message": "Sent large-file.bin to ~/Downloads/"})
|
||||
push.assert_called_once_with(source)
|
||||
|
||||
def test_direct_copy_validates_desktop_file_before_ssh(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, \
|
||||
mock.patch.dict(os.environ, {"FRAME_CONTROL_APP": "1"}), \
|
||||
mock.patch.object(server, "push_file") as push:
|
||||
empty = Path(tmp) / "empty.bin"
|
||||
empty.touch()
|
||||
hidden = Path(tmp) / ".hidden"
|
||||
hidden.write_bytes(b"x")
|
||||
for path in ("relative.bin", str(Path(tmp) / "missing.bin"), tmp,
|
||||
str(empty), str(hidden)):
|
||||
with self.subTest(path=path), self.assertRaises(server.Failure):
|
||||
server.transfer_local({"action": "copy-local", "path": path})
|
||||
push.assert_not_called()
|
||||
|
||||
def test_large_copy_gets_more_than_one_hour_without_loading_file(self):
|
||||
info = SimpleNamespace(st_size=27 * 1024 ** 3)
|
||||
result = subprocess.CompletedProcess([], 0, "", "")
|
||||
with mock.patch.object(Path, "stat", return_value=info), \
|
||||
mock.patch.object(server.shutil, "which", return_value=None), \
|
||||
mock.patch.object(server.frame_host, "run_ssh", return_value=result) as run:
|
||||
self.assertEqual(server.push_file("/tmp/large-file.bin"),
|
||||
"Sent large-file.bin to ~/Downloads/")
|
||||
self.assertGreater(run.call_args.kwargs["timeout"], 3600)
|
||||
+19
-1
@@ -267,6 +267,17 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(self.request("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404)
|
||||
self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403)
|
||||
|
||||
def test_direct_local_transfer_is_desktop_only(self):
|
||||
status, payload = self.post("/api/transfer", {"action": "copy-local", "path": "/tmp/a.bin"})
|
||||
self.assertEqual(status, 403, payload)
|
||||
|
||||
def test_oversized_browser_upload_rejected_without_reading_body(self):
|
||||
status, _, payload = self.request("POST", "/api/upload", b"",
|
||||
{"X-Frame-UI": "1", "X-Filename": "large.bin",
|
||||
"Content-Length": str(27 * 1024 ** 3)})
|
||||
self.assertEqual(status, 413, payload)
|
||||
self.assertIn(b"limited to 8 GiB", payload)
|
||||
|
||||
def test_web_install_needs_the_app_page(self):
|
||||
# A website can only open frame-control:// links; it can't call these itself.
|
||||
link = {"url": "https://cdn.example.com/game.apk"}
|
||||
@@ -497,7 +508,10 @@ class ArtworkSettings(unittest.TestCase):
|
||||
time.sleep(0.05)
|
||||
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
|
||||
self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
|
||||
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403)
|
||||
# The missing-key guard runs before reading a body. Leaving an
|
||||
# unread body on Windows can reset this test connection before
|
||||
# http.client receives the 403 response.
|
||||
self.assertEqual(request("POST", "/api/settings/artwork")[0], 403)
|
||||
status, payload = request("GET", "/api/settings/artwork", headers=key)
|
||||
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
|
||||
status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
|
||||
@@ -575,6 +589,10 @@ class PageScripts(unittest.TestCase):
|
||||
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "meta_rows.mjs")], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
|
||||
def test_large_local_file_uses_direct_copy(self):
|
||||
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "large_transfer.mjs")], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
|
||||
class LocalMode(unittest.TestCase):
|
||||
|
||||
+8
-3
@@ -1686,6 +1686,7 @@
|
||||
<option value="vr">VR app</option>
|
||||
</select>
|
||||
<div class="progress" id="prog"><i></i></div>
|
||||
<div class="hint">The desktop app copies files from this computer directly, including files larger than 8 GiB. Browser uploads are limited to 8 GiB.</div>
|
||||
<div class="hint">Clipboard text, both ways, is in <a href="#pad">Type on the headset</a>.</div>
|
||||
</section>
|
||||
|
||||
@@ -3843,6 +3844,7 @@ window.addEventListener("drop", e => {
|
||||
function upload(file, mode, dev = window.connDevice, backupToken = null, progress = () => {}, signal = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(new Error("Import cancelled"));
|
||||
if (file.size > 8 * 1024 ** 3) return reject(new Error("Browser uploads are limited to 8 GiB; use the desktop app to copy larger files"));
|
||||
const xhr = new XMLHttpRequest();
|
||||
const abort = () => xhr.abort();
|
||||
const cleanup = () => signal?.removeEventListener("abort", abort);
|
||||
@@ -3875,6 +3877,9 @@ function upload(file, mode, dev = window.connDevice, backupToken = null, progres
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
function copyToDownloads(file, path, dev) {
|
||||
return path ? api("/api/transfer", { action: "copy-local", path }, dev) : upload(file, "push", dev);
|
||||
}
|
||||
async function refreshMedia(selectId) {
|
||||
const data = await api("/api/media", { action: "list" });
|
||||
const selected = selectId || $("mediaFile").value;
|
||||
@@ -4001,7 +4006,7 @@ async function sendFilesOne(files, dirs, dev) {
|
||||
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||||
const apk = f.name.toLowerCase().endsWith(".apk");
|
||||
if (apk) await importAndroidFiles([f], dev);
|
||||
else await act(`Copy ${f.name} to ~/Downloads`, () => upload(f, "push", dev));
|
||||
else await act(`Copy ${f.name} to ~/Downloads`, () => copyToDownloads(f, path, dev));
|
||||
}
|
||||
$("fileInput").value = "";
|
||||
refresh();
|
||||
@@ -4137,7 +4142,7 @@ async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
|
||||
catch (e) {
|
||||
log(`${file.name}: ${e.message}`, "e");
|
||||
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
|
||||
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
|
||||
await act(`Copy ${file.name} to ~/Downloads`, () => copyToDownloads(file, path, dev));
|
||||
return;
|
||||
}
|
||||
// Its own fresh list, so the dialog can say whether this replaces an installed title.
|
||||
@@ -4146,7 +4151,7 @@ async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
|
||||
const choice = await confirmTitle(r.plan, canPush, installed);
|
||||
if (choice === "push") {
|
||||
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
|
||||
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
|
||||
await act(`Copy ${file.name} to ~/Downloads`, () => copyToDownloads(file, path, dev));
|
||||
return;
|
||||
}
|
||||
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
|
||||
|
||||
+31
-3
@@ -28,6 +28,7 @@ import shutil
|
||||
import signal
|
||||
import socket
|
||||
import socketserver
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -3494,8 +3495,27 @@ def power_action(body):
|
||||
raise Failure(str(exc), 409) from exc
|
||||
|
||||
|
||||
def transfer_local(body):
|
||||
"""Copy a desktop-selected file without a second, size-limited HTTP upload to /tmp."""
|
||||
if os.environ.get("FRAME_CONTROL_APP") != "1":
|
||||
raise Failure("Direct file copy needs the Frame Control desktop app", 403)
|
||||
if body.get("action") != "copy-local" or not isinstance(body.get("path"), str):
|
||||
raise Failure("Choose a file on this computer", 400)
|
||||
path = Path(body["path"])
|
||||
if not path.is_absolute() or not path.name or path.name.startswith("."):
|
||||
raise Failure("Choose a regular file with a visible name", 400)
|
||||
try:
|
||||
info = path.stat()
|
||||
except OSError:
|
||||
raise Failure("That file is no longer available on this computer", 400) from None
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size <= 0:
|
||||
raise Failure("Choose a nonempty regular file", 400)
|
||||
return {"message": push_file(path)}
|
||||
|
||||
|
||||
POST = {
|
||||
"/api/steam-pipewire": steam_pipewire_action,
|
||||
"/api/transfer": transfer_local,
|
||||
"/api/power": power_action,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/decky": decky,
|
||||
@@ -3611,6 +3631,12 @@ def _next_chunk(chunks, timeout):
|
||||
def push_file(path, dest="Downloads/"):
|
||||
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
|
||||
name = Path(path).name
|
||||
# A large file can legitimately take more than an hour over Wi-Fi. Keep a
|
||||
# finite deadline, but allow roughly one MiB/s plus startup time.
|
||||
try:
|
||||
timeout = max(3600, min(24 * 3600, int(Path(path).stat().st_size / (1 << 20)) + 600))
|
||||
except OSError:
|
||||
raise Failure(f"{name} is no longer available on this computer", 400) from None
|
||||
try:
|
||||
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take our POSIX -e quoting.
|
||||
if not frame_host.WINDOWS and shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
|
||||
@@ -3618,7 +3644,7 @@ def push_file(path, dest="Downloads/"):
|
||||
else:
|
||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Copying {name} timed out")
|
||||
if r.returncode != 0:
|
||||
@@ -4070,8 +4096,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if not name or name.startswith("."):
|
||||
raise Failure("missing filename", 400)
|
||||
if length <= 0 or length > MAX_UPLOAD:
|
||||
raise Failure("empty or too-large upload", 400)
|
||||
if length <= 0:
|
||||
raise Failure("empty upload", 400)
|
||||
if length > MAX_UPLOAD:
|
||||
raise Failure("Browser uploads are limited to 8 GiB; use the desktop app to copy larger files", 413)
|
||||
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
|
||||
raise Failure("APK install needs a .apk file", 400)
|
||||
backup = None
|
||||
|
||||
Reference in new issue
Block a user