Compare commits

..
Author SHA1 Message Date
SAPHID 792e9c016e Merge remote-tracking branch 'origin/main' into release/v0.4.39 2026-10-26 01:14:31 +00:00
SAPHID d90d111218 Prepare Frame Control 0.4.39 2026-10-26 01:07:02 +00:00
8 changed files with 19 additions and 220 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.38",
"version": "0.4.39",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.38",
"version": "0.4.39",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.38",
"version": "0.4.39",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+3 -3
View File
@@ -1,6 +1,6 @@
# What we have tested
Current for Frame Control **v0.4.38**, 26 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
Current for Frame Control **v0.4.39**, 26 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
@@ -24,7 +24,7 @@ Current for Frame Control **v0.4.38**, 26 October 2026. “Real Frame + physical
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 25 Oct, v0.4.29–0.4.37: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [worn checklist](framey-vr-check.md) |
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. The exact-version official catalog covers 116 plugins: 1 works (Notebook default flow only), 44 partial, 60 broken and 11 not applicable. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.38: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [compatibility table](framey-compatibility.md) |
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 run showed the Framey sample panel in stereo compositor and mirror captures. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; 98 local Chromium initializers were ready and 18 failed. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38: [unattended sample evidence](evidence/framey-vr-2026-10-25/README.md), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [worn checklist](framey-vr-check.md) |
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. The exact-version official catalog covers 116 plugins: 1 works (Notebook default flow only), 45 partial, 59 broken and 11 not applicable. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.39: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [compatibility table](framey-compatibility.md) |
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 run showed the Framey sample panel in stereo compositor and mirror captures. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; 98 local Chromium initializers were ready and 18 failed. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.39: [unattended sample evidence](evidence/framey-vr-2026-10-25/README.md), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [worn checklist](framey-vr-check.md) |
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
@@ -1,32 +0,0 @@
{
"tag": "v0.4.38",
"commit": "d87b863aa3c61c9833045b29ba79c7534a351ef4",
"startedUtc": "2026-10-26T01:47:23Z",
"frameOSVersion": "0.5.6",
"frameOSBuild": "20261022.6153909",
"preflight": {
"steamVRActivity": 3,
"proximityRaw": 6.3125,
"batteryPercent": 100
},
"postflight": {
"steamVRActivity": 3,
"proximityRaw": 6.25,
"batteryPercent": 100
},
"smoke": {
"passed": 13,
"failed": 0,
"arm64": "running",
"x86_64": "runtime missing",
"windowsExe": "running",
"finalCleanupPassed": true,
"ownedProcessesAfterCleanup": 0
},
"sharedLock": "unique owner released",
"limits": [
"No worn headset",
"No physical controller input",
"No official Decky panel tested in this smoke"
]
}
@@ -1,27 +0,0 @@
# v0.4.38 real Frame release smoke
On 2026-10-26 at 01:47 UTC, the exact `v0.4.38` tag
`d87b863aa3c61c9833045b29ba79c7534a351ef4` passed `scripts/frame-smoke.sh`
on a real Frame running SteamOS 0.5.6 build `20261022.6153909`. The wrapper
atomically acquired `/tmp/frame-test.lock` with a unique owner, then read
SteamVR activity 3 (standby), proximity raw 6.3125 and battery 100%. It ran
the smoke only after that preflight.
All 13 smoke steps passed. The ARM64 test title ran. The x86_64 title correctly
reported its runtime missing. The Windows executable ran through the ARM64
Proton path. The final cleanup found no test processes and removed the reserved
smoke titles. The read-only postflight still found activity 3 and battery 100%;
the wrapper released only its own lock. The public numeric summary is
[v0438-release-smoke-2026-10-26.json](v0438-release-smoke-2026-10-26.json).
Private raw logs and process paths remain in `/tmp` and were not published.
The new built-in shared-lock preflight on this PR's branch separately passed
the same 13/13 smoke steps at 01:46 UTC, with activity 3, proximity raw 6.3125
and battery 100% before the run. The lock was released after cleanup. The local
`tests.test_smoke_cleanup` suite passed 20 tests, including refusal on a busy
lock and release after a failed preflight. The built-in preflight was **not**
part of the v0.4.38 tag; that release used the external lock wrapper described
above.
These checks did not involve a worn headset or prove Framey panel visibility,
physical controller interaction, or optical comfort.
-7
View File
@@ -208,13 +208,6 @@ 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.
Before reading headset status or changing titles, the smoke script atomically
acquires the shared `/tmp/frame-test.lock` with a unique owner token. It reads
SteamVR activity, proximity and battery while locked. An awake headset, unknown
activity, battery below 20%, or a busy lock stops the run without installing
anything. It releases only its own lock after cleanup. The JSON result includes
the read-only preflight for completed smoke runs.
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
+12 -96
View File
@@ -4,11 +4,6 @@ Runs on your computer through the `frame` SSH alias (or FRAME_ALIAS), with
Frame Control's own modules, and records what happened with the headset's
BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
Before any status read or title change, it atomically owns /tmp/frame-test.lock
on the Frame and checks SteamVR standby, proximity and battery read-only. A
busy lock, awake headset or battery below 20% stops the run. Only this run's
lock is released, including after test failures.
- properties.json from Valve's devkit service names the login user;
- status (ui/frame_status.py) reads the build, battery and storage;
- three tiny titles (tests/smoke/tiny_programs.py: ARM64 and x86-64 static
@@ -33,7 +28,6 @@ import argparse
import json
import os
import platform
import secrets
import shlex
import subprocess
import sys
@@ -56,60 +50,6 @@ import stop_processes # noqa: E402
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
PREFIX = 'fc_smoke_'
LOCK = '/tmp/frame-test.lock'
def remote(command, *, input=None):
"""Use a bounded SSH call for shared-lock operations."""
return subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', ALIAS, command],
input=input, capture_output=True, text=True, timeout=20)
def acquire_lock(token):
"""Atomically reserve the Frame before reading its state or changing titles."""
command = (f'mkdir {LOCK} && {{ printf %s {shlex.quote(token)} > {LOCK}/owner || '
f'{{ rmdir {LOCK}; exit 1; }}; }}')
return remote(command).returncode == 0
def release_lock(token):
"""Never remove a lock if its owner changed."""
command = (f'test "$(cat {LOCK}/owner)" = {shlex.quote(token)} && '
f'rm {LOCK}/owner && rmdir {LOCK}')
return remote(command).returncode == 0
PREFLIGHT = r'''
import json, pathlib, subprocess
r = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd', '--stats'],
capture_output=True, text=True, timeout=8)
try: activity = json.loads(r.stdout)[0]['activity_level'] if r.returncode == 0 else None
except (ValueError, KeyError, IndexError, TypeError): activity = None
proximity = []
for path in pathlib.Path('/sys/bus/iio/devices').glob('iio:device*/in_proximity_raw'):
try: proximity.append(float(path.read_text().strip()))
except (OSError, ValueError): pass
battery = []
for path in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
try:
if (path.parent/'type').read_text().strip() == 'Battery':
battery.append(int(path.read_text().strip()))
except (OSError, ValueError): pass
print(json.dumps({'activity': activity, 'proximity': proximity, 'battery': battery}))
'''
def preflight():
result = remote('python3 -', input=PREFLIGHT)
if result.returncode:
raise RuntimeError('Locked SteamVR and battery preflight failed: ' + result.stderr.strip())
state = json.loads(result.stdout)
if state.get('activity') != 3:
raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched')
battery = state.get('battery')
if not battery or min(battery) < 20:
raise RuntimeError('Frame battery is unavailable or below the smoke threshold')
return state
def stop_titles(ids):
@@ -368,8 +308,17 @@ def pair(folder):
print(f' removed the smoke key from authorized_keys ({dropped} line(s))', flush=True)
def run_smoke(args, before):
"""Run only after the shared lock and read-only preflight succeeded."""
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--pair', action='store_true', help='also pair through the devkit service (needs you in the headset)')
args = ap.parse_args()
r = subprocess.run(['ssh', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes', ALIAS, 'true'],
capture_output=True, text=True)
if r.returncode != 0:
print(f'{ALIAS} is not reachable over SSH: {r.stderr.strip()}', file=sys.stderr)
return 2
smoke = Smoke()
started = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
release = os_release()
@@ -398,45 +347,12 @@ def run_smoke(args, before):
passed = sum(s['ok'] for s in smoke.steps)
RESULTS.mkdir(exist_ok=True)
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'preflight': before,
'os_release': release, 'paired': args.pair,
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'os_release': release, 'paired': args.pair,
'passed': passed, 'failed': len(smoke.steps) - passed,
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out}')
return 0 if passed == len(smoke.steps) else 1
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--pair', action='store_true', help='also pair through the devkit service (needs you in the headset)')
args = ap.parse_args()
token = 'frame-smoke-' + secrets.token_hex(10)
try:
locked = acquire_lock(token)
except (OSError, subprocess.TimeoutExpired):
locked = False
if not locked:
print('Shared Frame lock busy or unreachable; no headset status read or smoke run.', file=sys.stderr)
return 2
result = 1
try:
try:
before = preflight()
print('Locked read-only preflight: ' + json.dumps(before), flush=True)
result = run_smoke(args, before)
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as failure:
print(str(failure), file=sys.stderr)
result = 2
finally:
try:
released = release_lock(token)
except (OSError, subprocess.TimeoutExpired):
released = False
if not released:
print('ERROR: could not release owned Frame lock.', file=sys.stderr)
result = 1
return result
if __name__ == '__main__':
sys.exit(main())
+1 -52
View File
@@ -1,6 +1,5 @@
"""Smoke cleanup against real local Linux processes, never a headset or SSH."""
import sandbox # noqa: F401
import json
import os
from pathlib import Path
import shutil
@@ -140,61 +139,11 @@ class Safety(unittest.TestCase):
mock.patch.object(frame_smoke, 'cleanup', side_effect=RuntimeError('cannot stop')), \
mock.patch.object(frame_smoke, 'title_cycle') as cycle, \
mock.patch('builtins.print'):
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
{'activity':3,'proximity':[],'battery':[100]}), 1)
self.assertEqual(frame_smoke.main(), 1)
cycle.assert_not_called()
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
class SharedSmokeLock(unittest.TestCase):
def test_busy_lock_prevents_any_status_read_or_title_change(self):
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
mock.patch.object(frame_smoke, 'acquire_lock', return_value=False), \
mock.patch.object(frame_smoke, 'preflight') as preflight, \
mock.patch.object(frame_smoke, 'run_smoke') as run, \
mock.patch.object(frame_smoke, 'release_lock') as release:
self.assertEqual(frame_smoke.main(), 2)
preflight.assert_not_called()
run.assert_not_called()
release.assert_not_called()
def test_preflight_requires_standby_and_sufficient_battery(self):
for state in ({'activity':1,'proximity':[1],'battery':[100]},
{'activity':None,'proximity':[],'battery':[100]},
{'activity':3,'proximity':[],'battery':[19]},
{'activity':3,'proximity':[],'battery':[]}):
with self.subTest(state=state), mock.patch.object(frame_smoke, 'remote',
return_value=mock.Mock(returncode=0, stdout=json.dumps(state))):
with self.assertRaises(RuntimeError):
frame_smoke.preflight()
with mock.patch.object(frame_smoke, 'remote', return_value=mock.Mock(
returncode=0, stdout=json.dumps({'activity':3,'proximity':[0],'battery':[99]}))):
self.assertEqual(frame_smoke.preflight()['battery'], [99])
def test_owner_checked_on_release_and_preflight_failure_still_releases(self):
commands = []
def remote(command, **_):
commands.append(command)
return mock.Mock(returncode=0)
with mock.patch.object(frame_smoke, 'remote', side_effect=remote):
self.assertTrue(frame_smoke.acquire_lock('frame-smoke-test-token'))
self.assertTrue(frame_smoke.release_lock('frame-smoke-test-token'))
self.assertIn('mkdir /tmp/frame-test.lock', commands[0])
self.assertIn('> /tmp/frame-test.lock/owner', commands[0])
self.assertIn('test "$(cat /tmp/frame-test.lock/owner)" = frame-smoke-test-token', commands[1])
self.assertLess(commands[1].index('test '), commands[1].index('rm /tmp/frame-test.lock/owner'))
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
mock.patch.object(frame_smoke, 'acquire_lock', return_value=True), \
mock.patch.object(frame_smoke, 'preflight', side_effect=RuntimeError('awake')), \
mock.patch.object(frame_smoke, 'run_smoke') as run, \
mock.patch.object(frame_smoke, 'release_lock', return_value=True) as release:
self.assertEqual(frame_smoke.main(), 2)
run.assert_not_called()
release.assert_called_once()
self.assertTrue(release.call_args.args[0].startswith('frame-smoke-'))
@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."""