Compare commits

..
16 changed files with 424 additions and 450 deletions

No files matched your search

+5 -6
View File
@@ -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).
-3
View File
@@ -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.
-43
View File
@@ -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
View File
@@ -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
-141
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -4
View File
@@ -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" "$@"
+8 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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