Compare commits

...
Author SHA1 Message Date
SAPHID 228548b108 Merge remote-tracking branch 'origin/main' into feat/frame-loader-status-screen 2026-10-25 06:34:22 +00:00
SAPHID e8c43b4dfc Add read-only large status screen to headset panel 2026-10-25 06:34:22 +00:00
Alex SouthwellandSAPHID d5fa8feb31 Fix orphan smoke cleanup and portable smoke wrapper (#216)
* Fix orphan smoke cleanup and portable smoke wrapper

* Resolve smoke wrapper fixture working directory on macOS

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:31:45 +00:00
Alex SouthwellandSAPHID 4e07409627 Document Steam Frame plugin research and verification limits (#210)
* Record Steam Frame plugin research and fresh read-only evidence

* Update Decky research for official store audit

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:29:47 +00:00
10 changed files with 432 additions and 29 deletions

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
## 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;
+3
View File
@@ -1,5 +1,8 @@
# Decky Loader on Steam Frame
For the modding-path survey, licence review and update limits, see
[plugin and modding research](plugins-research.md).
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
+43
View File
@@ -0,0 +1,43 @@
{
"checkedAt": "2026-10-25T06:03:45.387452+00:00",
"method": "Read-only Python probe over the existing frame SSH alias; no device changes",
"baselineCommit": "d79efb4730becc781f9a641cc36cb27f6481667c",
"deckyStatus": {
"arch": "aarch64",
"frameOS": "0.5.6",
"variant": "vr",
"build": "20261022.6153909",
"cef": true,
"managed": false,
"running": false,
"version": null,
"upstreamInstall": false,
"plugins": [],
"upstreamPlugins": []
},
"platform": {
"userServiceManager": true,
"rootMountOptions": "rw,relatime,ssd,discard=async,space_cache=v2,subvolid=5,subvol=/",
"homeMountOptions": "rw,relatime",
"homebrewExists": false,
"cefMarkerExists": false,
"cefTargetTitles": [
"SteamVR - valve.steam.gamepadui.frame.menu.53930005 (53930005)",
"SteamVR - valve.steam.gamepadui.bar (53930000)",
"SteamVR",
"SteamVR - Notifications",
"SteamVR - valve.steam.gamepadui.floatingfooter (53930004)",
"SteamVR - valve.steam.gamepadui.volumelevel (53930003)",
"SteamVR - valve.steam.gamepadui.tooltip.53930002 (53930002)",
"SteamVR - valve.steam.gamepadui.barpopup.53930001 (53930001)",
"SteamVR - Keyboard",
"data:text/html,&lt;body&gt;&lt;%2Fbody&gt;",
"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"
]
}
+7 -2
View File
@@ -14,8 +14,13 @@ 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. It 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.
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
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
@@ -0,0 +1,141 @@
# Plugin and modding research
Checked **2026-10-25**. Decision: keep the experimental, reversible Decky manager
and ship Frame Control's own sandboxed panel API as the supported way to add
local features. Both implementations already landed in the replacement job
while the original job was unavailable. This report consolidates their evidence
and the original research questions; it does not claim another loader was built.
## Can Decky run on Frame?
**Its ARM64 backend and Steam shared-context injection work on the tested build.
A usable Decky menu inside VR remains unverified.** Do not equate an active
service or a JavaScript plugin registration with a working headset interface.
| Requirement | Evidence | Consequence |
| --- | --- | --- |
| ARM64 Linux binary | Upstream [3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0) has `PluginLoader-arm64`; Frame reports `aarch64` | ARM is not itself a blocker. Individual plugins may still ship x86 binaries or assume Deck hardware. |
| Steam Gamepad UI and CEF | Loopback `127.0.0.1:8080/json/list` exposes `SharedJSContext` plus distinct SteamVR pages | The shared context exists; the VR shell is a separate integration target. |
| CEF enable marker | `~/.steam/steam/.cef-enable-remote-debugging` is absent, but CEF already answers | No marker or Steam launch flag change is needed on this build. |
| User service manager | `systemctl --user show-environment` succeeds | A per-user service works without installing an upstream root service. The manager is degraded by a stale failed Decky unit, although its unit file is gone. |
| Filesystem | `/` currently mounts `rw` on Btrfs, `/home` mounts `rw` | This is an observation of this device, not a stock-image guarantee. Do not disable SteamOS read-only protection or install packages in `/usr`. |
| Homebrew folder | `~/homebrew` is absent | It is an installer convention, not a preinstalled SteamOS plugin API. Frame Control uses a separate managed directory. |
| Visible menu | Earlier tests registered Decky's tab only in shared JavaScript; SteamVR had no Decky overlay; headset was in standby | QAM visibility, controller input and a real plugin's visible result remain unverified. |
The fresh [read-only probe](evidence/plugins-2026-10-25.json) records SteamOS
`0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`. It confirms CEF, architecture,
mounts, user-service availability, and absence of a running managed or upstream
Decky installation. This retry did not install anything or restart Steam.
The replacement job's initial [test record](decky-frame.md) reports:
- Decky 3.3.0 started as the normal `steamos` user and injected into
`SharedJSContext`.
- Install → status → update → status → remove → status succeeded on the Frame.
Removal left the unit, binary and port 1337 absent, while preserving user data.
- ProtonDB Badges 1.1.0 loaded as a passive plugin and appeared in Decky's
shared-context registry. Its badge or menu was not seen inside VR.
- Injected frontend code can outlive backend removal until Steam's web helper
restarts. An earlier helper-restart test cleared the globals; this retry did
not repeat it.
These are prior documented results, separate from the fresh read-only probe.
The coordinator then ran a separate, locked
[official-store compatibility audit](decky-frame.md#official-store-plugin-audit-2026-10-25)
on the same Frame OS build. Seven plugins logged backend loads. A managed
ProtonDB Badges 1.3.4 store install, update and remove also succeeded. The
headset stayed unworn, so these results still do not establish VR UI use.
## Installer and update choice
The upstream [installer at e42a3c8](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
escalates through sudo, creates `~/homebrew/plugins` and `services`, writes the
CEF marker and a system unit under `/etc/systemd/system`, and starts Decky as
root. Its architecture switch supports `aarch64|arm64`. Running this installer
is unnecessary for a compatibility check and grants more access than a panel
extension needs.
Frame Control's [manager](../ui/frame_decky.py) instead downloads the official
ARM64 asset, checks GitHub's published SHA-256 and the ELF machine, and writes
only under `~/.local/share/frame-control/decky` and `~/.config/systemd/user`.
It refuses an upstream system-service installation, requires active CEF before
install/update, and restores the previous binary, receipt and unit if applying
an update fails. Existing tests exercise that rollback with a failed service
start. These checks do not prove every possible OS or Steam update is safe.
The binary is downloaded on demand, not bundled. Plugin discovery lists the
managed directory and upstream `~/homebrew/plugins`. The later
[store manager](../ui/frame_decky_store.py) exposes official entries in the PC
app and opt-in headset panel. It verifies archive digests, blocks root and
non-ARM64 binaries, and changes only its own plugin folders. That is file and
backend support, not a working VR plugin page. Decky plugins can execute
backend code and are not isolated by Frame Control's HTML extension sandbox.
Home-directory state is expected to survive SteamOS image updates, based on
SteamOS's layout; **an actual OS update and reboot persistence test has not been
performed**. Steam frontend changes, CEF availability, libc requirements and
plugin hardware assumptions can still break Decky. On failure, remove the user
service using [the documented removal command](decky-frame.md#opt-in-and-remove);
its removal path does not require a working CEF target. Test the sequence again
on each supported OS build before expanding support claims.
## Other paths
| Path | What it enables | Current limits and choice |
| --- | --- | --- |
| Decky plugins | Steam frontend hooks, optional Python backends | Seven official-store plugins logged backend loads on ARM64. Store install/update/remove worked for ProtonDB Badges. VR pages and controller input remain unverified. Keep experimental. |
| [CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader) / [DeckThemes](https://docs.deckthemes.com/) | Stylesheets applied to Steam UI | CSS Loader 2.1.3 backend loaded on Frame. No theme was applied or visually tested. Deck themes do not establish compatibility with Frame's separate VR shell. |
| User home / SteamOS homebrew | User services, user Flatpaks, files and helpers | Use owned folders and explicit uninstall. A folder named `homebrew` supplies no discovery or security boundary by itself. No rootfs changes needed for the chosen path. |
| Android / Lepton | Sideloaded apps, game-specific content and per-app settings | Frame Control already manages APKs and ADB. Lepton runs Android containers, not Steam's CEF UI; an Android mod does not add a Steam dashboard tab. No new Android mod was tested here. See [APKs](apks.md), [VR APKs](vr-apks.md), and [game mods](mods.md). |
| Frame Control extensions | Third-party HTML/CSS/JS panels with a small read-only bridge | Shipped in [#194](https://github.com/saphid/frame-control/pull/194), with a manifest, local folder, sandbox, example and [author guide](extensions.md). No Python imports, arbitrary SSH commands or write actions are exposed. |
| Frame Control's headset panel | Runs the local tools and extensions as a SteamVR panel | Shipped in [#198](https://github.com/saphid/frame-control/pull/198), expanded in [#201](https://github.com/saphid/frame-control/pull/201). Page/API execution and uninstall were tested; wearer-visible pixels and controller interaction remain unverified. See [test record](headset-app.md). |
The extension iframe has `sandbox="allow-scripts"`, an opaque origin, restrictive
CSP and a `postMessage` bridge accepting only `capabilities` and a reduced
`status` snapshot from the active frame. It is useful for local dashboards and
small tools today. It cannot replace the full Decky API. Third-party code still
needs review: an iframe may navigate itself externally, and local installation
does not establish publisher trust.
Extensions are loaded only when opened and are removed by closing their panel
and deleting their own directory. They modify no SteamOS files. The
[compatibility follow-up](https://github.com/saphid/frame-control/pull/209) adds
manifest `apiVersion` validation during discovery and asset serving, with
existing manifests defaulting to version 1. It rejects incompatible panels
even when requested through a direct asset URL.
## Licences: separate download versus bundling
Decky's [licence](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/LICENSE)
is GPL version 2. Frame Control is MIT-licensed. The integration contains its
own service-management code and downloads the upstream executable as a separate
program. It does not incorporate Decky source or ship its binary in Frame
Control installers. Keep the upstream licence/source links visible to users
who opt in; installing a separate GPL program does not by itself relicense
Frame Control's independent code.
If a future release bundles or mirrors Decky binaries, the distributor must
preserve copyright/licence notices and satisfy GPLv2's corresponding-source
requirements for the exact binary, including modifications and build scripts
as applicable. A link to an evolving default branch is not a substitute for
meeting those distribution obligations. Mere aggregation and deriving or
linking a combined work are different questions; reassess the boundary if
Decky code is incorporated. Each plugin, theme and bundled dependency has its
own licence and assets to check independently. The existing
[third-party notices](../THIRD_PARTY_NOTICES.md) describe software already
distributed with Frame Control.
## Next checks
1. On a worn Frame, establish an accessible Decky menu or implement an adapter
for the VR shell. Verify a plugin's visible result and controller navigation
before describing store-installed plugins as usable inside VR.
2. Verify the Frame Control panel and example extension visually, including
opening, refreshing and closing with the controllers.
3. Test a normal Steam restart, reboot and SteamOS update with a disposable
managed install; confirm removal still works with CEF unavailable. Retain
the experimental label until those checks pass.
The release coordinator owns those ongoing headset-integration checks. This
retry leaves the headset untouched and hands over the research and compatibility
follow-up instead of running concurrent loader experiments.
+21 -4
View File
@@ -188,6 +188,11 @@ scripts/frame-smoke.sh # needs `ssh frame` to work without a password
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
```
The wrapper uses POSIX `sh` and Python 3 on macOS or Linux; zsh is not required.
It resolves the checkout path independently of the current working directory.
`/path/to/checkout/scripts/frame-smoke.sh --help` is safe to run offline and
does not contact the headset.
It checks `properties.json` and the status, then installs, launches and
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
@@ -208,6 +213,14 @@ isn't reachable.
Smoke process cleanup runs before and after title removal and during the
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
Each leftover sweep checks all six IDs, even if no title directory or install
record remains. It removes their exact `-argv.json`, `-env.json`,
`-settings.json` and `-framecontrol.json` files, syncs Steam's shortcuts, then
syncs again to verify that none of those IDs is still registered. Missing
devkit utilities are copied before syncing and removed at the end if they
were absent before the run. Metadata or shortcuts that remain, or a reported
sync failure, fail cleanup. Similarly named titles and their metadata are
outside the deletion boundary.
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
an exact title directory in `/proc` cwd/exe or an individual absolute command
argument; similarly named folders and shell command substrings do not count.
@@ -231,12 +244,16 @@ The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
remove step stopped five test-owned wrapper/Proton PIDs before removing the
title; the following process sweep found none. The final cleanup found no
test titles or processes, the test desktop was closed, and the battery
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
the same entry point instead.
remained at 99%. That historical run used the Python entry point directly;
the current POSIX wrapper has local offline coverage on a PATH without zsh.
The orphan-metadata and shortcut follow-up has not been rerun on a headset.
The local regression suite runs real Linux processes in temporary fake title
directories (including deleted folders and a TERM-resistant wrapper); it checks
exit, escalation and survival of unrelated processes without SSH or a headset:
exit, escalation and survival of unrelated processes. Local filesystem commands
with mocked SSH/Steam also cover orphan metadata, shortcut-only leftovers,
sync failures and exact ID boundaries. Wrapper tests check offline help,
argument forwarding and exit status from another working directory. None of
these local tests contacts a headset:
```sh
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
+4 -3
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env zsh
#!/bin/sh
# Mac or Linux: the headset smoke test. Installs, launches and removes tiny
# test titles on the Frame (the `frame` alias) and records the results with
# its BUILD_ID under tests/smoke/results/. See docs/testing.md.
#
# Usage: scripts/frame-smoke.sh [--pair] (--pair needs you in the headset to approve)
set -euo pipefail
exec python3 "${0:A:h:h}/tests/smoke/frame_smoke.py" "$@"
set -eu
smoke_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd -P)
exec python3 "$smoke_root/tests/smoke/frame_smoke.py" "$@"
+10 -8
View File
@@ -203,23 +203,23 @@ def cleanup(smoke):
never removed. Raises if any owned process, file or shortcut stays.
"""
problems = []
try:
ids = {t['id'] for t in frame_titles.list_titles() if t['id'] in stop_processes.IDS}
except frame_android.FrameError as e:
ids = set()
problems.append(f'could not list titles: {e}')
ids |= smoke.installed
# Directory listings and this run's install tracker cannot reveal orphan
# metadata or shortcuts. Always sweep the exact, reserved IDs; never let
# arbitrary entries in smoke.installed expand the deletion boundary.
ids = stop_processes.IDS
# Scan every reserved ID, even when a prior run removed its folder and
# shortcut but left Proton running. Stop before removing any more files.
stopped = stop_titles(stop_processes.IDS)
gone = set()
for gid in sorted(ids):
try:
metadata = ' '.join(f'~/devkit-game/{gid}-{k}.json'
for k in ('argv', 'env', 'settings', 'framecontrol'))
if ssh(f'test -d ~/devkit-game/{gid} && echo yes || true').strip() == 'yes':
frame_titles.remove(gid)
# Also when remove() stopped part-way, or only the json files are left.
ssh(f"rm -f {' '.join(f'~/devkit-game/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))}")
if ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip():
ssh(f'rm -f {metadata}')
if ssh(f'ls -d ~/devkit-game/{gid} {metadata} 2>/dev/null || true').strip():
problems.append(f'{gid} is still on the Frame')
else:
gone.add(gid)
@@ -230,6 +230,8 @@ def cleanup(smoke):
# It logs a failed sync and exits 0, so read its log; a second run lists
# what Steam still has registered, which must not include ours.
try:
# A shortcut-only leftover (or a first run) may have no utilities.
frame_titles.ensure_utils()
command = frame_titles.devkit_script('delete') + ' 2>&1'
first = ssh(command, timeout=120)
second = ssh(command, timeout=120)
+161 -9
View File
@@ -119,15 +119,6 @@ class Safety(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, 'survived cleanup'):
stop_processes.stop(['fc_smoke_exe'], grace=0)
def test_cleanup_scans_even_when_title_already_removed(self):
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[]), \
mock.patch.object(frame_smoke, 'ssh') as ssh, \
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': [42]}) as stop:
result = frame_smoke.cleanup(frame_smoke.Smoke())
self.assertEqual(stop.call_args_list, [mock.call(stop_processes.IDS)] * 2)
self.assertEqual(result['process_cleanup'][0]['stopped_pids'], [42])
ssh.assert_not_called()
def test_stop_failure_prevents_file_removal(self):
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[{'id': 'fc_smoke_exe'}]), \
mock.patch.object(frame_smoke, 'ssh') as ssh, \
@@ -151,3 +142,164 @@ class Safety(unittest.TestCase):
self.assertEqual(frame_smoke.main(), 1)
cycle.assert_not_called()
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
@unittest.skipUnless(os.name == 'posix', 'local POSIX shell required')
class OrphanCleanup(unittest.TestCase):
"""Run the real cleanup shell commands in a throwaway home; mock Steam/SSH."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.home = Path(self.tmp.name)
self.games = self.home / 'devkit-game'
self.games.mkdir()
self.shortcuts = set()
self.sync_outputs = None
self.events = []
self.smoke = frame_smoke.Smoke()
for target, name, kwargs in (
(frame_smoke, 'ssh', {'side_effect': self.local_ssh}),
(frame_smoke, 'stop_titles', {'side_effect': self.stop}),
(frame_smoke.frame_titles, 'list_titles', {'return_value': []}),
(frame_smoke.frame_titles, 'remove', {'side_effect': self.remove}),
(frame_smoke.frame_titles, 'ensure_utils', {}),
(frame_smoke.frame_titles, 'devkit_script', {'return_value': 'smoke-sync'})):
patcher = mock.patch.object(target, name, **kwargs)
setattr(self, name + '_mock', patcher.start())
self.addCleanup(patcher.stop)
def stop(self, ids):
self.assertEqual(ids, stop_processes.IDS)
self.events.append('stop')
return {'stopped_pids': [42] if len(self.events) == 1 else []}
def remove(self, gid):
self.assertIn(gid, stop_processes.IDS)
self.events.append('remove')
shutil.rmtree(self.games / gid)
def local_ssh(self, command, timeout=60):
self.events.append(command)
self.assertEqual(self.events[0], 'stop', 'processes must stop before file cleanup')
if command == 'smoke-sync 2>&1':
if self.sync_outputs is not None:
return self.sync_outputs.pop(0)
# Valve logs the registered IDs before reconciling with disk.
out = '\n'.join(f'Found Devkit Game registered with Steam Client: {gid!r}'
for gid in sorted(self.shortcuts))
self.shortcuts = {gid for gid in self.shortcuts if (self.games / gid).is_dir()}
return out
return subprocess.run(command, shell=True, check=True, capture_output=True,
text=True, timeout=timeout,
env={**os.environ, 'HOME': str(self.home)}).stdout
def metadata(self, gid):
paths = [self.games / f'{gid}-{kind}.json'
for kind in ('argv', 'env', 'settings', 'framecontrol')]
for path in paths:
path.write_text('{}')
return paths
def test_orphan_metadata_for_every_reserved_id_is_removed_and_shortcuts_rechecked(self):
paths = [path for gid in stop_processes.IDS for path in self.metadata(gid)]
self.shortcuts = set(stop_processes.IDS)
result = frame_smoke.cleanup(self.smoke)
self.assertTrue(all(not path.exists() for path in paths))
self.assertFalse(self.shortcuts)
self.assertEqual(result['removed'], sorted(stop_processes.IDS))
self.assertEqual(result['process_cleanup'], [{'stopped_pids': [42]}, {'stopped_pids': []}])
self.assertEqual(self.events[-1], 'stop')
self.assertEqual(self.events.count('smoke-sync 2>&1'), 2)
self.ensure_utils_mock.assert_called_once_with()
self.remove_mock.assert_not_called()
def test_shortcut_only_leftover_is_synced_without_title_or_install_record(self):
self.shortcuts = {'fc_smoke_exe'}
frame_smoke.cleanup(self.smoke)
self.assertFalse(self.shortcuts)
self.assertEqual(self.events.count('smoke-sync 2>&1'), 2)
def test_unrelated_titles_and_metadata_survive_even_if_in_install_tracker(self):
others = ('portal2', 'fc_smoke_custom', 'fc_smoke_exe-other', 'fc_smoke_exe_other')
paths = []
for gid in ('fc_smoke_exe', *others):
(self.games / gid).mkdir()
paths.extend(self.metadata(gid) if gid in others else [])
self.shortcuts = {'fc_smoke_exe', *others}
self.smoke.installed = {'fc_smoke_exe', *others, 'fc_smoke_exe/../portal2'}
frame_smoke.cleanup(self.smoke)
self.assertFalse((self.games / 'fc_smoke_exe').exists())
self.assertTrue(all((self.games / gid).is_dir() for gid in others))
self.assertTrue(all(path.exists() for path in paths))
self.assertEqual(self.shortcuts, set(others))
self.assertNotIn('fc_smoke_exe', self.smoke.installed)
self.remove_mock.assert_called_once_with('fc_smoke_exe')
def test_still_registered_current_or_legacy_shortcut_fails_cleanup(self):
for gid in sorted(stop_processes.IDS):
with self.subTest(gid=gid):
self.sync_outputs = ['', f'Found Devkit Game registered with Steam Client: {gid!r}']
self.smoke.installed = {gid}
with self.assertRaisesRegex(AssertionError, f'{gid} is still registered with Steam'):
frame_smoke.cleanup(self.smoke)
self.assertIn(gid, self.smoke.installed)
def test_sync_failure_in_either_pass_fails_cleanup(self):
for failed_pass in (0, 1):
with self.subTest(failed_pass=failed_pass):
self.sync_outputs = ['', '']
self.sync_outputs[failed_pass] = 'Steam client sync of devkit games failed: offline'
self.smoke.installed = {'fc_smoke_exe'}
with self.assertRaisesRegex(AssertionError, 'sync of devkit games failed'):
frame_smoke.cleanup(self.smoke)
self.assertIn('fc_smoke_exe', self.smoke.installed)
def test_metadata_remaining_after_delete_fails_cleanup(self):
self.metadata('fc_smoke_exe')
real_ssh = self.local_ssh
with mock.patch.object(frame_smoke, 'ssh', side_effect=lambda command, **kwargs:
'' if command.startswith('rm -f ') else real_ssh(command, **kwargs)):
with self.assertRaisesRegex(AssertionError, 'fc_smoke_exe is still on the Frame'):
frame_smoke.cleanup(self.smoke)
def test_missing_utils_cannot_report_success_without_shortcut_verification(self):
self.ensure_utils_mock.side_effect = frame_smoke.frame_android.FrameError('copy failed')
with self.assertRaisesRegex(AssertionError, "syncing Steam's shortcuts: copy failed"):
frame_smoke.cleanup(self.smoke)
self.assertEqual(self.events[-1], 'stop')
@unittest.skipUnless(os.name == 'posix', 'POSIX wrapper')
class PortableWrapper(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='smoke wrapper ')
self.addCleanup(self.tmp.cleanup)
# macOS aliases /var to /private/var; relative commands need the physical cwd.
self.folder = Path(self.tmp.name).resolve()
self.bin = self.folder / 'bin'
self.bin.mkdir()
# No zsh or SSH on PATH: --help must exit before any headset access.
(self.bin / 'dirname').symlink_to(shutil.which('dirname'))
self.env = {**os.environ, 'PATH': str(self.bin), 'CDPATH': str(self.folder)}
self.wrapper = SMOKE.parent.parent / 'scripts' / 'frame-smoke.sh'
def test_help_runs_directly_and_with_posix_sh_from_another_cwd_without_zsh(self):
(self.bin / 'python3').symlink_to(sys.executable)
for launcher in ([], ['/bin/sh']):
with self.subTest(launcher=launcher):
result = subprocess.run([*launcher, str(self.wrapper), '--help'], cwd=self.folder,
env=self.env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('--pair', result.stdout)
self.assertIn('Headset smoke test', result.stdout)
def test_relative_invocation_preserves_arguments_and_python_exit_status(self):
python = self.bin / 'python3'
python.write_text('#!/bin/sh\nprintf "<%s>\\n" "$@"\nexit 7\n')
python.chmod(0o755)
result = subprocess.run([os.path.relpath(self.wrapper, self.folder), '--pair', 'two words', ''],
cwd=self.folder, env=self.env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 7, result.stderr)
self.assertEqual(result.stdout.splitlines(),
[f'<{SMOKE / "frame_smoke.py"}>', '<--pair>', '<two words>', '<>'])
+36 -3
View File
@@ -13,7 +13,12 @@ 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}
@media(max-width:720px){.cards{grid-template-columns:1fr}body{padding:18px}.game{display:block}.game button{margin-top:12px}}
#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}}
</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">
@@ -22,9 +27,20 @@ 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="gameMode" class="secondary">Return to Game Mode</button>
<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>
<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>
@@ -51,7 +67,20 @@ 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()}`;
}catch(error){latestStatus=null;byId('message').textContent=error.message;}}
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.`;
}
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;}}
@@ -115,6 +144,10 @@ 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;};