mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 12:01:48 +01:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4971c9232 | ||
|
|
288eb13b72 | ||
|
|
1255ef2bbf | ||
|
|
fdd60d3ccd | ||
|
|
d5fa8feb31 | ||
|
|
4e07409627 | ||
|
|
da34d94009 | ||
|
|
234b0a1814 | ||
|
|
7a8e919546 |
No files matched your search
@@ -2,6 +2,18 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix smoke cleanup skipping orphan metadata and Steam shortcuts after a test
|
||||
title directory was already removed. Sweep and verify all six exact reserved
|
||||
smoke IDs; preserve similarly named titles. Make `scripts/frame-smoke.sh`
|
||||
work with POSIX sh and Python 3 without zsh, including from another working
|
||||
directory. Local regressions cover both fixes; no new headset run.
|
||||
|
||||
- Skip backup saves and settings reached through a symbolic link anywhere below
|
||||
the home folder, and recheck paths before packing each file. Local synthetic
|
||||
tests cover redirected Proton prefixes and links changed after discovery;
|
||||
concurrent filesystem changes can still race the checks.
|
||||
[Backup layout and limits](docs/backup-and-recovery.md).
|
||||
|
||||
- Import a base APK with supported native-library or asset splits from a local
|
||||
Android backup or XAPK/APKS/APKM bundle. Check package and version before
|
||||
install; refuse resource, code and feature splits and device-variant sets.
|
||||
@@ -12,6 +24,11 @@
|
||||
existing files in the old flat folder are left in place.
|
||||
[Copying guide](docs/recording.md).
|
||||
|
||||
- Keep Steam clip exports separate for each headset, Steam account and nested
|
||||
recording session. Manual export and automatic copying use the same folders;
|
||||
existing flat exports stay untouched and do not mark unrelated clips saved.
|
||||
[Recording guide](docs/recording.md#steam-clips).
|
||||
|
||||
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
|
||||
Highlight exact PnP instances and known state changes after a dropout;
|
||||
incomplete inventories stay unknown. A Forget button clears the comparison.
|
||||
@@ -213,3 +230,8 @@
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
|
||||
- Keep failed automatic screenshot and clip copies visible after three attempts.
|
||||
**Resume** and **Copy everything now** allow another try without restarting;
|
||||
headset switches cannot use another headset's list or saved-file history.
|
||||
[Copying guide](docs/recording.md).
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.30",
|
||||
"version": "0.4.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.30",
|
||||
"version": "0.4.31",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.30",
|
||||
"version": "0.4.31",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -100,7 +100,21 @@ copy one by hand if you need it.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
- Symbolic links, including any ancestor below the home folder of a selected
|
||||
save or settings path, and single files over 2 GiB. Discovery checks each
|
||||
component, and packing checks it again before opening each file.
|
||||
|
||||
Steam backups use the normal `~/.local/share/Steam` directory tree. A separate
|
||||
`~/.steam/steam` alias does not affect this route. A library, Proton prefix or
|
||||
Wine save folder redirected by a symlink below the home folder is skipped,
|
||||
even if its target is inside home; it is not resolved and backed up elsewhere.
|
||||
The home folder itself is trusted and may use the host's normal path aliases.
|
||||
|
||||
These are path checks, not a filesystem snapshot: a concurrent rename or link
|
||||
replacement between a check and a read can still race it. Close games and
|
||||
avoid moving or relinking these folders during backup. This fix rejects links
|
||||
already present when checked, including ones introduced after discovery; it
|
||||
does not provide atomic protection against concurrent filesystem changes.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
@@ -192,11 +206,18 @@ If the button can't help, or the headset isn't reachable:
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
folders), links in save/settings roots and their ancestors refused, the manifest
|
||||
and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
Synthetic symlink fixtures cover every component of a Proton save root below
|
||||
home, including `compatdata/<appid>` and `steamuser/AppData`, plus directory
|
||||
and file links introduced between discovery and packing. Ordinary Proton and
|
||||
non-Steam saves, Linux saves, controller layouts, reference settings and home
|
||||
aliases still pack with their original bytes. These are local fixture checks;
|
||||
this path fix has not been tested on a headset.
|
||||
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
|
||||
the server, deletes it and restores it. Neither involves a real headset.
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.30**, 25 October 2026. “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 for Frame Control **v0.4.31**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.31 candidate source `1255ef2`, Frame OS 0.5.6 build `20261022.6153909`: [sanitized results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| 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) |
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Decky Loader on Steam Frame
|
||||
|
||||
For the modding-path survey, licence review and update limits, see
|
||||
[plugin and modding research](plugins-research.md).
|
||||
|
||||
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
|
||||
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
|
||||
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Auto-sync retry and headset-selection reliability
|
||||
|
||||
Date: 25 October 2026. Base: `d79efb4`, Frame Control v0.4.30.
|
||||
Issue: [#214](https://github.com/saphid/frame-control/issues/214).
|
||||
Environment: Linux x86_64, Python 3.12.3, Node 24.15.0.
|
||||
|
||||
## Reproduced problems
|
||||
|
||||
The existing 19 auto-sync tests passed on the base revision. Added regressions
|
||||
failed before the implementation: an exhausted clip lost its error, explicit
|
||||
retry did not recover failed screenshots or clips, and switches around listing
|
||||
or copying continued the old pass. The original callback interface also had no
|
||||
way to carry the tick's captured device into the server list reservation.
|
||||
|
||||
The final fake-source tests exercise these boundaries:
|
||||
|
||||
- Fail screenshots or clips three times, keep checking without further copy
|
||||
attempts, and retain the source error plus an explicit retry instruction.
|
||||
- Recover the source and use either Resume or Copy everything now without
|
||||
restarting. Successful copies stay deduplicated; Resume retains the ledger
|
||||
even after a local file is deleted. Copy everything now retains its documented
|
||||
ability to restore deleted files and include older media.
|
||||
- Reset an exhausted budget while the source still fails: another three
|
||||
attempts are allowed, rather than unlimited background attempts.
|
||||
- Save an exhausted item by hand and clear its stale failure on the next list.
|
||||
- Switch after tick captures A, during a list, after screenshot bookkeeping,
|
||||
between media kinds, before a transfer, or before transfer bookkeeping.
|
||||
Interrupted passes do not mark the other headset's IDs or continue copying.
|
||||
- Switch to B exactly at callback entry through the production `_sync_list`
|
||||
adapter. `working(A)` refuses before calling the source list, leaving both
|
||||
ledgers and copy callbacks untouched. The same production reservation blocks
|
||||
a device switch during a list and rejects stale screenshot/clip transfers.
|
||||
- Execute the production Settings renderer in Node: Resume is available for
|
||||
copy errors and disk-full pauses, and hidden for a healthy state.
|
||||
|
||||
## Implementation boundary
|
||||
|
||||
Each list callback now receives the captured device, like the copy callbacks.
|
||||
The existing server `working(meant)` mechanism checks that intended headset
|
||||
under `_work_lock` while reserving work; `devices_post` uses the same lock to
|
||||
refuse switches during that work. The worker also checks for interruption
|
||||
around lists and transfers. No new device lock or clip destination logic is
|
||||
introduced. The server diff is confined to `_sync_list`.
|
||||
|
||||
The failed-attempt and error maps are in-memory and reset on explicit retry or
|
||||
restart. The successful-copy ledger and settings persistence retain their
|
||||
existing format. Disk-full pauses, offline backoff, clip settling and the
|
||||
three-attempt limit remain covered by the original tests.
|
||||
|
||||
## Checks
|
||||
|
||||
- `python3 -m unittest discover -s tests -p test_autosync.py -v`: 33 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_shot_copy.py -v`: 2 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_clips.py -v`: 34 passed,
|
||||
including local ffmpeg coverage.
|
||||
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
|
||||
36 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_page_scripts.py -v`: 5 passed.
|
||||
- `node --test site/test/*.test.mjs`: 2 test files passed.
|
||||
- `python3 -m py_compile ui/frame_autosync.py ui/server.py tests/test_autosync.py`
|
||||
and `git diff --check`: passed.
|
||||
|
||||
- `python3 -m unittest discover -s tests -v`: 1,525 tests run in 184.876
|
||||
seconds, passed with 16 skips. Ran with sandbox escalation for loopback
|
||||
sockets; test isolation still used throwaway app/SSH data and mocked headset
|
||||
transport. Final output:
|
||||
|
||||
```text
|
||||
Ran 1525 tests in 184.876s
|
||||
OK (skipped=16)
|
||||
```
|
||||
|
||||
## Limits and handoff
|
||||
|
||||
All new regressions use fake sources and mocked transport; they do not prove
|
||||
real-headset transfer or UI rendering. No headset was accessed: its shared test
|
||||
lock was held by another task. The Docker fake-Frame end-to-end suite and
|
||||
Windows/macOS runs were not performed locally. The coordinator owns review and
|
||||
merge; this task does not release, change PR #113, or publish announcements.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"checkedAt": "2026-10-25T06:03:45.387452+00:00",
|
||||
"method": "Read-only Python probe over the existing frame SSH alias; no device changes",
|
||||
"baselineCommit": "d79efb4730becc781f9a641cc36cb27f6481667c",
|
||||
"deckyStatus": {
|
||||
"arch": "aarch64",
|
||||
"frameOS": "0.5.6",
|
||||
"variant": "vr",
|
||||
"build": "20261022.6153909",
|
||||
"cef": true,
|
||||
"managed": false,
|
||||
"running": false,
|
||||
"version": null,
|
||||
"upstreamInstall": false,
|
||||
"plugins": [],
|
||||
"upstreamPlugins": []
|
||||
},
|
||||
"platform": {
|
||||
"userServiceManager": true,
|
||||
"rootMountOptions": "rw,relatime,ssd,discard=async,space_cache=v2,subvolid=5,subvol=/",
|
||||
"homeMountOptions": "rw,relatime",
|
||||
"homebrewExists": false,
|
||||
"cefMarkerExists": false,
|
||||
"cefTargetTitles": [
|
||||
"SteamVR - valve.steam.gamepadui.frame.menu.53930005 (53930005)",
|
||||
"SteamVR - valve.steam.gamepadui.bar (53930000)",
|
||||
"SteamVR",
|
||||
"SteamVR - Notifications",
|
||||
"SteamVR - valve.steam.gamepadui.floatingfooter (53930004)",
|
||||
"SteamVR - valve.steam.gamepadui.volumelevel (53930003)",
|
||||
"SteamVR - valve.steam.gamepadui.tooltip.53930002 (53930002)",
|
||||
"SteamVR - valve.steam.gamepadui.barpopup.53930001 (53930001)",
|
||||
"SteamVR - Keyboard",
|
||||
"data:text/html,<body><%2Fbody>",
|
||||
"SharedJSContext"
|
||||
]
|
||||
},
|
||||
"limits": [
|
||||
"No Decky install/update/uninstall repeated in this retry",
|
||||
"No OS update or reboot performed",
|
||||
"No wearer-visible menu or controller input verified"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Round 2 retry, 25 October 2026
|
||||
|
||||
The provider retry completed four further fixes, each in an isolated child
|
||||
worktree with its own issue, branch, PR, tests, docs and Announcement draft.
|
||||
The coordinator reviewed each patch, independently ran focused tests, verified
|
||||
its exact head and complete CI, merged it, then read back GitHub's merged state.
|
||||
PR #113 was untouched. No social announcements were posted.
|
||||
|
||||
## Merged fixes
|
||||
|
||||
| Change | PR | Merge commit | Coordinator focused tests | Final CI |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Skip linked backup ancestors and recheck before packing | [#215](https://github.com/saphid/frame-control/pull/215) | `da34d94` | 34 passed | 11 checks passed at `8cc03c8` |
|
||||
| Clean orphan smoke metadata/shortcuts; POSIX runner without zsh | [#216](https://github.com/saphid/frame-control/pull/216) | `d5fa8fe` | 17 passed | 10 checks passed at `a5daa4a` |
|
||||
| Separate Steam clips by headset/account/full session ID | [#217](https://github.com/saphid/frame-control/pull/217) | `1255ef2` | 62 passed | 10 checks passed at `7dfe777` |
|
||||
| Retry failed automatic copies and bind listings to the captured headset | [#218](https://github.com/saphid/frame-control/pull/218) | `fdd60d3` | 33 passed | 10 checks passed at `af2bb17` |
|
||||
|
||||
Two initial CI fixture failures were fixed before merging: macOS temporary-path
|
||||
aliases needed a resolved working directory in the wrapper test; Windows
|
||||
needed explicit UTF-8 when reading the Settings test's HTML fixture.
|
||||
Concurrent main changes #208/#209/#210 were preserved. Changelog conflicts
|
||||
retained both entries. No history was rewritten.
|
||||
|
||||
## Integrated validation
|
||||
|
||||
At main `1255ef2`, after all four merges:
|
||||
|
||||
- `python3 -m unittest discover -s tests`: 1,559 tests, 16 skipped, passed in
|
||||
157.335 seconds. Loopback networking was enabled for local HTTP fixtures.
|
||||
- `node --test app/test/*.test.js site/test/*.test.mjs`: 59 tests passed.
|
||||
- Worker backup validation also passed both Docker recovery-kit E2E tests in
|
||||
an isolated Compose project, then removed its containers, volumes and network.
|
||||
- Local proof logs: `/tmp/frame-round2-integrated-tests.log` and
|
||||
`/tmp/frame-round2-integrated-node.log`. Individual focused logs use
|
||||
`/tmp/frame-round2-{backup,smoke,clips,autosync}-review.log`.
|
||||
|
||||
## Real Frame smoke
|
||||
|
||||
The current host is lxso2; its existing direct `ssh frame` alias works.
|
||||
Another OBS task initially held `/tmp/frame-test.lock`, so only read-only
|
||||
checks were done until it released the lock. This coordinator then acquired it
|
||||
atomically and labeled it `frame-round2-vdw4yh1xv8-20261025`.
|
||||
|
||||
The merged `scripts/frame-smoke.sh` ran directly without zsh at 06:38:37 UTC.
|
||||
All 13 steps passed on SteamOS 0.5.6, BUILD_ID `20261022.6153909`. The ARM64
|
||||
and Proton titles ran. The x86-64 Linux title reported its runtime missing,
|
||||
an explicitly accepted smoke outcome rather than a successful execution.
|
||||
Installs/removals succeeded; the Windows title's remove
|
||||
step stopped five test-owned PIDs. Both final process sweeps were empty.
|
||||
Independent readback found no reserved title folders or metadata, SteamVR's
|
||||
PID was unchanged, and the three observed gamescope focus properties matched
|
||||
before/after. Battery went from 99% Charging to 100% Full. Only this task's
|
||||
lock was removed, and its absence was verified.
|
||||
|
||||
[Sanitized structured proof](round2-smoke-2026-10-25.json). Raw local results:
|
||||
`/tmp/frame-round2-smoke-results/2026-10-25T063837Z-20261022.6153909.json`;
|
||||
console log `/tmp/frame-round2-headset-smoke.log`; state readbacks
|
||||
`/tmp/frame-round2-smoke-before.txt` and `/tmp/frame-round2-smoke-after.txt`.
|
||||
|
||||
The headset was unworn. Visible rendering/controller input was not verified.
|
||||
No pairing, password/security change, reboot or factory reset was performed.
|
||||
The new backup/clip/auto-sync paths have local fixtures and cross-platform CI;
|
||||
this smoke does not establish a new real-data backup/clip/UI test. Synthetic
|
||||
orphan metadata was not seeded on the real device. Backup path checks retain
|
||||
a documented race with concurrent filesystem changes.
|
||||
|
||||
## Prior round-two work reconciled
|
||||
|
||||
The failed coordinator checkout was still at v0.4.7. It was first updated to
|
||||
main `d79efb4`, already v0.4.30. Earlier PRs had completed clipboard testing
|
||||
(#124), original smoke process cleanup (#126), Windows VM capture (#129),
|
||||
battery health (#125), setup doctor (#127), and read-only storage (#128).
|
||||
Later evidence already covered real clip export (#147/#149/#154/#156) and
|
||||
Return to Game Mode (#143/#157). These remain earlier evidence, not tests
|
||||
performed by this retry. Physical Windows GPU/input and current physical
|
||||
macOS checks remain incomplete; see the current support matrix.
|
||||
|
||||
Decision: fix reproduced data/cleanup/retry defects rather than automate game
|
||||
moves without a verified transaction API or promise unverified phone-background
|
||||
sync. Prepare v0.4.31; publication still requires its Linux AppImage install test.
|
||||
|
||||
## Delegation provenance
|
||||
|
||||
All implementations completed. Task IDs use prefix
|
||||
`node:delegated-task:command%3Amcp%3Ad9bc35d8-0e9c-42c2-b798-25bd8af94d81%3Adelegate-task%3A`
|
||||
followed by one of:
|
||||
|
||||
- `frame-r2-retry-smoke-orphans-20261025-r1`
|
||||
- `frame-r2-backup-symlink-20261025-r1`
|
||||
- `frame-r2-clip-collision-20261025-r1`
|
||||
- `frame-r2-autosync-retry-20261025-r1`
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"source_commit": "1255ef2bbf2e2e5fd86f8b984b592bed56f20780",
|
||||
"started": "2026-10-25T06:38:37Z",
|
||||
"os_release": {
|
||||
"VERSION_ID": "0.5.6",
|
||||
"VARIANT_ID": "vr",
|
||||
"BUILD_ID": "20261022.6153909"
|
||||
},
|
||||
"passed": 13,
|
||||
"failed": 0,
|
||||
"paired": false,
|
||||
"devkit_utils_there_before": true,
|
||||
"battery_before": {
|
||||
"capacity": 99,
|
||||
"statuses": [
|
||||
"Charging",
|
||||
"Charging"
|
||||
]
|
||||
},
|
||||
"battery_after": {
|
||||
"capacity": 100,
|
||||
"statuses": [
|
||||
"Full",
|
||||
"Full"
|
||||
]
|
||||
},
|
||||
"steamvr_pid_before": 124586,
|
||||
"steamvr_pid_after": 124586,
|
||||
"observed_focus_properties_unchanged": true,
|
||||
"reserved_files_and_processes_absent_after": true,
|
||||
"own_shared_lock_released": true,
|
||||
"headset_worn": false,
|
||||
"steps": [
|
||||
{
|
||||
"step": "cleanup: leftovers from earlier runs",
|
||||
"ok": true,
|
||||
"seconds": 7.8,
|
||||
"stopped_process_counts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "devkit service: properties.json",
|
||||
"ok": true,
|
||||
"seconds": 0.0
|
||||
},
|
||||
{
|
||||
"step": "status",
|
||||
"ok": true,
|
||||
"seconds": 0.6
|
||||
},
|
||||
{
|
||||
"step": "arm64: install",
|
||||
"ok": true,
|
||||
"seconds": 7.7
|
||||
},
|
||||
{
|
||||
"step": "arm64: launch",
|
||||
"ok": true,
|
||||
"seconds": 3.3,
|
||||
"outcome": "running"
|
||||
},
|
||||
{
|
||||
"step": "arm64: remove",
|
||||
"ok": true,
|
||||
"seconds": 6.4,
|
||||
"stopped_process_counts": [
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "x86_64: install",
|
||||
"ok": true,
|
||||
"seconds": 7.6
|
||||
},
|
||||
{
|
||||
"step": "x86_64: launch",
|
||||
"ok": true,
|
||||
"seconds": 3.2,
|
||||
"outcome": "runtime missing"
|
||||
},
|
||||
{
|
||||
"step": "x86_64: remove",
|
||||
"ok": true,
|
||||
"seconds": 6.2,
|
||||
"stopped_process_counts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "exe: install",
|
||||
"ok": true,
|
||||
"seconds": 7.5
|
||||
},
|
||||
{
|
||||
"step": "exe: launch",
|
||||
"ok": true,
|
||||
"seconds": 3.3,
|
||||
"outcome": "running"
|
||||
},
|
||||
{
|
||||
"step": "exe: remove",
|
||||
"ok": true,
|
||||
"seconds": 6.5,
|
||||
"stopped_process_counts": [
|
||||
5,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "cleanup: everything this run installed",
|
||||
"ok": true,
|
||||
"seconds": 7.4,
|
||||
"stopped_process_counts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# Steam clip export collision fixtures, 25 October 2026
|
||||
|
||||
Base: `d79efb4` (Frame Control v0.4.30). Issue:
|
||||
[#212](https://github.com/saphid/frame-control/issues/212).
|
||||
Host: Linux 6.8.0-139-generic, Python 3.12.3, ffmpeg 6.1.1.
|
||||
|
||||
## Baseline reproduction
|
||||
|
||||
Loaded the unmodified `ui/frame_clips.py` from `d79efb4` into a temporary
|
||||
Python module and exported three fake sources into a temporary directory:
|
||||
|
||||
```
|
||||
1/clips/clip_620/video/bg
|
||||
2/clips/clip_620/video/bg
|
||||
1/clips/clip_620/video/other
|
||||
```
|
||||
|
||||
Each source's payload was its own ID. All three exports returned the same
|
||||
destination, `clip_620.mp4`; listing marked all three saved. The final file
|
||||
contained only the last source ID. No SSH or headset was used.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
`tests/test_clips.py` exports five IDs on each of two fake headsets, including
|
||||
equal clip names across accounts, nested sessions with equal leaf names,
|
||||
different leaf names under one clip and the background/clip distinction.
|
||||
All ten payloads survive at distinct destinations. Listing marks only the
|
||||
identities already exported. A pre-existing flat MP4 retains its original
|
||||
bytes and does not mark any identity saved. Normal export still returns
|
||||
`message` and `file`, removes its remote temporary export and clears local
|
||||
staging; a failed re-export preserves the completed file.
|
||||
|
||||
`tests/test_clip_copy.py` exercises the server's list and manual export
|
||||
functions and its actual auto-sync callbacks with fake transport. A manual
|
||||
copy is recognised by auto-sync, all other account/session identities copy,
|
||||
and switching to a second headset copies its own sources without overwriting
|
||||
the first. A delayed manual job refuses a changed headset with status 409.
|
||||
Both local phone mode and local headset runtime reject Steam clip list,
|
||||
export and auto-sync requests with status 400. The separate `Recorder` path
|
||||
is unchanged.
|
||||
|
||||
## Validation
|
||||
|
||||
- `PYTHONPATH=tests python3 -m unittest test_clips test_clip_copy test_autosync test_shot_copy test_headset_actions -v`
|
||||
— 66 tests passed in 17.288 seconds, no skips. Includes real ffmpeg/ffprobe
|
||||
export of a generated 65-second DASH fixture and recording stop/cleanup.
|
||||
- `PYTHONPATH=tests python3 -m unittest test_server -v`
|
||||
— 44 tests passed in 12.315 seconds, no skips.
|
||||
- `node --test site/test/*.test.mjs` — 2 tests passed.
|
||||
- `python3 -m unittest discover -s tests -v` — 1,518 tests run in
|
||||
181.025 seconds; OK, 16 skipped for unavailable platform/tool prerequisites.
|
||||
- `python3 -m py_compile ui/frame_clips.py ui/server.py tests/test_clips.py tests/test_clip_copy.py`
|
||||
and `git diff --check` — passed.
|
||||
|
||||
The server and full Python suites require localhost sockets and were run
|
||||
outside the network sandbox. Tests import `tests/sandbox.py` to isolate app
|
||||
data, SSH settings, home directories and service URLs.
|
||||
|
||||
## Limits and upgrade behaviour
|
||||
|
||||
No real headset was accessed: the shared device lock is held by other work.
|
||||
This is Linux fixture and server coverage, not a fresh packaged-app or
|
||||
Windows/macOS verification. Existing real-headset export evidence in the
|
||||
recording guide predates this destination change.
|
||||
|
||||
Old flat exports cannot prove headset/account/session identity and are left
|
||||
untouched. Previously remembered auto-sync IDs retain their existing
|
||||
watermark/history behaviour; **Copy everything now** or manual **Export**
|
||||
creates an identified copy. No release, version bump, merge or social post
|
||||
is included. PR #113 is outside this task.
|
||||
@@ -34,11 +34,19 @@ The manifest ID must match the folder name:
|
||||
"id": "your-id",
|
||||
"name": "Your feature",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"description": "What the panel does.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
```
|
||||
|
||||
`apiVersion` selects the Frame Control bridge contract, independently of your
|
||||
extension's own `version`. This app supports integer `1`; a missing field means
|
||||
`1` for existing extensions. Unsupported versions appear as errors on Refresh,
|
||||
and their assets are not served. Declare it in new extensions so a future API
|
||||
change can fail clearly instead of running incompatible code. The runtime
|
||||
`capabilities` reply below reports the bridge version too.
|
||||
|
||||
Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS,
|
||||
JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder.
|
||||
Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Plugin and modding research
|
||||
|
||||
Checked **2026-10-25**. Decision: keep the experimental, reversible Decky manager
|
||||
and ship Frame Control's own sandboxed panel API as the supported way to add
|
||||
local features. Both implementations already landed in the replacement job
|
||||
while the original job was unavailable. This report consolidates their evidence
|
||||
and the original research questions; it does not claim another loader was built.
|
||||
|
||||
## Can Decky run on Frame?
|
||||
|
||||
**Its ARM64 backend and Steam shared-context injection work on the tested build.
|
||||
A usable Decky menu inside VR remains unverified.** Do not equate an active
|
||||
service or a JavaScript plugin registration with a working headset interface.
|
||||
|
||||
| Requirement | Evidence | Consequence |
|
||||
| --- | --- | --- |
|
||||
| ARM64 Linux binary | Upstream [3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0) has `PluginLoader-arm64`; Frame reports `aarch64` | ARM is not itself a blocker. Individual plugins may still ship x86 binaries or assume Deck hardware. |
|
||||
| Steam Gamepad UI and CEF | Loopback `127.0.0.1:8080/json/list` exposes `SharedJSContext` plus distinct SteamVR pages | The shared context exists; the VR shell is a separate integration target. |
|
||||
| CEF enable marker | `~/.steam/steam/.cef-enable-remote-debugging` is absent, but CEF already answers | No marker or Steam launch flag change is needed on this build. |
|
||||
| User service manager | `systemctl --user show-environment` succeeds | A per-user service works without installing an upstream root service. The manager is degraded by a stale failed Decky unit, although its unit file is gone. |
|
||||
| Filesystem | `/` currently mounts `rw` on Btrfs, `/home` mounts `rw` | This is an observation of this device, not a stock-image guarantee. Do not disable SteamOS read-only protection or install packages in `/usr`. |
|
||||
| Homebrew folder | `~/homebrew` is absent | It is an installer convention, not a preinstalled SteamOS plugin API. Frame Control uses a separate managed directory. |
|
||||
| Visible menu | Earlier tests registered Decky's tab only in shared JavaScript; SteamVR had no Decky overlay; headset was in standby | QAM visibility, controller input and a real plugin's visible result remain unverified. |
|
||||
|
||||
The fresh [read-only probe](evidence/plugins-2026-10-25.json) records SteamOS
|
||||
`0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`. It confirms CEF, architecture,
|
||||
mounts, user-service availability, and absence of a running managed or upstream
|
||||
Decky installation. This retry did not install anything or restart Steam.
|
||||
|
||||
The replacement job's initial [test record](decky-frame.md) reports:
|
||||
|
||||
- Decky 3.3.0 started as the normal `steamos` user and injected into
|
||||
`SharedJSContext`.
|
||||
- Install → status → update → status → remove → status succeeded on the Frame.
|
||||
Removal left the unit, binary and port 1337 absent, while preserving user data.
|
||||
- ProtonDB Badges 1.1.0 loaded as a passive plugin and appeared in Decky's
|
||||
shared-context registry. Its badge or menu was not seen inside VR.
|
||||
- Injected frontend code can outlive backend removal until Steam's web helper
|
||||
restarts. An earlier helper-restart test cleared the globals; this retry did
|
||||
not repeat it.
|
||||
|
||||
These are prior documented results, separate from the fresh read-only probe.
|
||||
The coordinator then ran a separate, locked
|
||||
[official-store compatibility audit](decky-frame.md#official-store-plugin-audit-2026-10-25)
|
||||
on the same Frame OS build. Seven plugins logged backend loads. A managed
|
||||
ProtonDB Badges 1.3.4 store install, update and remove also succeeded. The
|
||||
headset stayed unworn, so these results still do not establish VR UI use.
|
||||
|
||||
## Installer and update choice
|
||||
|
||||
The upstream [installer at e42a3c8](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
|
||||
escalates through sudo, creates `~/homebrew/plugins` and `services`, writes the
|
||||
CEF marker and a system unit under `/etc/systemd/system`, and starts Decky as
|
||||
root. Its architecture switch supports `aarch64|arm64`. Running this installer
|
||||
is unnecessary for a compatibility check and grants more access than a panel
|
||||
extension needs.
|
||||
|
||||
Frame Control's [manager](../ui/frame_decky.py) instead downloads the official
|
||||
ARM64 asset, checks GitHub's published SHA-256 and the ELF machine, and writes
|
||||
only under `~/.local/share/frame-control/decky` and `~/.config/systemd/user`.
|
||||
It refuses an upstream system-service installation, requires active CEF before
|
||||
install/update, and restores the previous binary, receipt and unit if applying
|
||||
an update fails. Existing tests exercise that rollback with a failed service
|
||||
start. These checks do not prove every possible OS or Steam update is safe.
|
||||
|
||||
The binary is downloaded on demand, not bundled. Plugin discovery lists the
|
||||
managed directory and upstream `~/homebrew/plugins`. The later
|
||||
[store manager](../ui/frame_decky_store.py) exposes official entries in the PC
|
||||
app and opt-in headset panel. It verifies archive digests, blocks root and
|
||||
non-ARM64 binaries, and changes only its own plugin folders. That is file and
|
||||
backend support, not a working VR plugin page. Decky plugins can execute
|
||||
backend code and are not isolated by Frame Control's HTML extension sandbox.
|
||||
|
||||
Home-directory state is expected to survive SteamOS image updates, based on
|
||||
SteamOS's layout; **an actual OS update and reboot persistence test has not been
|
||||
performed**. Steam frontend changes, CEF availability, libc requirements and
|
||||
plugin hardware assumptions can still break Decky. On failure, remove the user
|
||||
service using [the documented removal command](decky-frame.md#opt-in-and-remove);
|
||||
its removal path does not require a working CEF target. Test the sequence again
|
||||
on each supported OS build before expanding support claims.
|
||||
|
||||
## Other paths
|
||||
|
||||
| Path | What it enables | Current limits and choice |
|
||||
| --- | --- | --- |
|
||||
| Decky plugins | Steam frontend hooks, optional Python backends | Seven official-store plugins logged backend loads on ARM64. Store install/update/remove worked for ProtonDB Badges. VR pages and controller input remain unverified. Keep experimental. |
|
||||
| [CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader) / [DeckThemes](https://docs.deckthemes.com/) | Stylesheets applied to Steam UI | CSS Loader 2.1.3 backend loaded on Frame. No theme was applied or visually tested. Deck themes do not establish compatibility with Frame's separate VR shell. |
|
||||
| User home / SteamOS homebrew | User services, user Flatpaks, files and helpers | Use owned folders and explicit uninstall. A folder named `homebrew` supplies no discovery or security boundary by itself. No rootfs changes needed for the chosen path. |
|
||||
| Android / Lepton | Sideloaded apps, game-specific content and per-app settings | Frame Control already manages APKs and ADB. Lepton runs Android containers, not Steam's CEF UI; an Android mod does not add a Steam dashboard tab. No new Android mod was tested here. See [APKs](apks.md), [VR APKs](vr-apks.md), and [game mods](mods.md). |
|
||||
| Frame Control extensions | Third-party HTML/CSS/JS panels with a small read-only bridge | Shipped in [#194](https://github.com/saphid/frame-control/pull/194), with a manifest, local folder, sandbox, example and [author guide](extensions.md). No Python imports, arbitrary SSH commands or write actions are exposed. |
|
||||
| Frame Control's headset panel | Runs the local tools and extensions as a SteamVR panel | Shipped in [#198](https://github.com/saphid/frame-control/pull/198), expanded in [#201](https://github.com/saphid/frame-control/pull/201). Page/API execution and uninstall were tested; wearer-visible pixels and controller interaction remain unverified. See [test record](headset-app.md). |
|
||||
|
||||
The extension iframe has `sandbox="allow-scripts"`, an opaque origin, restrictive
|
||||
CSP and a `postMessage` bridge accepting only `capabilities` and a reduced
|
||||
`status` snapshot from the active frame. It is useful for local dashboards and
|
||||
small tools today. It cannot replace the full Decky API. Third-party code still
|
||||
needs review: an iframe may navigate itself externally, and local installation
|
||||
does not establish publisher trust.
|
||||
|
||||
Extensions are loaded only when opened and are removed by closing their panel
|
||||
and deleting their own directory. They modify no SteamOS files. The
|
||||
[compatibility follow-up](https://github.com/saphid/frame-control/pull/209) adds
|
||||
manifest `apiVersion` validation during discovery and asset serving, with
|
||||
existing manifests defaulting to version 1. It rejects incompatible panels
|
||||
even when requested through a direct asset URL.
|
||||
|
||||
## Licences: separate download versus bundling
|
||||
|
||||
Decky's [licence](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/LICENSE)
|
||||
is GPL version 2. Frame Control is MIT-licensed. The integration contains its
|
||||
own service-management code and downloads the upstream executable as a separate
|
||||
program. It does not incorporate Decky source or ship its binary in Frame
|
||||
Control installers. Keep the upstream licence/source links visible to users
|
||||
who opt in; installing a separate GPL program does not by itself relicense
|
||||
Frame Control's independent code.
|
||||
|
||||
If a future release bundles or mirrors Decky binaries, the distributor must
|
||||
preserve copyright/licence notices and satisfy GPLv2's corresponding-source
|
||||
requirements for the exact binary, including modifications and build scripts
|
||||
as applicable. A link to an evolving default branch is not a substitute for
|
||||
meeting those distribution obligations. Mere aggregation and deriving or
|
||||
linking a combined work are different questions; reassess the boundary if
|
||||
Decky code is incorporated. Each plugin, theme and bundled dependency has its
|
||||
own licence and assets to check independently. The existing
|
||||
[third-party notices](../THIRD_PARTY_NOTICES.md) describe software already
|
||||
distributed with Frame Control.
|
||||
|
||||
## Next checks
|
||||
|
||||
1. On a worn Frame, establish an accessible Decky menu or implement an adapter
|
||||
for the VR shell. Verify a plugin's visible result and controller navigation
|
||||
before describing store-installed plugins as usable inside VR.
|
||||
2. Verify the Frame Control panel and example extension visually, including
|
||||
opening, refreshing and closing with the controllers.
|
||||
3. Test a normal Steam restart, reboot and SteamOS update with a disposable
|
||||
managed install; confirm removal still works with CEF unavailable. Retain
|
||||
the experimental label until those checks pass.
|
||||
|
||||
The release coordinator owns those ongoing headset-integration checks. This
|
||||
retry leaves the headset untouched and hands over the research and compatibility
|
||||
follow-up instead of running concurrent loader experiments.
|
||||
+38
-6
@@ -69,11 +69,31 @@ yes-or-no answer about passthrough pixels. See the
|
||||
|
||||
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
|
||||
clips and background recordings, newest first, with the game, length and size.
|
||||
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
|
||||
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
**Export** joins one into a single `.mp4` under `~/Videos/SteamFrame`, named after
|
||||
its clip folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
Steam's own export is reported to stop at about 60 seconds. Exports run in the
|
||||
background; ones already on your computer are marked.
|
||||
|
||||
Each export keeps its headset, Steam account and complete recording session ID:
|
||||
|
||||
```
|
||||
~/Videos/SteamFrame/headset-<id>/<account>/clips/<clip>/video/<session>/<clip>.mp4
|
||||
~/Videos/SteamFrame/headset-<id>/<account>/video/<session>/<session>.mp4
|
||||
```
|
||||
|
||||
The headset folder uses the same short device-identity hash as screenshots.
|
||||
All folders in the Steam clip ID are kept, including nested sessions, so equal
|
||||
clip names across accounts, headsets or sessions keep separate files. Manual
|
||||
**Export** and automatic copying use the same path and saved marker. Exporting
|
||||
the same identified session again replaces only that session's export.
|
||||
|
||||
Existing exports in the old flat `~/Videos/SteamFrame/<clip>.mp4` layout stay
|
||||
untouched. Their source identity cannot be proved, so they do not mark a clip
|
||||
as saved. Export again or use **Copy everything now** to make an identified
|
||||
copy. Automatic copying still remembers IDs copied before the upgrade; use
|
||||
**Copy everything now** to copy those again. This destination change has local
|
||||
fixture coverage; it has not been tested on a real headset.
|
||||
|
||||
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
|
||||
recording session.
|
||||
|
||||
@@ -137,18 +157,29 @@ case was tested with separate fake sources, not two real same-name screenshots.
|
||||
- **When this computer is full** (under 512 MB free, or a copy runs out of
|
||||
space) it pauses and says so in Settings and above the lists. Free some space
|
||||
and press **Resume**.
|
||||
- **With several headsets** it copies from the one you're connected to.
|
||||
- **When one file keeps failing** it tries at most three times, then leaves
|
||||
the problem visible in Settings. Fix the source problem and press **Resume**
|
||||
to allow another three attempts without restarting Frame Control. **Copy
|
||||
everything now** also resets failed attempts and includes older files.
|
||||
Resume keeps the history of successful copies, so it does not recopy them.
|
||||
- **With several headsets** each check and copy belongs to the headset selected
|
||||
when the check began. Switching between lists or copies stops that pass;
|
||||
another headset's saved files cannot become part of its copy history.
|
||||
|
||||
Settings shows when it last checked, how many files it last copied and when,
|
||||
and the last problem it had. The screenshot and clip lists say when it's on.
|
||||
Retry and switch handling have offline regression coverage with fake sources;
|
||||
see the [test record](evidence/autosync-reliability-2026-10-25.md).
|
||||
|
||||
## For developers
|
||||
|
||||
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
|
||||
copying, in `ui/frame_autosync.py`, does):
|
||||
|
||||
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
|
||||
computer's side; the server passes how to reach the Frame.
|
||||
- `list_clips(run_frame, device_id=...)` and
|
||||
`export_clip(id, run_frame, run, fetch, device_id=...)` are the computer's
|
||||
side; the server passes how to reach the Frame and its pinned device identity.
|
||||
Both use `clip_local_path(id, device_id, folder=None)` for the destination.
|
||||
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
|
||||
run as `python3 - list` / `python3 - export ID` with the file on stdin.
|
||||
- `Recorder` runs one recording from Start to the copy.
|
||||
@@ -171,7 +202,8 @@ The API, with the same guards as every other request:
|
||||
They aren't available when the server runs on the Frame itself (the iPhone
|
||||
app), since they save to a computer.
|
||||
|
||||
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
|
||||
Tests: `tests/test_clips.py`, `tests/test_clip_copy.py` for server destinations
|
||||
and collision coverage, and `tests/test_autosync.py` for automatic
|
||||
copying (with a fake headset). They run the recording's shell under bash with a
|
||||
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
|
||||
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
|
||||
|
||||
+43
-4
@@ -12,6 +12,21 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
|
||||
## Unit tests
|
||||
|
||||
Automatic screenshot and clip copying has offline coverage in
|
||||
`tests/test_autosync.py`:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_autosync.py -v
|
||||
```
|
||||
|
||||
Fake sources exercise the three-attempt limit, visible exhausted errors,
|
||||
Resume/Copy everything now recovery, successful-copy deduplication, disk-full
|
||||
pauses and headset changes around lists and transfers. Server tests use the
|
||||
production `working(meant)` reservation and listing/copy adapters; a Node test
|
||||
executes the Settings renderer to check that Resume is available after an error.
|
||||
These tests use no headset. See the
|
||||
[auto-sync reliability test record](evidence/autosync-reliability-2026-10-25.md).
|
||||
|
||||
The gamescope keyboard/trackpad route has focused suites in `test_input.py`,
|
||||
`test_touch.py` and `test_service_regressions.py`. They cover pad translation,
|
||||
ASCII/modifier keys, Unicode queuing/retries, stale panel targets, release
|
||||
@@ -188,6 +203,11 @@ scripts/frame-smoke.sh # needs `ssh frame` to work without a password
|
||||
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
|
||||
```
|
||||
|
||||
The wrapper uses POSIX `sh` and Python 3 on macOS or Linux; zsh is not required.
|
||||
It resolves the checkout path independently of the current working directory.
|
||||
`/path/to/checkout/scripts/frame-smoke.sh --help` is safe to run offline and
|
||||
does not contact the headset.
|
||||
|
||||
It checks `properties.json` and the status, then installs, launches and
|
||||
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||
@@ -208,6 +228,14 @@ isn't reachable.
|
||||
Smoke process cleanup runs before and after title removal and during the
|
||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
|
||||
Each leftover sweep checks all six IDs, even if no title directory or install
|
||||
record remains. It removes their exact `-argv.json`, `-env.json`,
|
||||
`-settings.json` and `-framecontrol.json` files, syncs Steam's shortcuts, then
|
||||
syncs again to verify that none of those IDs is still registered. Missing
|
||||
devkit utilities are copied before syncing and removed at the end if they
|
||||
were absent before the run. Metadata or shortcuts that remain, or a reported
|
||||
sync failure, fail cleanup. Similarly named titles and their metadata are
|
||||
outside the deletion boundary.
|
||||
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
|
||||
an exact title directory in `/proc` cwd/exe or an individual absolute command
|
||||
argument; similarly named folders and shell command substrings do not count.
|
||||
@@ -231,12 +259,23 @@ The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
|
||||
remove step stopped five test-owned wrapper/Proton PIDs before removing the
|
||||
title; the following process sweep found none. The final cleanup found no
|
||||
test titles or processes, the test desktop was closed, and the battery
|
||||
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
|
||||
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
|
||||
the same entry point instead.
|
||||
remained at 99%. That historical run used the Python entry point directly;
|
||||
the current POSIX wrapper has local offline coverage on a PATH without zsh.
|
||||
The current runner and all-six-ID cleanup sweeps passed another 13-step real
|
||||
Frame run on 25 October, build `20261022.6153909`, at source commit `1255ef2`.
|
||||
The Windows title's remove step stopped five test-owned Proton/wrapper PIDs;
|
||||
the final sweep and independent readback found no reserved files or processes.
|
||||
SteamVR stayed running, observed focus properties matched before/after, and
|
||||
this task's shared lock was released. The headset was unworn. This run did
|
||||
not seed synthetic orphan metadata or shortcuts; those cases use local
|
||||
fixtures. [Sanitized results](evidence/round2-smoke-2026-10-25.json).
|
||||
The local regression suite runs real Linux processes in temporary fake title
|
||||
directories (including deleted folders and a TERM-resistant wrapper); it checks
|
||||
exit, escalation and survival of unrelated processes without SSH or a headset:
|
||||
exit, escalation and survival of unrelated processes. Local filesystem commands
|
||||
with mocked SSH/Steam also cover orphan metadata, shortcut-only leftovers,
|
||||
sync failures and exact ID boundaries. Wrapper tests check offline help,
|
||||
argument forwarding and exit status from another working directory. None of
|
||||
these local tests contacts a headset:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "headset-glance",
|
||||
"name": "Headset Glance",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"description": "Shows a small read-only summary of the connected Steam Frame.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env zsh
|
||||
#!/bin/sh
|
||||
# Mac or Linux: the headset smoke test. Installs, launches and removes tiny
|
||||
# test titles on the Frame (the `frame` alias) and records the results with
|
||||
# its BUILD_ID under tests/smoke/results/. See docs/testing.md.
|
||||
#
|
||||
# Usage: scripts/frame-smoke.sh [--pair] (--pair needs you in the headset to approve)
|
||||
set -euo pipefail
|
||||
exec python3 "${0:A:h:h}/tests/smoke/frame_smoke.py" "$@"
|
||||
set -eu
|
||||
smoke_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd -P)
|
||||
exec python3 "$smoke_root/tests/smoke/frame_smoke.py" "$@"
|
||||
@@ -203,23 +203,23 @@ def cleanup(smoke):
|
||||
never removed. Raises if any owned process, file or shortcut stays.
|
||||
"""
|
||||
problems = []
|
||||
try:
|
||||
ids = {t['id'] for t in frame_titles.list_titles() if t['id'] in stop_processes.IDS}
|
||||
except frame_android.FrameError as e:
|
||||
ids = set()
|
||||
problems.append(f'could not list titles: {e}')
|
||||
ids |= smoke.installed
|
||||
# Directory listings and this run's install tracker cannot reveal orphan
|
||||
# metadata or shortcuts. Always sweep the exact, reserved IDs; never let
|
||||
# arbitrary entries in smoke.installed expand the deletion boundary.
|
||||
ids = stop_processes.IDS
|
||||
# Scan every reserved ID, even when a prior run removed its folder and
|
||||
# shortcut but left Proton running. Stop before removing any more files.
|
||||
stopped = stop_titles(stop_processes.IDS)
|
||||
gone = set()
|
||||
for gid in sorted(ids):
|
||||
try:
|
||||
metadata = ' '.join(f'~/devkit-game/{gid}-{k}.json'
|
||||
for k in ('argv', 'env', 'settings', 'framecontrol'))
|
||||
if ssh(f'test -d ~/devkit-game/{gid} && echo yes || true').strip() == 'yes':
|
||||
frame_titles.remove(gid)
|
||||
# Also when remove() stopped part-way, or only the json files are left.
|
||||
ssh(f"rm -f {' '.join(f'~/devkit-game/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))}")
|
||||
if ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip():
|
||||
ssh(f'rm -f {metadata}')
|
||||
if ssh(f'ls -d ~/devkit-game/{gid} {metadata} 2>/dev/null || true').strip():
|
||||
problems.append(f'{gid} is still on the Frame')
|
||||
else:
|
||||
gone.add(gid)
|
||||
@@ -230,6 +230,8 @@ def cleanup(smoke):
|
||||
# It logs a failed sync and exits 0, so read its log; a second run lists
|
||||
# what Steam still has registered, which must not include ours.
|
||||
try:
|
||||
# A shortcut-only leftover (or a first run) may have no utilities.
|
||||
frame_titles.ensure_utils()
|
||||
command = frame_titles.devkit_script('delete') + ' 2>&1'
|
||||
first = ssh(command, timeout=120)
|
||||
second = ssh(command, timeout=120)
|
||||
|
||||
+227
-2
@@ -7,11 +7,13 @@ import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import errno
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_autosync as fa # noqa: E402
|
||||
@@ -35,13 +37,15 @@ class FakeFrame:
|
||||
self.device, self.busy, self.down = "frame-a", False, False
|
||||
self.save_error = self.export_error = None
|
||||
|
||||
def list_shots(self):
|
||||
def list_shots(self, device):
|
||||
assert device == self.device
|
||||
self.calls.append("list_shots")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
return {"shots": list(self.shots)}
|
||||
|
||||
def list_clips(self):
|
||||
def list_clips(self, device):
|
||||
assert device == self.device
|
||||
self.calls.append("list_clips")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
@@ -263,6 +267,134 @@ class AutoSyncTest(unittest.TestCase):
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
|
||||
self.assertIn("ffmpeg couldn't join", self.sync.state()["error"] or "")
|
||||
self.assertIn("Resume", self.sync.state()["error"] or "")
|
||||
|
||||
def test_explicit_retry_recovers_without_recopying_successful_items(self):
|
||||
for kind in ("shots", "clips"):
|
||||
for action in ("resume", "copy_all"):
|
||||
with self.subTest(kind=kind, action=action):
|
||||
self.frame = FakeFrame()
|
||||
f = self.frame
|
||||
sync = self.make()
|
||||
sync.update({"screenshots": True, "clips": True})
|
||||
f.shots, f.clips = [shot(1, NOW + 5)], [clip(1, NOW + 5)]
|
||||
error_attr = "save_error" if kind == "shots" else "export_error"
|
||||
copy_call = "save_shots" if kind == "shots" else "export_clip"
|
||||
setattr(f, error_attr, RuntimeError("source not ready"))
|
||||
for _ in range(fa.TRIES + 2):
|
||||
sync.tick()
|
||||
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
|
||||
self.assertIn("source not ready", sync.state()["error"] or "")
|
||||
setattr(f, error_attr, None)
|
||||
sync.tick() # recovery alone must not create unlimited automatic retries
|
||||
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
|
||||
getattr(sync, action)()
|
||||
sync.tick()
|
||||
self.assertEqual(f.saved, [("frame-a", [shot(1, 0)["id"]])])
|
||||
self.assertEqual(f.exported, [("frame-a", clip(1, 0)["id"])])
|
||||
self.assertIsNone(sync.state()["error"])
|
||||
# Resume preserves the per-headset ledger even if a local file was deleted.
|
||||
for item in f.shots + f.clips:
|
||||
item["saved"] = False
|
||||
sync.resume()
|
||||
sync.tick()
|
||||
self.assertEqual((len(f.saved), len(f.exported)), (1, 1))
|
||||
self.sync.path.unlink(missing_ok=True)
|
||||
|
||||
def test_explicit_retry_is_still_bounded(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("bad source")
|
||||
for action in ("resume", "copy_all"):
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
before = self.frame.calls.count("export_clip")
|
||||
getattr(self.sync, action)()
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip") - before, fa.TRIES)
|
||||
self.assertIn("bad source", self.sync.state()["error"] or "")
|
||||
|
||||
def test_failed_item_saved_by_hand_clears_error(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("bad source")
|
||||
for _ in range(fa.TRIES + 1):
|
||||
self.sync.tick()
|
||||
self.frame.clips[0]["saved"] = True
|
||||
self.sync.tick()
|
||||
self.assertIsNone(self.sync.state()["error"])
|
||||
self.assertIn(clip(1, 0)["id"], self.sync._synced("frame-a", "clips"))
|
||||
|
||||
def test_switch_after_capture_does_not_list_the_new_headset(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
def switched():
|
||||
self.frame.device = "frame-b"
|
||||
return False
|
||||
self.sync.busy = switched # tick has already captured frame-a
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.frame.calls, [])
|
||||
|
||||
def test_list_callbacks_receive_the_captured_device(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.sync.list_shots = mock.Mock(return_value={"shots": []})
|
||||
self.sync.list_clips = mock.Mock(return_value={"clips": []})
|
||||
self.sync.tick()
|
||||
self.sync.list_shots.assert_called_once_with("frame-a")
|
||||
self.sync.list_clips.assert_called_once_with("frame-a")
|
||||
|
||||
def test_switch_during_listing_does_not_mark_or_copy_its_results(self):
|
||||
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
|
||||
with self.subTest(kind=kind):
|
||||
self.frame.device = "frame-a"
|
||||
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
def listing(*_args):
|
||||
self.frame.device = "frame-b"
|
||||
return {kind: [dict(item, saved=True), dict(item, id="uncopied", saved=False)]}
|
||||
setattr(self.sync, "list_" + kind, listing)
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.sync._synced("frame-a", kind), set())
|
||||
self.assertEqual(self.sync._synced("frame-b", kind), set())
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_switch_between_kinds_does_not_read_clips_or_mark_transfer(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.clips = [clip(1, NOW + 5, saved=True)]
|
||||
save = self.sync.save_shots
|
||||
def then_switch(device, ids):
|
||||
save(device, ids)
|
||||
self.frame.device = "frame-b"
|
||||
self.sync.save_shots = then_switch
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertNotIn("list_clips", self.frame.calls)
|
||||
self.assertEqual(self.sync._synced("frame-a", "clips"), set())
|
||||
self.assertEqual(self.sync._synced("frame-a", "shots"), set())
|
||||
|
||||
def test_switch_after_bookkeeping_stops_before_next_kind(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5, saved=True)]
|
||||
mark = self.sync._mark
|
||||
def then_switch(device, kind, ids):
|
||||
mark(device, kind, ids)
|
||||
self.frame.device = "frame-b"
|
||||
self.sync._mark = then_switch
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertNotIn("list_clips", self.frame.calls)
|
||||
|
||||
def test_switch_before_transfer_does_not_copy(self):
|
||||
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
|
||||
with self.subTest(kind=kind):
|
||||
self.frame.device = "frame-a"
|
||||
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
setattr(self.frame, kind, [item])
|
||||
def switched_space(_folder):
|
||||
self.frame.device = "frame-b"
|
||||
return 100 * GB
|
||||
self.sync.space = switched_space
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_worker_stops(self):
|
||||
stop = threading.Event()
|
||||
@@ -275,6 +407,75 @@ class AutoSyncTest(unittest.TestCase):
|
||||
|
||||
|
||||
class Server(unittest.TestCase):
|
||||
def test_switch_at_list_callback_entry_does_not_read_or_mark_the_new_headset(self):
|
||||
import server
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
for kind in ("shots", "clips"):
|
||||
with self.subTest(kind=kind):
|
||||
active = {"id": "frame-a"}
|
||||
link = mock.Mock()
|
||||
link.active_device.side_effect = lambda: dict(active)
|
||||
listing = mock.Mock(return_value={kind: [
|
||||
shot(1, NOW + 5, saved=True) if kind == "shots" else clip(1, NOW + 5, saved=True)]})
|
||||
def switch_then_list(device):
|
||||
active["id"] = "frame-b"
|
||||
return server._sync_list(listing)(device)
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
|
||||
sync = fa.AutoSync(switch_then_list, mock.Mock(), switch_then_list, mock.Mock(),
|
||||
lambda: active["id"], lambda: False,
|
||||
path=Path(folder) / (kind + ".json"), clock=lambda: NOW)
|
||||
sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
sync.tick()
|
||||
listing.assert_not_called()
|
||||
sync.save_shots.assert_not_called()
|
||||
sync.export_clip.assert_not_called()
|
||||
self.assertEqual(sync._synced("frame-a", kind), set())
|
||||
self.assertEqual(sync._synced("frame-b", kind), set())
|
||||
self.assertIn("switched headsets", sync.state()["error"])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_listing_rejects_switch_before_reserving_work(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-b"}
|
||||
listing = mock.Mock(return_value={"shots": [shot(1, NOW, saved=True)]})
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
|
||||
with self.assertRaisesRegex(server.Failure, "switched headsets"):
|
||||
server._sync_list(listing)("frame-a")
|
||||
listing.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_listing_reserves_work_on_the_captured_device(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-a", "name": "Frame A"}
|
||||
def listing():
|
||||
self.assertGreater(server._work[0], 0)
|
||||
with self.assertRaises(server.Failure):
|
||||
server.devices_post({"action": "use", "id": "frame-b"})
|
||||
return {"shots": []}
|
||||
with mock.patch.object(server, "LINK", link), \
|
||||
mock.patch.object(server, "PRIVATE", False), mock.patch.object(server, "_work", [0]):
|
||||
self.assertEqual(server._sync_list(listing)("frame-a"), {"shots": []})
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_copy_rejects_switch_after_listing(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-a"}
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]), \
|
||||
mock.patch.object(server, "save_shots") as save, \
|
||||
mock.patch.object(server.frame_clips, "export_clip") as export:
|
||||
server._sync_list(lambda: {"shots": []})("frame-a")
|
||||
link.active_device.return_value = {"id": "frame-b"}
|
||||
for copy in (lambda: server._sync_shots("frame-a", [shot(1, NOW)["id"]]),
|
||||
lambda: server._sync_clip("frame-a", clip(1, NOW)["id"])):
|
||||
with self.assertRaisesRegex(server.Failure, "switched headsets"):
|
||||
copy()
|
||||
save.assert_not_called()
|
||||
export.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_route_and_settings(self):
|
||||
import server
|
||||
self.assertIn("/api/autosync", server.POST)
|
||||
@@ -287,5 +488,29 @@ class Server(unittest.TestCase):
|
||||
server.auto_sync({"action": "now"}) # nothing connected
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node exercises the Settings renderer")
|
||||
class Settings(unittest.TestCase):
|
||||
def test_resume_is_available_after_a_copy_error(self):
|
||||
page = (Path(__file__).resolve().parents[1] / "ui/index.html").read_text(encoding="utf-8")
|
||||
script = page.split("const autoSync =", 1)[1].split("async function loadAutoSync", 1)[0]
|
||||
harness = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const nodes = {};
|
||||
const $ = id => nodes[id] ||= {};
|
||||
const HOST = {mobile: false};
|
||||
const esc = value => value;
|
||||
'''
|
||||
checks = r'''
|
||||
for (const state of [{error: 'Could not copy clip'}, {paused: 'Disk full'}, {}]) {
|
||||
renderAutoSync(state);
|
||||
assert.equal($('syncResume').hidden, !(state.error || state.paused));
|
||||
if (state.error) assert.match($('syncStatus').textContent, /Could not copy clip/);
|
||||
}
|
||||
'''
|
||||
result = subprocess.run(["node", "-e", harness + "const autoSync =" + script + checks],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -113,6 +113,108 @@ class Remote(unittest.TestCase):
|
||||
os.symlink(str(outside), str(base / 'linked-file'))
|
||||
self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f])
|
||||
|
||||
def packed(self, part):
|
||||
out = io.BytesIO()
|
||||
remote.pack(part, out)
|
||||
out.seek(0)
|
||||
with tarfile.open(fileobj=out, mode='r:gz') as archive:
|
||||
return {m.name: archive.extractfile(m).read() for m in archive}
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_backup_rejects_every_ancestor_of_a_save_root(self):
|
||||
save = USER % 1234 + '/AppData/Roaming/Game/fixture.dat'
|
||||
# Each component below HOME, including the selected root itself.
|
||||
components = (USER % 1234 + '/AppData/Roaming').split('/')
|
||||
for end in range(1, len(components) + 1):
|
||||
ancestor = '/'.join(components[:end])
|
||||
with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp) / 'home'
|
||||
manifest(home, 1234, 'Synthetic game')
|
||||
path = write(home, save, b'synthetic fixture')
|
||||
linked = home / ancestor
|
||||
outside = Path(tmp) / 'outside/.ssh'
|
||||
outside.parent.mkdir()
|
||||
linked.rename(outside)
|
||||
linked.symlink_to(outside, target_is_directory=True)
|
||||
# Only synthetic bytes: the lexical archive path hides .ssh.
|
||||
self.assertEqual(path.read_bytes(), b'synthetic fixture')
|
||||
with patch.object(remote, 'HOME', home):
|
||||
self.assertEqual(self.files('saves:1234'), [])
|
||||
self.assertNotIn('saves:1234', [p['id'] for p in remote.plan()['parts']])
|
||||
self.assertEqual(self.packed('saves:1234'), {})
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_pack_rechecks_links_changed_after_discovery(self):
|
||||
save = USER % 1234 + '/Documents/Game/fixture.dat'
|
||||
for ancestor in (remote.COMPAT + '/1234', USER % 1234 + '/Documents', save):
|
||||
with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp) / 'home'
|
||||
write(home, save, b'original save')
|
||||
linked = home / ancestor
|
||||
outside = Path(tmp) / 'outside/.ssh'
|
||||
outside.parent.mkdir()
|
||||
part_files = remote.part_files
|
||||
|
||||
def discover_then_link(part):
|
||||
result = part_files(part)
|
||||
self.assertEqual(result[0], [(save, len(b'original save'))])
|
||||
linked.rename(outside)
|
||||
linked.symlink_to(outside, target_is_directory=outside.is_dir())
|
||||
(home / save).write_bytes(b'synthetic redirected bytes')
|
||||
return result
|
||||
|
||||
with patch.object(remote, 'HOME', home), \
|
||||
patch.object(remote, 'part_files', side_effect=discover_then_link):
|
||||
self.assertEqual(self.packed('saves:1234'), {})
|
||||
|
||||
def test_legitimate_steam_layout_and_reference_files_still_pack(self):
|
||||
frame_home(self.home)
|
||||
manifest(self.home, 2800000002, 'Synthetic non-Steam game')
|
||||
save = USER % 2800000002 + '/Saved Games/fixture.dat'
|
||||
write(self.home, save, b'non-Steam save')
|
||||
controller = STEAM + '/steamapps/common/Steam Controller Configs/555/config/layout.vdf'
|
||||
write(self.home, controller, b'controller layout')
|
||||
for part in ('saves:1234', 'saves:2800000002', 'saves:linux',
|
||||
'settings:steam', 'settings:system'):
|
||||
with self.subTest(part=part):
|
||||
expected = {rel: (self.home / rel).read_bytes() for rel in self.files(part)}
|
||||
self.assertTrue(expected)
|
||||
self.assertEqual(self.packed(part), expected)
|
||||
self.assertEqual(self.packed('saves:2800000002'), {save: b'non-Steam save'})
|
||||
self.assertIn(controller, self.packed('settings:steam'))
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_settings_discovery_and_manifest_reads_reject_links(self):
|
||||
frame_home(self.home)
|
||||
for ancestor, part in (('.config', 'settings:system'),
|
||||
(STEAM + '/userdata', 'settings:steam')):
|
||||
with self.subTest(ancestor=ancestor):
|
||||
linked = self.home / ancestor
|
||||
moved = linked.with_name(linked.name + '-original')
|
||||
linked.rename(moved)
|
||||
linked.symlink_to(moved, target_is_directory=True)
|
||||
self.assertEqual(self.files(part), [])
|
||||
self.assertEqual(self.packed(part), {})
|
||||
if part == 'settings:steam':
|
||||
self.assertEqual(remote.accounts(), [])
|
||||
appmanifest = self.home / STEAM / 'steamapps/appmanifest_1234.acf'
|
||||
moved = appmanifest.with_suffix('.original')
|
||||
appmanifest.rename(moved)
|
||||
appmanifest.symlink_to(moved)
|
||||
with patch.object(Path, 'read_text', side_effect=AssertionError('must not read a link')):
|
||||
self.assertIsNone(remote.game_name('1234'))
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_home_alias_and_unused_steam_alias_preserve_normal_backups(self):
|
||||
frame_home(self.home)
|
||||
alias = self.home / 'home-alias'
|
||||
alias.symlink_to(self.home, target_is_directory=True)
|
||||
(self.home / '.steam/steam').symlink_to(self.home / STEAM, target_is_directory=True)
|
||||
expected = self.packed('saves:1234')
|
||||
with patch.object(remote, 'HOME', alias):
|
||||
self.assertEqual(self.packed('saves:1234'), expected)
|
||||
self.assertEqual(remote.game_name('1234'), 'Test Game')
|
||||
|
||||
def test_big_files_are_listed_as_skipped(self):
|
||||
frame_home(self.home)
|
||||
with patch.object(remote, 'MAX_FILE', 3):
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Clip destinations through the server and automatic copying, without a headset."""
|
||||
import sandbox # noqa: F401
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_autosync
|
||||
import frame_clips
|
||||
import server
|
||||
|
||||
|
||||
IDS = ["1/clips/clip_620/video/bg", "2/clips/clip_620/video/bg",
|
||||
"1/clips/clip_620/video/other", "1/clips/other/video/bg"]
|
||||
NOW = 1800000000
|
||||
|
||||
|
||||
class ClipCopyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = Path(tempfile.mkdtemp(prefix="frame-clip-copy-"))
|
||||
self.addCleanup(shutil.rmtree, self.folder, ignore_errors=True)
|
||||
self.device = "frame-a"
|
||||
self.link = mock.Mock()
|
||||
self.link.active_device.side_effect = lambda: {"id": self.device, "name": self.device}
|
||||
self.link.snapshot.return_value = {"phase": "connected"}
|
||||
self.exports = []
|
||||
self.payload = None
|
||||
for name, value in (("LINK", self.link), ("LOCAL", False)):
|
||||
patch = mock.patch.object(server, name, value)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
patch = mock.patch.object(frame_clips, "VIDEOS_DIR", self.folder)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
for name, fn in (("_clips_frame", self.frame), ("fetch_file", self.fetch),
|
||||
("ssh", lambda *a, **k: "")):
|
||||
patch = mock.patch.object(server, name, side_effect=fn)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
(self.folder / "clip_620.mp4").write_bytes(b"legacy export")
|
||||
|
||||
def frame(self, args, timeout):
|
||||
if args == ["list"]:
|
||||
return {"clips": [{"id": cid, "kind": "clip", "time": NOW + 5, "idle": 60} for cid in IDS]}
|
||||
self.exports.append((self.device, args[1]))
|
||||
self.payload = f"{self.device}: {args[1]}".encode()
|
||||
return {"path": f"{frame_clips.WORK}/export-0123456789abcdef/clip_620.mp4",
|
||||
"size": len(self.payload)}
|
||||
|
||||
def fetch(self, remote, target):
|
||||
target.write_bytes(self.payload)
|
||||
|
||||
def test_manual_export_lists_only_matching_identity_and_keeps_job_shape(self):
|
||||
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()) as job:
|
||||
for device in ("frame-a", "frame-b"):
|
||||
self.device = device
|
||||
self.assertFalse(any(c["saved"] for c in server.list_clips()["clips"]))
|
||||
result = server.export_clip({"id": IDS[0]})
|
||||
self.assertEqual(set(result), {"message", "file"})
|
||||
self.assertEqual(Path(result["file"]).read_bytes(), f"{device}: {IDS[0]}".encode())
|
||||
self.assertEqual([c["saved"] for c in server.list_clips()["clips"]], [True, False, False, False])
|
||||
self.assertEqual(job.call_args.args[0], "Export clip_620.mp4")
|
||||
first = frame_clips.clip_local_path(IDS[0], "frame-a", self.folder)
|
||||
self.assertEqual(first.read_bytes(), f"frame-a: {IDS[0]}".encode())
|
||||
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
|
||||
|
||||
def test_auto_sync_uses_same_identity_as_manual_exports(self):
|
||||
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()):
|
||||
server.export_clip({"id": IDS[0]})
|
||||
sync = frame_autosync.AutoSync(
|
||||
server._sync_list(server.list_shots), server._sync_shots,
|
||||
server._sync_list(server.list_clips), server._sync_clip,
|
||||
server._sync_device, lambda: False, shots_folder=self.folder / "shots",
|
||||
clips_folder=self.folder, path=self.folder / "auto-sync.json", clock=lambda: NOW,
|
||||
space=lambda folder: 100 * 1024**3)
|
||||
sync.update({"clips": True})
|
||||
sync.tick()
|
||||
self.assertEqual(self.exports, [("frame-a", cid) for cid in IDS])
|
||||
self.device = "frame-b"
|
||||
sync.tick()
|
||||
self.assertEqual(self.exports, [(device, cid) for device in ("frame-a", "frame-b") for cid in IDS])
|
||||
sync.tick()
|
||||
self.assertEqual(len(self.exports), 8)
|
||||
for device in ("frame-a", "frame-b"):
|
||||
for cid in IDS:
|
||||
target = frame_clips.clip_local_path(cid, device, self.folder)
|
||||
self.assertEqual(target.read_bytes(), f"{device}: {cid}".encode())
|
||||
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
|
||||
|
||||
def test_delayed_job_refuses_a_different_headset(self):
|
||||
with mock.patch.object(server, "start_job", return_value={"job": "queued"}) as job:
|
||||
self.assertEqual(server.export_clip({"id": IDS[0]}), {"job": "queued"})
|
||||
self.device = "frame-b"
|
||||
with self.assertRaises(server.Failure) as error:
|
||||
job.call_args.args[1]()
|
||||
self.assertEqual(error.exception.status, 409)
|
||||
self.assertEqual(self.exports, [])
|
||||
|
||||
def test_local_and_headset_modes_reject_steam_clip_exports(self):
|
||||
for headset in (False, True):
|
||||
with self.subTest(headset=headset), mock.patch.object(server, "LOCAL", True), \
|
||||
mock.patch.object(server, "HEADSET_RUNTIME", headset):
|
||||
for action in (server.list_clips, lambda: server.export_clip({"id": IDS[0]}),
|
||||
lambda: server.auto_sync({"action": "now"})):
|
||||
with self.assertRaises(server.Failure) as error:
|
||||
action()
|
||||
self.assertEqual(error.exception.status, 400)
|
||||
self.assertEqual(self.exports, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+65
-8
@@ -71,6 +71,17 @@ class Ids(unittest.TestCase):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
clips.remove_command(bad)
|
||||
|
||||
def test_local_path_keeps_validated_identity_and_hashes_device_names(self):
|
||||
folder = Path("exports")
|
||||
target = clips.clip_local_path("123/clips/clip/video/session", "../../device:name", folder)
|
||||
self.assertRegex(target.parts[1], r"^headset-[0-9a-f]{12}$")
|
||||
self.assertEqual(target.parts[2:], ("123", "clips", "clip", "video", "session", "clip.mp4"))
|
||||
for bad in (None, "", 42):
|
||||
with self.subTest(device=bad), self.assertRaises(ValueError):
|
||||
clips.clip_local_path("123/video/bg", bad, folder)
|
||||
with self.assertRaises(ValueError):
|
||||
clips.clip_local_path("../clips/clip", "frame-a", folder)
|
||||
|
||||
def test_recording_names_checked(self):
|
||||
for fn in (clips.finish_command, clips.cleanup_command, lambda n: clips.record_command(n, True)):
|
||||
for bad in ("rec-1", "rec-20261020-201950; rm -rf ~", "../rec-20261020-201950", "*"):
|
||||
@@ -156,10 +167,10 @@ class Sessions(unittest.TestCase):
|
||||
|
||||
def test_listing_marks_what_is_already_here(self):
|
||||
here = self.dir / "here"
|
||||
write(here / "clip_620_20261020_202134.mp4")
|
||||
write(clips.clip_local_path("1/clips/clip_620_20261020_202134/video/bg", "frame-a", here))
|
||||
frame = lambda args, timeout: {"clips": [{"id": "1/clips/clip_620_20261020_202134/video/bg"},
|
||||
{"id": "1/video/bg_620_20261020_201950"}]}
|
||||
got = clips.list_clips(frame, folder=here)
|
||||
got = clips.list_clips(frame, folder=here, device_id="frame-a")
|
||||
self.assertEqual([c["saved"] for c in got["clips"]], [True, False])
|
||||
self.assertEqual(got["folder"], str(here))
|
||||
|
||||
@@ -261,12 +272,16 @@ class ComputerSide(unittest.TestCase):
|
||||
frame = lambda args, timeout: calls.append(("frame", args)) or {"path": path, "size": 3}
|
||||
run = lambda cmd, timeout: calls.append(("run", cmd))
|
||||
fetch = lambda remote, local: calls.append(("fetch", remote)) or Path(local).write_bytes(b"mp4")
|
||||
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir)
|
||||
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir, device_id="frame-a")
|
||||
self.assertEqual(got["message"], "Saved clip_620.mp4 to ~/Videos/SteamFrame")
|
||||
self.assertEqual((self.dir / "clip_620.mp4").read_bytes(), b"mp4")
|
||||
target = clips.clip_local_path("1/clips/clip_620/video/bg", "frame-a", self.dir)
|
||||
self.assertEqual(got["file"], str(target))
|
||||
self.assertEqual(set(got), {"message", "file"})
|
||||
self.assertEqual(target.read_bytes(), b"mp4")
|
||||
self.assertEqual([c[0] for c in calls], ["frame", "fetch", "run"])
|
||||
self.assertEqual(calls[2][1], clips.remove_command(path))
|
||||
self.assertEqual([p.name for p in self.dir.iterdir()], ["clip_620.mp4"])
|
||||
self.assertEqual(list(self.dir.rglob("*.mp4")), [target])
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
|
||||
def test_failed_copy_leaves_no_partial_file_and_still_cleans_up(self):
|
||||
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
@@ -277,20 +292,62 @@ class ComputerSide(unittest.TestCase):
|
||||
raise RuntimeError("connection lost")
|
||||
with self.assertRaisesRegex(RuntimeError, "connection lost"):
|
||||
clips.export_clip("1/clips/clip_620/video/bg", lambda a, t: {"path": path, "size": 4},
|
||||
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir)
|
||||
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir, device_id="frame-a")
|
||||
self.assertEqual(list(self.dir.iterdir()), [])
|
||||
self.assertEqual(ran, [clips.remove_command(path)])
|
||||
|
||||
def test_same_names_keep_each_account_headset_kind_and_nested_session(self):
|
||||
first = "1/clips/clip_620/video/bg"
|
||||
ids = [first, "2/clips/clip_620/video/bg", "1/clips/clip_620/video/other",
|
||||
"1/clips/clip_620/other/bg", "1/video/clip_620"]
|
||||
legacy = write(self.dir / "clip_620.mp4", b"legacy export")
|
||||
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
frame = lambda args, timeout: {"clips": [{"id": cid} for cid in ids]}
|
||||
stored = {}
|
||||
for device in ("frame-a", "frame-b"):
|
||||
self.assertFalse(any(c["saved"] for c in clips.list_clips(
|
||||
frame, folder=self.dir, device_id=device)["clips"]))
|
||||
for index, cid in enumerate(ids):
|
||||
payload = f"{device} session {index}".encode()
|
||||
result = clips.export_clip(cid, lambda a, t: {"path": remote, "size": len(payload)},
|
||||
lambda c, t: None, lambda r, p: p.write_bytes(payload),
|
||||
folder=self.dir, device_id=device)
|
||||
stored[Path(result["file"])] = payload
|
||||
listing = clips.list_clips(frame, folder=self.dir, device_id=device)
|
||||
self.assertEqual([c["saved"] for c in listing["clips"]],
|
||||
[n <= index for n in range(len(ids))])
|
||||
self.assertEqual(listing["folder"], str(self.dir))
|
||||
self.assertEqual(len(stored), len(ids) * 2)
|
||||
for path, payload in stored.items():
|
||||
self.assertEqual(path.read_bytes(), payload)
|
||||
self.assertEqual(legacy.read_bytes(), b"legacy export")
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
|
||||
def test_failed_reexport_preserves_completed_export(self):
|
||||
cid = "1/clips/clip_620/video/bg"
|
||||
target = write(clips.clip_local_path(cid, "frame-a", self.dir), b"completed")
|
||||
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
|
||||
def fetch(remote, local):
|
||||
local.write_bytes(b"partial")
|
||||
raise RuntimeError("connection lost")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "connection lost"):
|
||||
clips.export_clip(cid, lambda a, t: {"path": remote}, lambda c, t: None, fetch,
|
||||
folder=self.dir, device_id="frame-a")
|
||||
self.assertEqual(target.read_bytes(), b"completed")
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
|
||||
def test_bad_id_never_reaches_the_frame(self):
|
||||
never = mock.Mock(side_effect=AssertionError("reached the Frame"))
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir)
|
||||
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir, device_id="frame-a")
|
||||
|
||||
def test_unexpected_path_from_the_frame_refused(self):
|
||||
never = mock.Mock(side_effect=AssertionError("deleted or copied"))
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export_clip("1/video/bg", lambda a, t: {"path": ".ssh/id_ed25519", "size": 1}, never, never,
|
||||
folder=self.dir)
|
||||
folder=self.dir, device_id="frame-a")
|
||||
|
||||
|
||||
class Commands(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Extension compatibility and filesystem boundaries, without running plugin code."""
|
||||
import sandbox # noqa: F401
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_extensions
|
||||
|
||||
|
||||
class ExtensionFiles(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name) / 'extensions'
|
||||
self.root.mkdir()
|
||||
self.patch = patch.object(frame_extensions, 'root', return_value=self.root)
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def extension(self, name='test-panel', **fields):
|
||||
folder = self.root / name
|
||||
folder.mkdir(exist_ok=True)
|
||||
manifest = {'id': name, 'name': 'Test panel', 'version': '1.0.0',
|
||||
'description': 'A read-only panel', 'entry': 'index.html', **fields}
|
||||
(folder / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
|
||||
(folder / 'index.html').write_text('<script src="main.js"></script>', encoding='utf-8')
|
||||
(folder / 'main.js').write_text('/* extension code */', encoding='utf-8')
|
||||
return folder
|
||||
|
||||
def test_existing_and_explicit_v1_manifests_are_compatible(self):
|
||||
self.extension('legacy')
|
||||
self.extension('explicit', apiVersion=1)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['errors'], [])
|
||||
self.assertEqual([(x['id'], x['apiVersion']) for x in result['extensions']],
|
||||
[('explicit', 1), ('legacy', 1)])
|
||||
self.assertEqual(frame_extensions.asset('explicit', 'main.js'),
|
||||
(b'/* extension code */', 'text/javascript; charset=utf-8'))
|
||||
|
||||
def test_unsupported_or_malformed_api_cannot_be_loaded_directly(self):
|
||||
for version in (2, 0, -1, '1', None, True, 1.0, [], {}):
|
||||
with self.subTest(apiVersion=version):
|
||||
self.extension(apiVersion=version)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['extensions'], [])
|
||||
self.assertIn('unsupported apiVersion', result['errors'][0]['error'])
|
||||
with self.assertRaisesRegex(ValueError, 'unsupported apiVersion'):
|
||||
frame_extensions.asset('test-panel', 'index.html')
|
||||
|
||||
def test_one_incompatible_extension_does_not_hide_compatible_panels(self):
|
||||
self.extension('future', apiVersion=2)
|
||||
self.extension('working', apiVersion=1)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual([x['id'] for x in result['extensions']], ['working'])
|
||||
self.assertEqual(result['errors'][0]['folder'], 'future')
|
||||
|
||||
def test_sample_is_discoverable_and_its_assets_are_served(self):
|
||||
source = Path(__file__).resolve().parents[1] / 'examples/extensions/headset-glance'
|
||||
shutil.copytree(source, self.root / source.name)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['errors'], [])
|
||||
self.assertEqual(result['extensions'][0]['id'], source.name)
|
||||
for asset in ('index.html', 'main.js', 'style.css'):
|
||||
self.assertEqual(frame_extensions.asset(source.name, asset)[0],
|
||||
(source / asset).read_bytes())
|
||||
|
||||
def test_private_nested_and_traversal_paths_are_rejected(self):
|
||||
self.extension()
|
||||
for name in ('manifest.json', '../outside.js', 'sub/main.js', '.hidden.js'):
|
||||
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', name)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('../test-panel', 'main.js')
|
||||
|
||||
def test_oversized_assets_are_not_read(self):
|
||||
folder = self.extension()
|
||||
with (folder / 'large.js').open('wb') as output:
|
||||
output.truncate(5 * 1024 * 1024 + 1)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', 'large.js')
|
||||
|
||||
def test_symlinks_cannot_expose_files_outside_a_panel(self):
|
||||
folder = self.extension()
|
||||
outside = Path(self.tmp.name) / 'outside.js'
|
||||
outside.write_text('private file', encoding='utf-8')
|
||||
try:
|
||||
(folder / 'escape.js').symlink_to(outside)
|
||||
(self.root / 'alias').symlink_to(folder, target_is_directory=True)
|
||||
except OSError:
|
||||
self.skipTest('symlinks unavailable for this user')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', 'escape.js')
|
||||
with self.assertRaises(ValueError):
|
||||
frame_extensions.asset('alias', 'main.js')
|
||||
(folder / 'manifest.json').unlink()
|
||||
(folder / 'manifest.json').symlink_to(outside)
|
||||
with self.assertRaises(ValueError):
|
||||
frame_extensions.asset('test-panel', 'main.js')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -126,6 +126,24 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(code, 400)
|
||||
self.assertIn('experimental', body['error'])
|
||||
|
||||
def test_incompatible_extension_is_reported_and_assets_are_unavailable(self):
|
||||
folder = Path(os.environ['FRAME_CONTROL_DATA_DIR']) / 'extensions' / 'future-panel'
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / 'manifest.json').write_text(json.dumps({'id': 'future-panel', 'name': 'Future',
|
||||
'version': '1.0', 'description': 'Requires a newer API', 'apiVersion': 2}))
|
||||
(folder / 'index.html').write_text('<p>Incompatible panel</p>')
|
||||
try:
|
||||
self.assertEqual(self.request('GET', '/api/extensions')[0], 403)
|
||||
code, _, body = self.request('GET', '/api/extensions', headers={'X-Frame-UI': '1'})
|
||||
self.assertEqual(code, 200)
|
||||
data = json.loads(body)
|
||||
self.assertNotIn('future-panel', [x['id'] for x in data['extensions']])
|
||||
self.assertTrue(any(x['folder'] == 'future-panel' and 'apiVersion' in x['error']
|
||||
for x in data['errors']))
|
||||
self.assertEqual(self.request('GET', '/extension/future-panel/index.html')[0], 404)
|
||||
finally:
|
||||
shutil.rmtree(folder)
|
||||
|
||||
def test_headset_page_and_install_acknowledgement(self):
|
||||
code, _, body = self.request('GET', '/headset')
|
||||
self.assertEqual(code, 200)
|
||||
|
||||
+161
-9
@@ -119,15 +119,6 @@ class Safety(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, 'survived cleanup'):
|
||||
stop_processes.stop(['fc_smoke_exe'], grace=0)
|
||||
|
||||
def test_cleanup_scans_even_when_title_already_removed(self):
|
||||
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[]), \
|
||||
mock.patch.object(frame_smoke, 'ssh') as ssh, \
|
||||
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': [42]}) as stop:
|
||||
result = frame_smoke.cleanup(frame_smoke.Smoke())
|
||||
self.assertEqual(stop.call_args_list, [mock.call(stop_processes.IDS)] * 2)
|
||||
self.assertEqual(result['process_cleanup'][0]['stopped_pids'], [42])
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_stop_failure_prevents_file_removal(self):
|
||||
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[{'id': 'fc_smoke_exe'}]), \
|
||||
mock.patch.object(frame_smoke, 'ssh') as ssh, \
|
||||
@@ -151,3 +142,164 @@ class Safety(unittest.TestCase):
|
||||
self.assertEqual(frame_smoke.main(), 1)
|
||||
cycle.assert_not_called()
|
||||
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'posix', 'local POSIX shell required')
|
||||
class OrphanCleanup(unittest.TestCase):
|
||||
"""Run the real cleanup shell commands in a throwaway home; mock Steam/SSH."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
self.games = self.home / 'devkit-game'
|
||||
self.games.mkdir()
|
||||
self.shortcuts = set()
|
||||
self.sync_outputs = None
|
||||
self.events = []
|
||||
self.smoke = frame_smoke.Smoke()
|
||||
for target, name, kwargs in (
|
||||
(frame_smoke, 'ssh', {'side_effect': self.local_ssh}),
|
||||
(frame_smoke, 'stop_titles', {'side_effect': self.stop}),
|
||||
(frame_smoke.frame_titles, 'list_titles', {'return_value': []}),
|
||||
(frame_smoke.frame_titles, 'remove', {'side_effect': self.remove}),
|
||||
(frame_smoke.frame_titles, 'ensure_utils', {}),
|
||||
(frame_smoke.frame_titles, 'devkit_script', {'return_value': 'smoke-sync'})):
|
||||
patcher = mock.patch.object(target, name, **kwargs)
|
||||
setattr(self, name + '_mock', patcher.start())
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def stop(self, ids):
|
||||
self.assertEqual(ids, stop_processes.IDS)
|
||||
self.events.append('stop')
|
||||
return {'stopped_pids': [42] if len(self.events) == 1 else []}
|
||||
|
||||
def remove(self, gid):
|
||||
self.assertIn(gid, stop_processes.IDS)
|
||||
self.events.append('remove')
|
||||
shutil.rmtree(self.games / gid)
|
||||
|
||||
def local_ssh(self, command, timeout=60):
|
||||
self.events.append(command)
|
||||
self.assertEqual(self.events[0], 'stop', 'processes must stop before file cleanup')
|
||||
if command == 'smoke-sync 2>&1':
|
||||
if self.sync_outputs is not None:
|
||||
return self.sync_outputs.pop(0)
|
||||
# Valve logs the registered IDs before reconciling with disk.
|
||||
out = '\n'.join(f'Found Devkit Game registered with Steam Client: {gid!r}'
|
||||
for gid in sorted(self.shortcuts))
|
||||
self.shortcuts = {gid for gid in self.shortcuts if (self.games / gid).is_dir()}
|
||||
return out
|
||||
return subprocess.run(command, shell=True, check=True, capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
env={**os.environ, 'HOME': str(self.home)}).stdout
|
||||
|
||||
def metadata(self, gid):
|
||||
paths = [self.games / f'{gid}-{kind}.json'
|
||||
for kind in ('argv', 'env', 'settings', 'framecontrol')]
|
||||
for path in paths:
|
||||
path.write_text('{}')
|
||||
return paths
|
||||
|
||||
def test_orphan_metadata_for_every_reserved_id_is_removed_and_shortcuts_rechecked(self):
|
||||
paths = [path for gid in stop_processes.IDS for path in self.metadata(gid)]
|
||||
self.shortcuts = set(stop_processes.IDS)
|
||||
result = frame_smoke.cleanup(self.smoke)
|
||||
self.assertTrue(all(not path.exists() for path in paths))
|
||||
self.assertFalse(self.shortcuts)
|
||||
self.assertEqual(result['removed'], sorted(stop_processes.IDS))
|
||||
self.assertEqual(result['process_cleanup'], [{'stopped_pids': [42]}, {'stopped_pids': []}])
|
||||
self.assertEqual(self.events[-1], 'stop')
|
||||
self.assertEqual(self.events.count('smoke-sync 2>&1'), 2)
|
||||
self.ensure_utils_mock.assert_called_once_with()
|
||||
self.remove_mock.assert_not_called()
|
||||
|
||||
def test_shortcut_only_leftover_is_synced_without_title_or_install_record(self):
|
||||
self.shortcuts = {'fc_smoke_exe'}
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertFalse(self.shortcuts)
|
||||
self.assertEqual(self.events.count('smoke-sync 2>&1'), 2)
|
||||
|
||||
def test_unrelated_titles_and_metadata_survive_even_if_in_install_tracker(self):
|
||||
others = ('portal2', 'fc_smoke_custom', 'fc_smoke_exe-other', 'fc_smoke_exe_other')
|
||||
paths = []
|
||||
for gid in ('fc_smoke_exe', *others):
|
||||
(self.games / gid).mkdir()
|
||||
paths.extend(self.metadata(gid) if gid in others else [])
|
||||
self.shortcuts = {'fc_smoke_exe', *others}
|
||||
self.smoke.installed = {'fc_smoke_exe', *others, 'fc_smoke_exe/../portal2'}
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertFalse((self.games / 'fc_smoke_exe').exists())
|
||||
self.assertTrue(all((self.games / gid).is_dir() for gid in others))
|
||||
self.assertTrue(all(path.exists() for path in paths))
|
||||
self.assertEqual(self.shortcuts, set(others))
|
||||
self.assertNotIn('fc_smoke_exe', self.smoke.installed)
|
||||
self.remove_mock.assert_called_once_with('fc_smoke_exe')
|
||||
|
||||
def test_still_registered_current_or_legacy_shortcut_fails_cleanup(self):
|
||||
for gid in sorted(stop_processes.IDS):
|
||||
with self.subTest(gid=gid):
|
||||
self.sync_outputs = ['', f'Found Devkit Game registered with Steam Client: {gid!r}']
|
||||
self.smoke.installed = {gid}
|
||||
with self.assertRaisesRegex(AssertionError, f'{gid} is still registered with Steam'):
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertIn(gid, self.smoke.installed)
|
||||
|
||||
def test_sync_failure_in_either_pass_fails_cleanup(self):
|
||||
for failed_pass in (0, 1):
|
||||
with self.subTest(failed_pass=failed_pass):
|
||||
self.sync_outputs = ['', '']
|
||||
self.sync_outputs[failed_pass] = 'Steam client sync of devkit games failed: offline'
|
||||
self.smoke.installed = {'fc_smoke_exe'}
|
||||
with self.assertRaisesRegex(AssertionError, 'sync of devkit games failed'):
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertIn('fc_smoke_exe', self.smoke.installed)
|
||||
|
||||
def test_metadata_remaining_after_delete_fails_cleanup(self):
|
||||
self.metadata('fc_smoke_exe')
|
||||
real_ssh = self.local_ssh
|
||||
with mock.patch.object(frame_smoke, 'ssh', side_effect=lambda command, **kwargs:
|
||||
'' if command.startswith('rm -f ') else real_ssh(command, **kwargs)):
|
||||
with self.assertRaisesRegex(AssertionError, 'fc_smoke_exe is still on the Frame'):
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
|
||||
def test_missing_utils_cannot_report_success_without_shortcut_verification(self):
|
||||
self.ensure_utils_mock.side_effect = frame_smoke.frame_android.FrameError('copy failed')
|
||||
with self.assertRaisesRegex(AssertionError, "syncing Steam's shortcuts: copy failed"):
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertEqual(self.events[-1], 'stop')
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'posix', 'POSIX wrapper')
|
||||
class PortableWrapper(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix='smoke wrapper ')
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
# macOS aliases /var to /private/var; relative commands need the physical cwd.
|
||||
self.folder = Path(self.tmp.name).resolve()
|
||||
self.bin = self.folder / 'bin'
|
||||
self.bin.mkdir()
|
||||
# No zsh or SSH on PATH: --help must exit before any headset access.
|
||||
(self.bin / 'dirname').symlink_to(shutil.which('dirname'))
|
||||
self.env = {**os.environ, 'PATH': str(self.bin), 'CDPATH': str(self.folder)}
|
||||
self.wrapper = SMOKE.parent.parent / 'scripts' / 'frame-smoke.sh'
|
||||
|
||||
def test_help_runs_directly_and_with_posix_sh_from_another_cwd_without_zsh(self):
|
||||
(self.bin / 'python3').symlink_to(sys.executable)
|
||||
for launcher in ([], ['/bin/sh']):
|
||||
with self.subTest(launcher=launcher):
|
||||
result = subprocess.run([*launcher, str(self.wrapper), '--help'], cwd=self.folder,
|
||||
env=self.env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('--pair', result.stdout)
|
||||
self.assertIn('Headset smoke test', result.stdout)
|
||||
|
||||
def test_relative_invocation_preserves_arguments_and_python_exit_status(self):
|
||||
python = self.bin / 'python3'
|
||||
python.write_text('#!/bin/sh\nprintf "<%s>\\n" "$@"\nexit 7\n')
|
||||
python.chmod(0o755)
|
||||
result = subprocess.run([os.path.relpath(self.wrapper, self.folder), '--pair', 'two words', ''],
|
||||
cwd=self.folder, env=self.env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 7, result.stderr)
|
||||
self.assertEqual(result.stdout.splitlines(),
|
||||
[f'<{SMOKE / "frame_smoke.py"}>', '<--pair>', '<two words>', '<>'])
|
||||
+56
-12
@@ -16,9 +16,9 @@ saved Steam clips to ~/Videos/SteamFrame.
|
||||
- When the headset can't be reached it waits longer each time, up to MAX_BACKOFF.
|
||||
|
||||
The server supplies how to reach the headset, so this can be tested without one:
|
||||
list_shots() -> {"shots": [{"id", "time", "saved"}...]}
|
||||
list_shots(device) -> {"shots": [{"id", "time", "saved"}...]}
|
||||
save_shots(device, ids) copies those screenshots here
|
||||
list_clips() -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
|
||||
list_clips(device) -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
|
||||
export_clip(device, id) exports one clip here
|
||||
device() -> the connected headset's id, or None
|
||||
busy() -> True while other work runs on the headset (installs, jobs, a recording)
|
||||
@@ -41,7 +41,7 @@ SETTLE = 30 # a clip written to within this many seconds is still being recorde
|
||||
MIN_FREE = 512 * 1024**2 # pause below this much free space on this computer
|
||||
KEEP = 5000 # copied ids remembered per headset and kind
|
||||
BATCH = 100 # screenshots per copy
|
||||
TRIES = 3 # an item that fails this often is left until the app restarts
|
||||
TRIES = 3 # failed attempts per item until Resume, Copy everything now, or restart
|
||||
KINDS = ("shots", "clips")
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ class AutoSync:
|
||||
self._running = False
|
||||
self._backoff = 0
|
||||
self._tries = {} # (device, id) -> failed attempts this run
|
||||
self._failures = {} # last error for each failed item, including exhausted retries
|
||||
self._errors = [] # this pass's, for the page
|
||||
self._s = self._load()
|
||||
|
||||
@@ -143,13 +144,19 @@ class AutoSync:
|
||||
"""Copy everything on the headset that isn't on this computer, once (also resumes)."""
|
||||
with self._lock:
|
||||
self._all, self._s["paused"] = True, None
|
||||
self._tries.clear()
|
||||
self._failures.clear()
|
||||
self._s["error"] = None
|
||||
self._save()
|
||||
self._backoff = 0
|
||||
self._wake.set()
|
||||
return self.state()
|
||||
|
||||
def resume(self):
|
||||
with self._lock:
|
||||
self._s["paused"], self._s["error"] = None, None
|
||||
self._tries.clear()
|
||||
self._failures.clear()
|
||||
self._save()
|
||||
self._backoff = 0
|
||||
self._wake.set()
|
||||
@@ -215,6 +222,9 @@ class AutoSync:
|
||||
|
||||
def _mark(self, device, kind, ids):
|
||||
with self._lock:
|
||||
for item in ids:
|
||||
self._tries.pop((device, item), None)
|
||||
self._failures.pop((device, item), None)
|
||||
per = self._s["synced"].setdefault(device, {})
|
||||
have = per.get(kind, [])
|
||||
new = [i for i in ids if i not in set(have)]
|
||||
@@ -233,24 +243,46 @@ class AutoSync:
|
||||
"then press Resume.")
|
||||
if self.offline(error):
|
||||
raise error
|
||||
key = (device, item)
|
||||
self._tries[key] = self._tries.get(key, 0) + 1
|
||||
self._errors.append(f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}")
|
||||
with self._lock:
|
||||
key = (device, item)
|
||||
self._tries[key] = self._tries.get(key, 0) + 1
|
||||
message = f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}"
|
||||
if self._tries[key] >= TRIES:
|
||||
message += ". Automatic retries stopped. Press Resume to try again."
|
||||
self._failures[key] = message
|
||||
self._errors.append(message)
|
||||
|
||||
def _retry_allowed(self, device, item):
|
||||
with self._lock:
|
||||
key = (device, item)
|
||||
if self._tries.get(key, 0) < TRIES:
|
||||
return True
|
||||
self._errors.append(self._failures[key])
|
||||
return False
|
||||
|
||||
def _interrupted(self, device):
|
||||
return self.busy() or self.device() != device
|
||||
|
||||
def _pass(self, device, shots, clips, since):
|
||||
"""-> (copied, clips still being written, finished without stopping for other work)."""
|
||||
copied = waiting = 0
|
||||
if shots:
|
||||
listing = self.list_shots()["shots"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
listing = self.list_shots(device)["shots"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
done = self._synced(device, "shots")
|
||||
self._mark(device, "shots", [s["id"] for s in listing if s.get("saved")])
|
||||
todo = [s["id"] for s in sorted(listing, key=lambda s: s["time"])
|
||||
if not s.get("saved") and (since is None or (s["time"] >= since and s["id"] not in done))
|
||||
and self._tries.get((device, s["id"]), 0) < TRIES]
|
||||
and self._retry_allowed(device, s["id"])]
|
||||
for i in range(0, len(todo), BATCH):
|
||||
if self.busy():
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
self._room(self.shots_folder)
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
batch = todo[i:i + BATCH]
|
||||
try:
|
||||
self.save_shots(device, batch)
|
||||
@@ -258,28 +290,40 @@ class AutoSync:
|
||||
for item in batch:
|
||||
self._failed(device, item, e)
|
||||
continue
|
||||
if self.device() != device:
|
||||
return copied, waiting, False
|
||||
self._mark(device, "shots", batch)
|
||||
copied += len(batch)
|
||||
if clips:
|
||||
listing = [c for c in self.list_clips()["clips"] if c.get("kind") == "clip"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
listing = [c for c in self.list_clips(device)["clips"] if c.get("kind") == "clip"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
done = self._synced(device, "clips")
|
||||
self._mark(device, "clips", [c["id"] for c in listing if c.get("saved")])
|
||||
for c in sorted(listing, key=lambda c: c["time"]):
|
||||
if c.get("saved") or self._tries.get((device, c["id"]), 0) >= TRIES:
|
||||
if c.get("saved"):
|
||||
continue
|
||||
if since is not None and (c["time"] < since or c["id"] in done):
|
||||
continue
|
||||
if not self._retry_allowed(device, c["id"]):
|
||||
continue
|
||||
if c.get("idle") is None or c["idle"] < SETTLE:
|
||||
waiting += 1 # still being written: copied on a later look
|
||||
continue
|
||||
if self.busy():
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
self._room(self.clips_folder)
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
try:
|
||||
self.export_clip(device, c["id"])
|
||||
except Exception as e:
|
||||
self._failed(device, c["id"], e)
|
||||
continue
|
||||
if self.device() != device:
|
||||
return copied, waiting, False
|
||||
self._mark(device, "clips", [c["id"]])
|
||||
copied += 1
|
||||
return copied, waiting, True
|
||||
+60
-17
@@ -19,6 +19,7 @@ import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
@@ -88,28 +89,57 @@ def _under(rel, root):
|
||||
return rel == root or rel.startswith(root + '/')
|
||||
|
||||
|
||||
def _backup_stat(rel):
|
||||
"""lstat a backup path only if every component below trusted HOME is link-free.
|
||||
|
||||
Rechecked at discovery and before reads; not atomic against concurrent renames.
|
||||
HOME itself may use the host's normal home/temp-directory aliases.
|
||||
"""
|
||||
if not safe_rel(rel):
|
||||
return None
|
||||
path = HOME
|
||||
try:
|
||||
for component in rel.split('/'):
|
||||
path = path / component
|
||||
st = path.lstat()
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
return None
|
||||
return st
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _backup_dir(rel):
|
||||
st = _backup_stat(rel)
|
||||
return st is not None and stat.S_ISDIR(st.st_mode)
|
||||
|
||||
|
||||
def _walk(rel_root, skip=()):
|
||||
"""Regular files under HOME/rel_root, never following links: [(rel, size)], [skipped]."""
|
||||
files, skipped = [], []
|
||||
base = HOME / rel_root
|
||||
if base.is_symlink() or not base.is_dir():
|
||||
if base.is_file() and not base.is_symlink():
|
||||
return [(rel_root, base.stat().st_size)], skipped
|
||||
st = _backup_stat(rel_root)
|
||||
if st is None or is_secret(rel_root):
|
||||
return files, skipped
|
||||
if stat.S_ISREG(st.st_mode):
|
||||
if st.st_size > MAX_FILE:
|
||||
skipped.append({'path': rel_root, 'reason': 'over 2 GiB'})
|
||||
return files, skipped
|
||||
return [(rel_root, st.st_size)], skipped
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
return files, skipped
|
||||
for top, dirs, names in os.walk(str(base)):
|
||||
rel_top = PurePosixPath(rel_root, *Path(top).relative_to(base).parts).as_posix()
|
||||
dirs[:] = sorted(d for d in dirs if not os.path.islink(os.path.join(top, d)) and
|
||||
if not _backup_dir(rel_top):
|
||||
dirs[:] = []
|
||||
continue
|
||||
dirs[:] = sorted(d for d in dirs if _backup_dir(rel_top + '/' + d) and
|
||||
not any(_under(rel_top + '/' + d, s) for s in skip) and
|
||||
not is_secret(rel_top + '/' + d + '/x'))
|
||||
for name in sorted(names):
|
||||
path, rel = os.path.join(top, name), rel_top + '/' + name
|
||||
if os.path.islink(path) or is_secret(rel):
|
||||
continue
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if not os.path.isfile(path):
|
||||
rel = rel_top + '/' + name
|
||||
st = _backup_stat(rel)
|
||||
if st is None or not stat.S_ISREG(st.st_mode) or is_secret(rel):
|
||||
continue
|
||||
if st.st_size > MAX_FILE:
|
||||
skipped.append({'path': rel, 'reason': 'over 2 GiB'})
|
||||
@@ -124,26 +154,34 @@ def _manifest_field(text, key):
|
||||
|
||||
|
||||
def game_name(appid):
|
||||
rel = '%s/steamapps/appmanifest_%s.acf' % (STEAM, appid)
|
||||
st = _backup_stat(rel)
|
||||
if st is None or not stat.S_ISREG(st.st_mode):
|
||||
return None
|
||||
try:
|
||||
text = (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).read_text(errors='replace')
|
||||
text = (HOME / rel).read_text(errors='replace')
|
||||
except OSError:
|
||||
return None
|
||||
return _manifest_field(text, 'name')
|
||||
|
||||
|
||||
def accounts():
|
||||
root = HOME / STEAM / 'userdata'
|
||||
rel = STEAM + '/userdata'
|
||||
if not _backup_dir(rel):
|
||||
return []
|
||||
try:
|
||||
return sorted(p.name for p in root.iterdir() if p.name.isdigit() and p.is_dir()
|
||||
and not p.is_symlink() and p.name != '0')
|
||||
return sorted(p.name for p in (HOME / rel).iterdir() if p.name.isdigit() and
|
||||
p.name != '0' and _backup_dir(rel + '/' + p.name))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _prefixes():
|
||||
if not _backup_dir(COMPAT):
|
||||
return []
|
||||
try:
|
||||
return sorted((p.name for p in (HOME / COMPAT).iterdir()
|
||||
if p.name.isdigit() and (p / PREFIX_USER).is_dir()), key=int)
|
||||
if p.name.isdigit() and _backup_dir(COMPAT + '/' + p.name + '/' + PREFIX_USER)), key=int)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
@@ -244,9 +282,14 @@ def pack(part, output):
|
||||
with tarfile.open(fileobj=output, mode='w|gz') as archive:
|
||||
for rel, _ in files:
|
||||
path = HOME / rel
|
||||
st = _backup_stat(rel)
|
||||
if st is None or not stat.S_ISREG(st.st_mode) or is_secret(rel):
|
||||
continue
|
||||
try:
|
||||
with open(str(path), 'rb') as src:
|
||||
st = os.fstat(src.fileno())
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_FILE:
|
||||
continue
|
||||
member = tarfile.TarInfo(rel)
|
||||
member.size, member.mtime, member.mode = st.st_size, int(st.st_mtime), st.st_mode & 0o755
|
||||
archive.addfile(member, src)
|
||||
|
||||
+20
-7
@@ -26,6 +26,7 @@ that stream. The folder names are as users and Steam's own files describe them;
|
||||
listing doesn't depend on them: any folder under clips/ or video/ (up to four levels)
|
||||
holding init-stream*.m4s counts as a session.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -363,10 +364,20 @@ def parse_id(clip_id):
|
||||
|
||||
|
||||
def export_name(clip_id):
|
||||
"""The .mp4's name on the computer: the clip's (or background session's) own folder name."""
|
||||
"""The .mp4's basename; it alone does not identify a saved export."""
|
||||
return parse_id(clip_id)[2] + ".mp4"
|
||||
|
||||
|
||||
def clip_local_path(clip_id, device_id, folder=None):
|
||||
"""Keep the headset, account and full Steam session identity in the destination."""
|
||||
parts = parse_id(clip_id)
|
||||
if not isinstance(device_id, str) or not device_id:
|
||||
raise ValueError("missing clip headset identity")
|
||||
headset = hashlib.sha256(device_id.encode("utf-8")).hexdigest()[:12]
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
return folder / f"headset-{headset}" / Path(*parts) / export_name(clip_id)
|
||||
|
||||
|
||||
def read_session(folder):
|
||||
"""{stream number: [init, chunk, chunk...]} for one session folder, chunks in number
|
||||
order. A stream without its init segment or without chunks can't be played, so it's left out."""
|
||||
@@ -552,19 +563,20 @@ def remove_command(path):
|
||||
# run_frame(args, timeout) runs this file on the Frame with `args` and returns its JSON;
|
||||
# run(command, timeout) and fetch(remote, local) as for Recorder.
|
||||
|
||||
def list_clips(run_frame, folder=None):
|
||||
def list_clips(run_frame, folder=None, *, device_id):
|
||||
"""Steam's recordings on the Frame, each marked saved if its .mp4 is already here."""
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
clips = run_frame(["list"], 60)["clips"]
|
||||
for c in clips:
|
||||
c["saved"] = (folder / export_name(c["id"])).exists()
|
||||
c["saved"] = clip_local_path(c["id"], device_id, folder).is_file()
|
||||
return {"clips": clips, "folder": str(folder)}
|
||||
|
||||
|
||||
def export_clip(clip_id, run_frame, run, fetch, folder=None):
|
||||
def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
"""Stitch a Steam recording on the Frame and copy the .mp4 here. -> {"message", "file"}."""
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
name = export_name(clip_id) # checks the id before anything reaches the Frame
|
||||
target = clip_local_path(clip_id, device_id, folder) # validate before reaching the Frame
|
||||
name = target.name
|
||||
made = run_frame(["export", clip_id], 1900)
|
||||
path = made.get("path")
|
||||
remove = remove_command(path) # refuses a path that isn't one of our exports
|
||||
@@ -573,7 +585,8 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None):
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=folder))
|
||||
try:
|
||||
fetch(path, incoming / name)
|
||||
os.replace(incoming / name, folder / name)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(incoming / name, target)
|
||||
finally:
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
finally:
|
||||
@@ -581,7 +594,7 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None):
|
||||
run(remove, 30)
|
||||
except Exception:
|
||||
pass # the next export sweeps it
|
||||
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(folder / name)}
|
||||
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(target)}
|
||||
|
||||
|
||||
# ---- on the Frame -----------------------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@ import frame_host
|
||||
|
||||
|
||||
ID = re.compile(r"[a-z][a-z0-9-]{0,39}\Z")
|
||||
API_VERSION = 1
|
||||
ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
|
||||
TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8", ".json": "application/json",
|
||||
@@ -27,6 +28,11 @@ def _manifest(folder):
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or data.get("id") != folder.name:
|
||||
raise ValueError("manifest id must match its folder")
|
||||
# Older manifests predate this field and use bridge v1. Check again when
|
||||
# serving assets, so an incompatible panel cannot bypass discovery.
|
||||
api_version = data.get("apiVersion", 1)
|
||||
if type(api_version) is not int or api_version != API_VERSION:
|
||||
raise ValueError(f"unsupported apiVersion; this app supports {API_VERSION}")
|
||||
for key, limit in (("name", 80), ("version", 40), ("description", 240)):
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
@@ -34,7 +40,8 @@ def _manifest(folder):
|
||||
entry = data.get("entry", "index.html")
|
||||
if entry != "index.html" or (folder / entry).is_symlink() or not (folder / entry).is_file():
|
||||
raise ValueError("entry must be a regular index.html")
|
||||
return {key: data[key] for key in ("id", "name", "version", "description")}
|
||||
return {**{key: data[key] for key in ("id", "name", "version", "description")},
|
||||
"apiVersion": api_version}
|
||||
|
||||
|
||||
def list_extensions():
|
||||
|
||||
+1
-1
@@ -5610,7 +5610,7 @@ const syncWhen = t => new Date(t * 1000).toLocaleString([], { dateStyle: "medium
|
||||
function renderAutoSync(s) {
|
||||
autoSync.s = s;
|
||||
$("syncShots").checked = s.screenshots; $("syncClips").checked = s.clips;
|
||||
$("syncResume").hidden = !s.paused;
|
||||
$("syncResume").hidden = !(s.paused || s.error);
|
||||
const on = s.screenshots || s.clips, lines = [];
|
||||
if (s.paused) lines.push(s.paused);
|
||||
else if (s.error) lines.push(`Last problem: ${s.error}`);
|
||||
|
||||
+10
-6
@@ -928,7 +928,9 @@ def _clips_frame(args, timeout):
|
||||
def list_clips():
|
||||
_clips_here()
|
||||
try:
|
||||
return frame_clips.list_clips(_clips_frame)
|
||||
device = agent_target()["id"]
|
||||
with working(device):
|
||||
return frame_clips.list_clips(_clips_frame, device_id=device)
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
|
||||
@@ -938,11 +940,13 @@ def export_clip(body):
|
||||
_clips_here()
|
||||
clip_id = body.get("id")
|
||||
name = frame_clips.export_name(clip_id) # ValueError, so 400, for a bad id
|
||||
device = agent_target()["id"]
|
||||
|
||||
def work():
|
||||
try:
|
||||
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
|
||||
fetch_file)
|
||||
with working(device):
|
||||
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
|
||||
fetch_file, device_id=device)
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
return start_job(f"Export {name}", work)
|
||||
@@ -965,12 +969,12 @@ def _sync_shots(device, ids):
|
||||
def _sync_clip(device, clip_id):
|
||||
with working(device):
|
||||
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
|
||||
fetch_file)
|
||||
fetch_file, device_id=device)
|
||||
|
||||
|
||||
def _sync_list(listing):
|
||||
def run():
|
||||
with working(_sync_device()):
|
||||
def run(device):
|
||||
with working(device):
|
||||
return listing()
|
||||
return run
|
||||
|
||||
|
||||
Reference in new issue
Block a user