mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 13:01:47 +01:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2bb178d5 | ||
|
|
b1f1880736 | ||
|
|
338726d581 | ||
|
|
32727fc6e5 |
No files matched your search
@@ -2,12 +2,6 @@
|
||||
|
||||
## 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;
|
||||
@@ -24,11 +18,6 @@
|
||||
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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.30",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.30",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# What we have tested
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
| 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,8 +1,5 @@
|
||||
# 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
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
"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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,141 +0,0 @@
|
||||
# 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.
|
||||
+5
-28
@@ -69,31 +69,11 @@ 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` under `~/Videos/SteamFrame`, named after
|
||||
its clip folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
**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.
|
||||
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.
|
||||
|
||||
@@ -176,10 +156,8 @@ see the [test record](evidence/autosync-reliability-2026-10-25.md).
|
||||
`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, 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_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_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.
|
||||
@@ -202,8 +180,7 @@ 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`, `tests/test_clip_copy.py` for server destinations
|
||||
and collision coverage, and `tests/test_autosync.py` for automatic
|
||||
Tests: `tests/test_clips.py`, 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
|
||||
|
||||
+4
-28
@@ -203,11 +203,6 @@ 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
|
||||
@@ -228,14 +223,6 @@ 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.
|
||||
@@ -259,23 +246,12 @@ 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%. 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).
|
||||
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.
|
||||
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. 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:
|
||||
exit, escalation and survival of unrelated processes without SSH or a headset:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env zsh
|
||||
# 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 -eu
|
||||
smoke_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd -P)
|
||||
exec python3 "$smoke_root/tests/smoke/frame_smoke.py" "$@"
|
||||
set -euo pipefail
|
||||
exec python3 "${0:A:h:h}/tests/smoke/frame_smoke.py" "$@"
|
||||
@@ -203,23 +203,23 @@ def cleanup(smoke):
|
||||
never removed. Raises if any owned process, file or shortcut stays.
|
||||
"""
|
||||
problems = []
|
||||
# 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
|
||||
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
|
||||
# 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 {metadata}')
|
||||
if ssh(f'ls -d ~/devkit-game/{gid} {metadata} 2>/dev/null || true').strip():
|
||||
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():
|
||||
problems.append(f'{gid} is still on the Frame')
|
||||
else:
|
||||
gone.add(gid)
|
||||
@@ -230,8 +230,6 @@ 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)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""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()
|
||||
+8
-65
@@ -71,17 +71,6 @@ 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", "*"):
|
||||
@@ -167,10 +156,10 @@ class Sessions(unittest.TestCase):
|
||||
|
||||
def test_listing_marks_what_is_already_here(self):
|
||||
here = self.dir / "here"
|
||||
write(clips.clip_local_path("1/clips/clip_620_20261020_202134/video/bg", "frame-a", here))
|
||||
write(here / "clip_620_20261020_202134.mp4")
|
||||
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, device_id="frame-a")
|
||||
got = clips.list_clips(frame, folder=here)
|
||||
self.assertEqual([c["saved"] for c in got["clips"]], [True, False])
|
||||
self.assertEqual(got["folder"], str(here))
|
||||
|
||||
@@ -272,16 +261,12 @@ 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, device_id="frame-a")
|
||||
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir)
|
||||
self.assertEqual(got["message"], "Saved clip_620.mp4 to ~/Videos/SteamFrame")
|
||||
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((self.dir / "clip_620.mp4").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(list(self.dir.rglob("*.mp4")), [target])
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
self.assertEqual([p.name for p in self.dir.iterdir()], ["clip_620.mp4"])
|
||||
|
||||
def test_failed_copy_leaves_no_partial_file_and_still_cleans_up(self):
|
||||
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
@@ -292,62 +277,20 @@ 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, device_id="frame-a")
|
||||
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir)
|
||||
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, device_id="frame-a")
|
||||
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir)
|
||||
|
||||
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, device_id="frame-a")
|
||||
folder=self.dir)
|
||||
|
||||
|
||||
class Commands(unittest.TestCase):
|
||||
|
||||
+9
-161
@@ -119,6 +119,15 @@ 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, \
|
||||
@@ -142,164 +151,3 @@ 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>', '<>'])
|
||||
+7
-20
@@ -26,7 +26,6 @@ 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
|
||||
@@ -364,20 +363,10 @@ def parse_id(clip_id):
|
||||
|
||||
|
||||
def export_name(clip_id):
|
||||
"""The .mp4's basename; it alone does not identify a saved export."""
|
||||
"""The .mp4's name on the computer: the clip's (or background session's) own folder name."""
|
||||
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."""
|
||||
@@ -563,20 +552,19 @@ 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, *, device_id):
|
||||
def list_clips(run_frame, folder=None):
|
||||
"""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"] = clip_local_path(c["id"], device_id, folder).is_file()
|
||||
c["saved"] = (folder / export_name(c["id"])).exists()
|
||||
return {"clips": clips, "folder": str(folder)}
|
||||
|
||||
|
||||
def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
def export_clip(clip_id, run_frame, run, fetch, folder=None):
|
||||
"""Stitch a Steam recording on the Frame and copy the .mp4 here. -> {"message", "file"}."""
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
target = clip_local_path(clip_id, device_id, folder) # validate before reaching the Frame
|
||||
name = target.name
|
||||
name = export_name(clip_id) # checks the id before anything reaches the Frame
|
||||
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
|
||||
@@ -585,8 +573,7 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=folder))
|
||||
try:
|
||||
fetch(path, incoming / name)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(incoming / name, target)
|
||||
os.replace(incoming / name, folder / name)
|
||||
finally:
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
finally:
|
||||
@@ -594,7 +581,7 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
run(remove, 30)
|
||||
except Exception:
|
||||
pass # the next export sweeps it
|
||||
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(target)}
|
||||
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(folder / name)}
|
||||
|
||||
|
||||
# ---- on the Frame -----------------------------------------------------------------
|
||||
|
||||
+4
-8
@@ -928,9 +928,7 @@ def _clips_frame(args, timeout):
|
||||
def list_clips():
|
||||
_clips_here()
|
||||
try:
|
||||
device = agent_target()["id"]
|
||||
with working(device):
|
||||
return frame_clips.list_clips(_clips_frame, device_id=device)
|
||||
return frame_clips.list_clips(_clips_frame)
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
|
||||
@@ -940,13 +938,11 @@ 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:
|
||||
with working(device):
|
||||
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
|
||||
fetch_file, device_id=device)
|
||||
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
|
||||
fetch_file)
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
return start_job(f"Export {name}", work)
|
||||
@@ -969,7 +965,7 @@ 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, device_id=device)
|
||||
fetch_file)
|
||||
|
||||
|
||||
def _sync_list(listing):
|
||||
|
||||
Reference in new issue
Block a user