mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 13:01:47 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2bb178d5 | ||
|
|
b1f1880736 | ||
|
|
338726d581 | ||
|
|
32727fc6e5 |
No files matched your search
+5
-6
@@ -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;
|
||||
@@ -225,3 +219,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).
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+2
-7
@@ -14,13 +14,8 @@ panel. **Show in SteamVR** requests that dashboard tab; the wearer can select
|
||||
it again from SteamVR's dashboard. Closing the panel stops its browser and
|
||||
server. The service is not enabled at boot.
|
||||
|
||||
The page shows battery, free home storage and SteamVR status.
|
||||
The **Large status screen** uses large read-only values for battery, SteamVR,
|
||||
free home storage and Frame OS, with a refresh button and clear unknown values.
|
||||
It is an at-a-glance view, not a crash diagnosis or a Decky plugin page.
|
||||
The headset must still be worn to verify that a person can see and use it.
|
||||
|
||||
The page can request SteamVR's main Game Mode dashboard, list and force quit a running game after an
|
||||
The page shows battery, free home storage and SteamVR status. It can request
|
||||
SteamVR's main Game Mode dashboard, list and force quit a running game after an
|
||||
unsaved-progress confirmation, save an OpenVR screenshot to
|
||||
`~/Pictures/FrameControl`, and record the SteamVR mirror to
|
||||
`~/Videos/FrameControl`. Recordings have a 20-minute limit and use a separate
|
||||
|
||||
@@ -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.
|
||||
+10
-1
@@ -137,10 +137,19 @@ 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
|
||||
|
||||
|
||||
+19
-21
@@ -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,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
|
||||
@@ -213,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.
|
||||
@@ -244,16 +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 orphan-metadata and shortcut follow-up has not been rerun on a headset.
|
||||
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)
|
||||
|
||||
+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()
|
||||
+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>', '<>'])
|
||||
+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
|
||||
+3
-36
@@ -13,12 +13,7 @@ button:focus-visible{outline:5px solid #f8d370;outline-offset:3px}button:disable
|
||||
button.secondary{background:#253c50;border-color:#617f97}button.danger{background:#78372d;border-color:#d78d79}
|
||||
small{color:#b9c9d6}#message,#actionStatus{min-height:1.5em;margin:12px 0}.game{display:flex;gap:14px;justify-content:space-between;align-items:center}
|
||||
iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid #617f97;border-radius:12px;margin-top:14px}
|
||||
#statusScreen[hidden]{display:none}#statusScreen{position:fixed;inset:0;z-index:10;overflow:auto;background:#07131f;padding:30px}
|
||||
#statusScreen h2{font-size:clamp(40px,6vw,76px);margin:10px 0 20px}#statusScreen .cards{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
#statusScreen .card{min-height:170px}#statusScreen .value{font-size:clamp(40px,6vw,76px)}
|
||||
#statusScreen .value.warn{color:#ffd27a}#statusScreen .value.bad{color:#ff9585}
|
||||
#statusScreen .meta{font-size:20px;margin-top:22px;color:#b9c9d6}
|
||||
@media(max-width:720px){.cards,#statusScreen .cards{grid-template-columns:1fr}body{padding:18px}.game{display:block}.game button{margin-top:12px}}
|
||||
@media(max-width:720px){.cards{grid-template-columns:1fr}body{padding:18px}.game{display:block}.game button{margin-top:12px}}
|
||||
</style></head><body>
|
||||
<h1>Frame Control</h1><p>Your Frame, from inside SteamVR. Select this panel again from the dashboard after switching away.</p>
|
||||
<div class="cards">
|
||||
@@ -27,20 +22,9 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
|
||||
<div class="card"><span class="label">SteamVR</span><span class="value" id="steamvr">…</span></div>
|
||||
</div>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
<div class="row"><button id="refresh">Refresh status</button><button id="statusScreenOpen" class="secondary">Large status screen</button><button id="gameMode" class="secondary">Return to Game Mode</button>
|
||||
<div class="row"><button id="refresh">Refresh status</button><button id="gameMode" class="secondary">Return to Game Mode</button>
|
||||
<button id="screenshot" class="secondary">Save screenshot</button><button id="record" class="secondary">Start recording</button></div>
|
||||
<p id="actionStatus" role="status" aria-live="polite"></p>
|
||||
<section id="statusScreen" role="dialog" aria-modal="true" aria-labelledby="statusScreenTitle" hidden>
|
||||
<div class="row"><button id="statusScreenClose">Close status screen</button><button id="statusScreenRefresh" class="secondary">Refresh</button></div>
|
||||
<h2 id="statusScreenTitle">Frame status</h2>
|
||||
<div class="cards">
|
||||
<div class="card"><span class="label">Battery</span><span class="value" id="bigBattery">…</span></div>
|
||||
<div class="card"><span class="label">SteamVR</span><span class="value" id="bigSteamvr">…</span></div>
|
||||
<div class="card"><span class="label">Home storage free</span><span class="value" id="bigStorage">…</span></div>
|
||||
<div class="card"><span class="label">Frame OS</span><span class="value" id="bigOs">…</span></div>
|
||||
</div>
|
||||
<p id="bigStatusMessage" class="meta" role="status" aria-live="polite">Read-only status. This view does not diagnose a crash.</p>
|
||||
</section>
|
||||
<h2>Running games</h2><div class="row"><button id="gamesRefresh" class="secondary">Refresh games</button></div>
|
||||
<div id="games" class="stack" style="margin-top:12px">Loading…</div>
|
||||
<h2>Decky plugins</h2><p>Manage official store plugins here. Their backend can run on Frame, but Decky's Steam Deck Quick Access Menu is absent from this VR shell. Plugin pages, settings, routes, and toasts are not yet available in this panel.</p>
|
||||
@@ -67,20 +51,7 @@ async function refresh(){try{const status=latestStatus=await api('/api/status');
|
||||
byId('storage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
|
||||
byId('steamvr').textContent=status.services?.steamvr?'Running':'Unavailable';
|
||||
byId('message').textContent=`Frame OS ${status.os?.version||'unknown'} · Updated ${new Date().toLocaleTimeString()}`;
|
||||
renderLargeStatus(status);
|
||||
}catch(error){latestStatus=null;byId('message').textContent=error.message;renderLargeStatus(null,error.message);}}
|
||||
function renderLargeStatus(status,error=''){
|
||||
const battery=status?.battery?.percent;
|
||||
byId('bigBattery').textContent=Number.isFinite(battery)?`${battery}%`:'Unavailable';
|
||||
byId('bigBattery').className='value'+(Number.isFinite(battery)&&battery<20?' bad':Number.isFinite(battery)&&battery<40?' warn':'');
|
||||
byId('bigSteamvr').textContent=status?.services?.steamvr?'Running':'Unavailable';
|
||||
byId('bigSteamvr').className='value'+(status?.services?.steamvr?'':' bad');
|
||||
const free=status?.disk?.home?.free;
|
||||
byId('bigStorage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
|
||||
byId('bigStorage').className='value'+(Number.isFinite(free)&&free<5e9?' warn':'');
|
||||
byId('bigOs').textContent=status?.os?.version||'Unknown';
|
||||
byId('bigStatusMessage').textContent=error||`Read-only status · Frame OS build ${status?.os?.build||'unknown'} · Updated ${new Date().toLocaleTimeString()}. This view does not diagnose a crash.`;
|
||||
}
|
||||
}catch(error){latestStatus=null;byId('message').textContent=error.message;}}
|
||||
async function act(action){const button=byId(action==='game-mode'?'gameMode':'screenshot');button.disabled=true;
|
||||
try{const result=await api('/api/headset/quick',{action});byId('actionStatus').textContent=result.message;}
|
||||
catch(error){byId('actionStatus').textContent=error.message;}finally{button.disabled=false;}}
|
||||
@@ -144,10 +115,6 @@ async function refreshExtensions(){const list=byId('extensions');try{const data=
|
||||
if(data.errors.length){const errors=document.createElement('p');errors.textContent=data.errors.map(x=>`${x.folder}: ${x.error}`).join('; ');list.append(errors);}
|
||||
}catch(error){list.textContent=error.message;}}
|
||||
byId('refresh').onclick=refresh;byId('gameMode').onclick=()=>act('game-mode');byId('screenshot').onclick=()=>act('screenshot');
|
||||
byId('statusScreenOpen').onclick=()=>{byId('statusScreen').hidden=false;renderLargeStatus(latestStatus);byId('statusScreenClose').focus();};
|
||||
byId('statusScreenClose').onclick=()=>{byId('statusScreen').hidden=true;byId('statusScreenOpen').focus();};
|
||||
byId('statusScreenRefresh').onclick=refresh;
|
||||
window.addEventListener('keydown',event=>{if(event.key==='Escape'&&!byId('statusScreen').hidden)byId('statusScreenClose').click();});
|
||||
byId('gamesRefresh').onclick=refreshGames;byId('record').onclick=toggleRecord;byId('extensionsRefresh').onclick=refreshExtensions;
|
||||
byId('deckyRefresh').onclick=refreshDecky;byId('deckyStoreLoad').onclick=loadDeckyStore;byId('deckySearch').oninput=showDeckyStore;
|
||||
byId('extensionClose').onclick=()=>{activeExtension=null;byId('extensionFrame').removeAttribute('src');byId('extensionPanel').hidden=true;};
|
||||
|
||||
+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}`);
|
||||
|
||||
+2
-2
@@ -969,8 +969,8 @@ def _sync_clip(device, clip_id):
|
||||
|
||||
|
||||
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