Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 4ec2ff40c3 APK installs: decode the Frame's ssh output as UTF-8 on every OS
Windows decoded it with its code page, so the Windows CI saw 'ok ÿ' where
the test expects a replacement character, and real UTF-8 messages from the
headset came out garbled there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:07:12 +11:00
saphidandClaude Opus 5.5 39e819872a Privacy notice and setting: say headsets are counted, by a one-way hash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:00:08 +11:00
saphidandClaude Opus 5.5 ee7951e8d0 Release 0.4.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:55:41 +11:00
saphidandClaude Opus 5.5 d456aa1f3d Take the #81 log scrubbing rework out of this release
Three review rounds kept finding ways that matching names, ssh operands and
user@host in one pass shows something the scrubber on main hid (URLs, home
folders, keys). The two #81 edge cases are minor; the scrubber goes back to
main's, and #81 stays open with the findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:07:35 +11:00
saphidandClaude Opus 5.5 6c7b2ca22f Log scrubbing: match names in the text as it came, with user@host found alongside
Round 2 of the review: scrubbing the text before matching names stopped a
name the scrubber partly rewrote ("frame.local Jane Doe headset") from
matching whole, and cut the text before names were hidden. Instead the
shared scrubber's user@host patterns are found in the original text as
spans too, so a name can't take their @. A randomised comparison with the
scrubber on main (160,000 cases) found nothing it hid that this shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:59:42 +11:00
saphidandClaude Opus 5.5 8a06848b5f Log scrubbing: find overlapping ssh operands; scrub user names before display names
Review of #81 found two cases that scrubbed less than before: "to hostname X"
left X visible, and a display name ending in "@<host>" took the @ the user
name scrubber needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:53:04 +11:00
saphid 9cca67529b Merge rel/issue87 into the release batch 2026-10-12 22:42:12 +11:00
saphidandClaude Opus 5.5 b656f80922 Host-key recovery UI: whole reasons, no doubled stops, header fits, selection follows (#87)
Follow-up to #85 from #87:
- A failure can carry a short reason (SSH identity changed, Saved SSH identity
  missing, This computer's key refused). The address that failed shows it
  instead of repeating the whole message, so the offline banner no longer
  reads "...may be answering here.." or "...must be on).."; the banner also
  drops a closing full stop before adding its own and keeps "SSH"/"Frame"
  capitalised.
- The header pill leads with that reason (then the countdown), so a narrow
  pill cuts the countdown, not the reason. The Devices tab's changed-key and
  refused-key explanations wrap; the list row shows the short reason with the
  whole message as its tooltip.
- The header's pill and buttons sit in one group pushed right with tighter
  gaps; the pill and headset picker give way, the buttons don't, and the
  shortcut hints hide at 1366 px and below. Refresh no longer overhangs at
  1280 px.
- Use this headset selects the headset it switched to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:41:56 +11:00
saphid 7cc418662d Merge rel/apkcat into the release batch 2026-10-12 22:33:17 +11:00
saphid b2bbff4305 Merge rel/comfort into the release batch 2026-10-12 22:33:17 +11:00
saphid 2cfcdaaf0e Merge rel/issue81 into the release batch 2026-10-12 22:33:17 +11:00
saphidandClaude Opus 5.5 2960581541 APK installs: time large copies by size, readable errors, no leftovers
- _copy allowed a fixed 10 minutes, failing any APK over about 300 MB on a
  4 Mbit/s link; it now allows at least 512 KiB/s.
- ssh() and _copy decode output with errors='replace', so a message in
  another encoding is a FrameError, not a UnicodeDecodeError; ssh() with
  bytes input no longer reports "b'...'".
- A failed update removes the partial app.apk.part it copied.
- frame_apk.apk_info turns a damaged APK's zlib.error, EOFError,
  ValueError or RuntimeError into ApkError (the upload path didn't catch
  them, so they gave a 500 and no install report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:32:54 +11:00
saphidandClaude Opus 5.5 8b1c290730 Telemetry: name the APK install failures that were 'other'
steam_not_running, steam_not_responding, steam_js_error and artwork_failed
for the Frame-side Steam client script (its "Connection refused" and
"timed out" were filed as frame_unreachable), copy_timeout and copy_failed
for rsync/scp, internal_error for Python's own errors (KeyError,
JSONDecodeError, ...) whatever they say, and apk_unreadable now covers
every frame_apk.ApkError wording. docs/privacy.md lists them all, and a
test keeps it in sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:32:54 +11:00
saphidandClaude Opus 5.5 b64082c974 Comfort: run the helper cached on the headset; send the sources only when missing
Every call (one per 5 s poll) sent ~35 KB of frame_comfort.py, frame_status.py
and frame_steam.py over ssh, though they are already cached on the headset in a
content-addressed folder. Now the first ssh only checks that folder and runs
the cached copy; if any file is missing it answers {"comfort": "missing"} and
a second call writes the sources as before (umask 077, temp file then rename,
same version hash).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:29:32 +11:00
saphidandClaude Opus 5.5 7c1d698400 Comfort: don't report the poll meeting an unreachable Frame, or poll while it is
The page polled POST /api/comfort status every 5 s whatever the connection,
and every POST Failure >= 500 went to diagnostics. ssh already marks link
failures, but those are still sent once per session per category, so each
session with a sleeping, away or re-keyed headset sent up to three reports
from this poll (most of 0.4.2's error reports). GET /api/status never reports
Failures at all.

- server: a polled POST read (POLLS: comfort status) that couldn't reach the
  headset isn't a diagnostic, as with GET /api/status. Real comfort errors,
  and link failures from starting or cancelling a session, still are.
- page: the comfort poll starts after the first status check and skips its
  ssh while the Frame is offline (online === false); status checks bring
  it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:29:32 +11:00
saphidandClaude Opus 5.5 b6406a90a4 Log scrubbing: find names and ssh operands in the original text (#81)
Follow-up to #79. Known names and the hosts ssh names are now located as
spans in the text as it came, overlapping spans merged, and replaced in one
pass. A display name containing a scrubber token ("<host> Jane Doe headset")
now goes whole, and a name like "to host" can no longer hide ssh's
"connect to host X" operand. Tokens on their own are still left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:27:44 +11:00
saphidandClaude Opus 5.5 9b26c907ce Telemetry: count headsets, not just installs
frame_connected now carries headset_id, a one-way hash of the headset's
/etc/machine-id worked out on the headset (frame_status.py), and
headsets_saved, how many headsets this copy of the app has set up. It is
sent once per build for each headset. The hash goes to usage statistics
only: the status endpoint and MCP clients don't get it. docs/privacy.md
says what is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:42:03 +11:00
Alex Southwell 5f3798b79b Merge pull request #88 from saphid/fix/tests-real-ssh-dir
Tests: keep every test (and the servers they start) off the real ~/.ssh
2026-10-12 16:34:34 +11:00
saphidandClaude Opus 5.5 6e0b140efc e2e: run only inside scripts/e2e.sh's container, and never start a server otherwise
test_agents had no harness.require() and FrameTestCase started ui/server.py
unconditionally, so plain discovery over tests/e2e started a server on this
computer's real app data, ~/.ssh and cache. FRAME_E2E=1 alone outside the
container did the same with the real cache.

require() now also needs FRAME_E2E_CONTAINER=1 (set in compose.yaml) and the
container runtime's marker file, and says why it refuses; FrameTestCase and
Server.start call it before starting anything; test_agents calls it at
module level. test_sandbox checks discovery over tests/e2e, with and
without FRAME_E2E=1, skips everything and starts no server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:21:09 +11:00
saphidandClaude Opus 5.5 226ec40720 Tests: keep the e2e container's real home; sandbox the per-user cache folders too
tests/e2e/test_android imported its APK helpers from the sandboxed
test_frame_apk, so the HOME move reached the whole e2e process: frame_connect
wrote keys and config into the temp home while OpenSSH, which finds ~/.ssh
from the password database, kept reading the container user's. The helpers
now live in the sandbox-free tests/apk_fixtures.py, and the e2e harness sets
the data dir, telemetry and compat DB guards itself (it used to get them
through that import).

sandbox.py also points LOCALAPPDATA, APPDATA and the XDG folders into the
throwaway home, so test servers on Windows and Linux no longer warm indexes
or sweep staging folders in the real app cache. test_sandbox checks both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:56:26 +11:00
saphidandClaude Opus 5.5 f1aaed6279 Tests: keep every test (and the servers they start) off the real ~/.ssh
Servers started by test_server's OneServer and LocalMode tests got no
FRAME_CONTROL_SSH_DIR, so each one imported the real ~/.ssh/config into a
fresh throwaway devices.json and seeded a pin into the real
~/.ssh/frame-control-hosts: about a dozen files per suite run.

tests/sandbox.py now gives the whole run a throwaway HOME/USERPROFILE and
FRAME_CONTROL_SSH_DIR (inherited by child processes), every test module
imports it first, and frame_connect.SSH_DIR honours FRAME_CONTROL_SSH_DIR
like frame_devices.ssh_dir(). test_sandbox checks the paths and reruns the
tests that leaked from the original environment, asserting nothing under
the real ~/.ssh changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:40:01 +11:00
40 changed files with 934 additions and 161 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.4",
"version": "0.4.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.4",
"version": "0.4.5",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.4",
"version": "0.4.5",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+16 -7
View File
@@ -33,6 +33,12 @@ computer, exactly as they were sent.
yours.
- Every event includes the app version, OS name (macOS, Windows or Linux),
CPU architecture and Python version.
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
so the machine-id itself never leaves it. It is the same for one headset on
every computer, which lets us count headsets rather than installs; it can't
be turned back into the machine-id or a serial number. `headsets_saved` is how
many headsets this copy of Frame Control has set up.
## Usage events
@@ -41,7 +47,7 @@ computer, exactly as they were sent.
| `app_installed` | First run | |
| `app_updated` | First run of a new version | `from_version` |
| `app_opened` | At most once a day | |
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
| `tab_viewed` | The first click on each tab in a session | `tab` |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
@@ -51,12 +57,15 @@ error becomes one category from this fixed list, plus Android's own
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
`cant_run_on_frame`, `steam_shortcut`, `frame_not_set_up`, `frame_auth`,
`frame_unreachable`, `frame_disk_full`, `download_failed`, `flatpak`,
`cancelled`, `lepton` or `other`. A VR APK that installed without Frame
Control's OpenXR compatibility layer, because this copy of the app is missing
it, carries `xr_layer_missing: true`; otherwise that field is left out. It
names what was installed only when that's already public:
`cant_run_on_frame`, `steam_shortcut`, `steam_not_running`,
`steam_not_responding`, `steam_js_error`, `artwork_failed`, `copy_timeout`,
`frame_not_set_up`, `frame_auth`, `frame_unreachable`, `frame_disk_full`,
`copy_failed`, `download_failed`, `flatpak`, `cancelled`, `lepton`,
`internal_error` (a mistake in Frame Control's own code) or `other`. A VR
APK that installed without Frame Control's OpenXR compatibility layer,
because this copy of the app is missing it, carries `xr_layer_missing: true`;
otherwise that field is left out. It names what was installed only when
that's already public:
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
catalogue app's package name
+87
View File
@@ -0,0 +1,87 @@
"""Small APKs built in memory (binary manifest plus resource table), for test_frame_apk
and the end-to-end tests. Deliberately doesn't import sandbox: tests/e2e runs in a
throwaway container and keeps its real home, which sandbox would move."""
import io
import os
import struct
import sys
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_apk # noqa: E402
def pool(strings, utf8=False):
"""A ResStringPool chunk."""
data, offsets = b'', []
for s in strings:
offsets.append(len(data))
if utf8:
b = s.encode()
data += bytes([len(s), len(b)]) + b + b'\0'
else:
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
data += b'\0' * (-len(data) % 4)
start = 28 + 4 * len(strings)
body = struct.pack(f'<{len(strings)}I', *offsets) + data
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
package_raw=False drops the package's raw string (as some repackers do);
foreign_label adds a non-android `label` attribute after android:label.
"""
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
def element(name, attrs):
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
for aname, raw, dtype, value in attrs:
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
none = 0xffffffff
chunks = (pool(strings) + resmap
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
(2, none, frame_apk.T_REF, version_ref),
(11, none, frame_apk.T_INT_DEC, 210)])
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
def resources(values):
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
pkg_body = b''
for (tid, lang, density), entries in values.items():
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
count = max(entries) + 1
offsets, data = [], b''
for i in range(count):
if i in entries:
offsets.append(len(data))
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
else:
offsets.append(0xffffffff)
header = 20 + 64
estart = header + 4 * count
body = struct.pack(f'<{count}I', *offsets) + data
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
pkg = pkg_header + pkg_body
table = pool(strings, utf8=True) + pkg
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
def apk(files):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
for name, data in files.items():
z.writestr(name, data)
return buf.getvalue()
+27 -4
View File
@@ -10,6 +10,7 @@ import atexit
import http.client
import json
import os
import shutil
import socket
import subprocess
import sys
@@ -22,7 +23,22 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
ENABLED = os.environ.get('FRAME_E2E') == '1'
# Only inside the compose `host` container (FRAME_E2E_CONTAINER from compose.yaml, plus
# the container runtime's marker file): these tests drive ui/server.py with the real
# home, ~/.ssh and cache, which is only safe in a throwaway container.
REQUESTED = os.environ.get('FRAME_E2E') == '1'
IN_CONTAINER = os.environ.get('FRAME_E2E_CONTAINER') == '1' and (
os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv'))
ENABLED = REQUESTED and IN_CONTAINER
if ENABLED:
# What tests/sandbox.py does, minus moving HOME: the container's real ~/.ssh is what
# points `ssh frame` at the fake Frame, and OpenSSH reads ~/.ssh from the password
# database, not $HOME. (These tests used to get this through test_frame_apk.)
_data = tempfile.mkdtemp(prefix='frame-control-e2e-')
atexit.register(shutil.rmtree, _data, ignore_errors=True)
os.environ['FRAME_CONTROL_DATA_DIR'] = _data
os.environ['FRAME_CONTROL_TELEMETRY'] = '0'
os.environ['FRAME_COMPAT_DB_URL'] = 'http://127.0.0.1:9'
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
HOME = '/home/steamos'
@@ -30,9 +46,14 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
def require():
"""Call at module level: skips the module unless the fake Frame is up."""
if not ENABLED:
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
"""Skips (the module, at module level; the class, in setUpClass) unless this is
scripts/e2e.sh's container with the fake Frame up."""
if ENABLED:
return
if REQUESTED:
raise unittest.SkipTest('FRAME_E2E=1 outside the e2e container: these tests use the real home, ~/.ssh '
'and app cache, so they only run in scripts/e2e.sh\'s throwaway container')
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
# ---- the fake Frame's control port --------------------------------------------
@@ -97,6 +118,7 @@ class Server:
@classmethod
def start(cls):
require() # never a server on this computer's real data
if cls.proc and cls.proc.poll() is None:
return
with socket.socket() as s:
@@ -193,6 +215,7 @@ class FrameTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
require()
Server.start()
def setUp(self):
+2
View File
@@ -6,6 +6,8 @@ import sys
import harness
from harness import api, ok, finished, ssh
harness.require()
sys.path.insert(0, str(harness.ROOT / 'ui'))
import frame_mcp
+1 -1
View File
@@ -5,7 +5,7 @@ import unittest
import harness
from harness import HOME, exists, ok, state, upload, wait_for
from test_frame_apk import apk, manifest, resources
from apk_fixtures import apk, manifest, resources
harness.require()
+1
View File
@@ -43,6 +43,7 @@ services:
FAKEFRAME_CTL: http://fakeframe:9999
FAKEFRAME_HOST: fakeframe
FRAME_E2E: "1"
FRAME_E2E_CONTAINER: "1" # tests/e2e/harness.py refuses to run anywhere else
healthcheck:
test: ["CMD", "test", "-f", "/home/tester/.ready"]
interval: 1s
+31 -1
View File
@@ -1,12 +1,20 @@
"""Imported first by every test module: nothing a test does reaches this person's
app data, their telemetry, or the shared compatibility database.
app data, their ~/.ssh, their telemetry, or the shared compatibility database.
Must run before any ui module is imported, since those read these at import time.
The variables are set in os.environ, so servers and scripts the tests start
inherit them too (they pass {**os.environ, ...}).
"""
import atexit
import os
import shutil
import tempfile
from pathlib import Path
# The environment and real ~/.ssh from before the changes below: test_sandbox
# reruns tests from that environment and checks ~/.ssh is left alone.
ORIGINAL_ENV = dict(os.environ)
REAL_SSH_DIR = Path.home() / ".ssh"
_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
atexit.register(shutil.rmtree, _dir, ignore_errors=True)
@@ -14,3 +22,25 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
# A maintainer's machine holds the database key; send anything that slips through nowhere.
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
# its lock file) and the other per-user folders some modules use. A server started
# without FRAME_CONTROL_SSH_DIR used to import the real ~/.ssh/config and seed a pin
# into the real ~/.ssh/frame-control-hosts on every run.
#
# tests/e2e must not import this (directly or through a test_* module): they run in
# a throwaway container whose real ~/.ssh points `ssh frame` at the fake Frame, and
# OpenSSH finds ~/.ssh from the password database, not $HOME. Shared helpers they
# need live in sandbox-free modules such as apk_fixtures.py.
HOME = os.path.join(_dir, "home")
SSH_DIR = os.path.join(HOME, ".ssh")
os.makedirs(SSH_DIR, mode=0o700)
os.environ["HOME"] = HOME
os.environ["USERPROFILE"] = HOME # Path.home() on Windows
os.environ["FRAME_CONTROL_SSH_DIR"] = SSH_DIR # frame_devices.ssh_dir(), frame_connect.SSH_DIR
# The per-user folders that don't follow HOME when these are set: frame_host.cache_dir()
# (servers warm indexes there and sweep its staging folders) and data_dir()'s fallbacks.
for _name, _sub in (("LOCALAPPDATA", "AppData/Local"), ("APPDATA", "AppData/Roaming"),
("XDG_CACHE_HOME", ".cache"), ("XDG_DATA_HOME", ".local/share"),
("XDG_STATE_HOME", ".local/state"), ("XDG_CONFIG_HOME", ".config")):
os.environ[_name] = os.path.join(HOME, *_sub.split("/"))
+1
View File
@@ -1,4 +1,5 @@
"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
+1
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
+1
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile
from pathlib import Path
from unittest.mock import patch
+1
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
+45
View File
@@ -1,4 +1,5 @@
"""Fake-Frame session clock/actions plus real helper serialization and sensor probes."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import shutil
import json
@@ -255,3 +256,47 @@ class SensorTests(unittest.TestCase):
self.assertIsNone(status.activity_level())
with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
self.assertEqual(status.activity_level(), 3)
class DesktopBundleTests(unittest.TestCase):
"""server.comfort runs the copy cached on the headset; the sources go only when it's missing."""
@unittest.skipUnless(os.name == "posix", "checks POSIX permissions")
def test_sources_are_sent_once_per_version(self):
import server
with tempfile.TemporaryDirectory() as tmp:
here, home = Path(tmp) / 'ui', Path(tmp) / 'frame'
here.mkdir()
home.mkdir()
for name in server.COMFORT_FILES:
(here / name).write_text('# ' + name + '\n')
(here / 'frame_comfort.py').write_text('import json, sys\nprint(json.dumps({"active": False, "got": sys.argv[1]}))\n')
sent = []
def ssh(remote, *, stdin, timeout): # the headset: the script run under its own home
sent.append(stdin)
return subprocess.run([sys.executable, '-'], input=stdin, capture_output=True, text=True, check=True,
env={**os.environ, 'HOME': str(home)}).stdout
with patch.object(server, 'HERE', here), patch.object(server, 'ssh', side_effect=ssh):
first = server.comfort({'action': 'status'})
self.assertEqual(json.loads(first['got']), {'action': 'status'})
self.assertEqual(len(sent), 2) # the check, then the sources
self.assertNotIn('# frame_steam.py', sent[0])
self.assertIn('# frame_steam.py', sent[1])
self.assertEqual(server.comfort({'action': 'status'}), first)
self.assertEqual(len(sent), 3)
self.assertNotIn('# frame_steam.py', sent[2])
cached, = (home / '.cache/frame-control/comfort').iterdir()
self.assertEqual(sorted(p.name for p in cached.iterdir()), sorted(server.COMFORT_FILES))
self.assertEqual(cached.stat().st_mode & 0o777, 0o700)
self.assertTrue(all(p.stat().st_mode & 0o777 == 0o600 for p in cached.iterdir()))
# A copy cut short is completed; a changed helper is a new version.
(cached / 'frame_status.py').unlink()
server.comfort({'action': 'status'})
self.assertEqual(len(sent), 5)
self.assertTrue((cached / 'frame_status.py').exists())
(here / 'frame_steam.py').write_text('# frame_steam.py, changed\n')
server.comfort({'action': 'status'})
self.assertEqual(len(sent), 7)
self.assertEqual(len(list((home / '.cache/frame-control/comfort').iterdir())), 2)
+30
View File
@@ -1,4 +1,5 @@
"""Run the actual shared page's comfort renderer against a minimal DOM/bridge."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import pathlib
import shutil
import subprocess
@@ -58,3 +59,32 @@ const log = ()=>{}, toast = ()=>{};
'''
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_poll_waits_while_the_frame_is_unreachable(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
start = page.index('async function pollComfort()')
code = page[start:page.index('$("comfortForm").onsubmit', start)]
js = r'''
const assert = require('node:assert/strict');
const el = {textContent: 'Checking session…'};
const $ = () => el;
let online = false, comfortBusy = false, calls = 0, timers = 0;
const api = async () => { calls++; return {}; };
const renderComfort = () => {};
const setTimeout = () => { timers++; };
''' + code + r'''
(async () => {
await pollComfort();
assert.equal(calls, 0); // no ssh attempt while the status check can't reach the Frame
assert.match(el.textContent, /when the Frame is connected/);
assert.equal(timers, 1); // still scheduled, to resume when it's back
online = true;
await pollComfort();
assert.equal(calls, 1);
online = null; // a status check that failed for another reason: keep polling
await pollComfort();
assert.equal(calls, 2);
})().catch(e => { console.error(e); process.exitCode = 1; });
'''
result = subprocess.run(['node', '-e', js], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
+1
View File
@@ -1,4 +1,5 @@
"""Offline authenticated repository fixtures; no tests contact a server."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
+1
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
+57
View File
@@ -1,4 +1,5 @@
"""Offline artwork, Steam API and launcher supervision regressions."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import importlib.util
import json
import os
@@ -422,6 +423,19 @@ class InstallTests(unittest.TestCase):
self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list])
self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list))
def test_failed_update_removes_partial_apk_and_keeps_the_app(self):
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
patch.object(android, 'read_meta', return_value=dict(self.existing)), \
patch.object(android, '_copy', side_effect=android.FrameError('copying x.apk to the Frame timed out')), \
patch.object(android, 'ssh', return_value='') as ssh, \
patch.object(android, 'shortcut_tool') as api:
with self.assertRaisesRegex(android.FrameError, 'timed out'):
android._install('x.apk', self.info, 'org.test.vr', False, None, None)
commands = [c.args[0] for c in ssh.call_args_list]
self.assertIn('rm -f Applications/Android/org.test.vr/app.apk.part', commands)
self.assertFalse(any(c.startswith('rm -rf') for c in commands))
api.assert_not_called()
def test_remove_keeps_data_when_requested_and_survives_steam_failure(self):
with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \
patch.object(android, 'stop'), \
@@ -485,6 +499,49 @@ class InstallTests(unittest.TestCase):
self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
class CopyAndSshTests(unittest.TestCase):
"""The commands behind an install fail as FrameErrors with readable words, and a large APK
gets the time it needs."""
def fake(self, stderr, code=1, stdout=b''):
"""frame_host.run_ssh running a stand-in that writes these bytes, with the caller's options."""
script = (f'import sys; sys.stdout.buffer.write({stdout!r}); sys.stderr.buffer.write({stderr!r}); '
f'sys.exit({code})')
return lambda argv, **kwargs: subprocess.run([sys.executable, '-c', script], **kwargs)
def test_large_apk_copy_timeout_grows_with_its_size(self):
seen = {}
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)), \
patch.object(android.os.path, 'getsize', return_value=2 * 1024 ** 3):
android._copy(f.name, 'Applications/Android/x/app.apk.part')
# 2 GB at the slowest rate allowed: 4096 s, not the old fixed 600 (300 MB at 4 Mbit/s).
self.assertGreaterEqual(seen['timeout'], 2 * 1024 ** 3 / (512 * 1024))
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)):
android._copy(f.name, 'x/launch.sh', timeout=120)
self.assertEqual(seen['timeout'], 120) # a small file keeps the least time given
def test_copy_failure_in_another_encoding_is_a_frame_error(self):
# A message from a Windows code page, or a file name, isn't UTF-8.
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
patch.object(android.shutil, 'which', return_value=None), \
patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'scp: Verbindung \x81ber\xff\n')):
with self.assertRaisesRegex(android.FrameError, r'\Acopying .* to the Frame failed: scp: Verbindung'):
android._copy(f.name, 'x/app.apk.part')
def test_ssh_failure_messages_are_text(self):
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'mv: \xff cannot move\n')):
with self.assertRaisesRegex(android.FrameError, r'\Amv: .* cannot move\Z'):
android.ssh('mv a b')
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'cat: write error: No space left on device\n')):
with self.assertRaises(android.FrameError) as caught:
android.ssh('cat > x', input=b'\x89PNG') # bytes in, bytes out
self.assertEqual(str(caught.exception), 'cat: write error: No space left on device')
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'', code=0, stdout=b'ok \xff\n')):
self.assertEqual(android.ssh('echo ok').strip(), 'ok �')
class SteamAPITests(unittest.TestCase):
def test_artwork_api_enums_and_safe_serialization(self):
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
+15 -76
View File
@@ -11,82 +11,7 @@ import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_apk # noqa: E402
def pool(strings, utf8=False):
"""A ResStringPool chunk."""
data, offsets = b'', []
for s in strings:
offsets.append(len(data))
if utf8:
b = s.encode()
data += bytes([len(s), len(b)]) + b + b'\0'
else:
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
data += b'\0' * (-len(data) % 4)
start = 28 + 4 * len(strings)
body = struct.pack(f'<{len(strings)}I', *offsets) + data
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
package_raw=False drops the package's raw string (as some repackers do);
foreign_label adds a non-android `label` attribute after android:label.
"""
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
def element(name, attrs):
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
for aname, raw, dtype, value in attrs:
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
none = 0xffffffff
chunks = (pool(strings) + resmap
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
(2, none, frame_apk.T_REF, version_ref),
(11, none, frame_apk.T_INT_DEC, 210)])
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
def resources(values):
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
pkg_body = b''
for (tid, lang, density), entries in values.items():
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
count = max(entries) + 1
offsets, data = [], b''
for i in range(count):
if i in entries:
offsets.append(len(data))
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
else:
offsets.append(0xffffffff)
header = 20 + 64
estart = header + 4 * count
body = struct.pack(f'<{count}I', *offsets) + data
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
pkg = pkg_header + pkg_body
table = pool(strings, utf8=True) + pkg
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
def apk(files):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
for name, data in files.items():
z.writestr(name, data)
return buf.getvalue()
from apk_fixtures import apk, manifest, pool, resources # noqa: E402,F401
class ApkInfo(unittest.TestCase):
@@ -168,6 +93,20 @@ class ApkInfo(unittest.TestCase):
frame_apk.MAX_MANIFEST = limit
self.assertLess(peak, 8 * 1024**2)
def test_damaged_download_is_an_apk_error(self):
# Corrupt deflate data used to escape as zlib.error, which the upload path didn't catch
# (a 500, and no install report) and frame_android.install reported as 'other'.
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('AndroidManifest.xml', manifest('com.example.bad', 0x7f010000, 0x7f010001, 21) * 20)
data = bytearray(buf.getvalue())
at = data.index(b'PK\x03\x04')
start = at + 30 + sum(struct.unpack_from('<HH', data, at + 26)) # past the name and extra field
for i in range(start, start + 40):
data[i] ^= 0x5a
with self.assertRaisesRegex(frame_apk.ApkError, 'could not read the APK manifest'):
self.read(bytes(data))
def test_refuses_compression_android_cant_read(self):
for method in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA):
buf = io.BytesIO()
+2 -1
View File
@@ -1,4 +1,5 @@
"""Local-only VR manifest, raw ZIP and independent signature checks."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import os
from pathlib import Path
@@ -15,7 +16,7 @@ import frame_apk
import frame_apk_vr as vr
import frame_apk_sign as signing
import frame_android
from test_frame_apk import pool
from apk_fixtures import pool
DEFAULT = 'android.intent.category.DEFAULT'
+1
View File
@@ -6,6 +6,7 @@ identity line, then starts TLS as the *client*.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import shutil
+1
View File
@@ -1,4 +1,5 @@
"""Every public sideload entry point reaches the mandatory five-slot writer."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import contextlib
import io
import json
+17 -1
View File
@@ -46,6 +46,17 @@ def explain(msg):
return None
class FailureDetail(unittest.TestCase):
def test_an_address_that_failed_says_why_in_a_few_words(self):
# #87: the banner put each address's detail after the headline, and with the whole
# message as the detail it said everything twice ("...may be answering here..").
self.assertEqual(fl.failure_detail({"message": "A long sentence.", "short": "SSH identity changed"}),
"SSH identity changed")
self.assertEqual(fl.failure_detail({"message": "The connection to the Frame dropped. Try again."}),
"The connection to the Frame dropped. Try again.")
self.assertEqual(fl.failure_detail(None), "SSH failed")
class Probe(unittest.TestCase):
def test_answers_refusals_and_unknown_names(self):
with socket.socket() as srv:
@@ -200,7 +211,8 @@ class Connecting(unittest.TestCase):
rows = {p["host"]: p for p in s["probes"]}
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
self.assertEqual(rows["::1"]["state"], "sshfailed")
self.assertIn("different SSH identity", rows["::1"]["detail"])
# Its row says why in a few words: the banner's headline already has the whole sentence (#87).
self.assertEqual(rows["::1"]["detail"], "SSH identity changed")
# Every ssh command was pointed at the winner, with the host key pinned per device.
alias, opts = self.routes[-1]
self.assertEqual(alias, "frame-t")
@@ -260,6 +272,8 @@ class Connecting(unittest.TestCase):
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
self.assertIn("SSH key", s["error"]["message"])
self.assertEqual(s["error"]["auth"], "denied") # the page offers Set Up Connection
self.assertEqual(s["error"]["short"], "This computer's key refused") # the header pill's words (#87)
self.assertEqual(s["probes"][0]["detail"], "This computer's key refused")
def test_pinned_identity_is_checked_strictly(self):
d = self.device("localhost")
@@ -349,6 +363,7 @@ class Connecting(unittest.TestCase):
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
self.assertEqual(s["error"]["hostkey"]["state"], "missing")
self.assertIn("missing or can't be read", s["error"]["message"])
self.assertEqual(s["error"]["short"], "Saved SSH identity missing")
view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"])
self.assertTrue(view["identity_lost"])
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
@@ -634,6 +649,7 @@ class Connecting(unittest.TestCase):
self.assertEqual((shown["state"], shown["type"], shown["host"]), ("changed", "ssh-ed25519", "localhost"))
self.assertTrue(shown["fingerprint"].startswith("SHA256:"))
self.assertIn("Devices tab", s["error"]["message"])
self.assertEqual(s["error"]["short"], "SSH identity changed")
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved) # never accepted on its own
r = self.command(d)
self.assertEqual(r.returncode, 255)
+136
View File
@@ -0,0 +1,136 @@
"""The connection pill, the offline banner and the Devices tab's selection: the page's own
functions from ui/index.html, run in Node against a minimal DOM (#87)."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import pathlib
import re
import shutil
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
PAGE = (ROOT / "ui/index.html").read_text(encoding="utf-8")
def between(start, end):
return PAGE[PAGE.index(start):PAGE.index(end)]
SETUP = r'''
const assert = require('node:assert/strict');
const elements = new Map();
const $ = id => {
if (!elements.has(id)) elements.set(id, {id, textContent: '', innerHTML: '', title: '', hidden: true, value: ''});
return elements.get(id);
};
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
const serverNow = () => 1000;
const activeStage = s => (s.stages || []).find(x => x.state === 'active');
const ago = () => 'a minute ago';
const openTrust = () => {};
const window = {};
let online = false;
const link = {live: true, s: null};
const dv = {list: [], sel: null, data: null, found: 'x', editing: 'y'};
let rendered = 0;
const renderDevices = () => { rendered++; };
let refuse = false;
const act = async (label, fn) => refuse ? undefined : fn();
const api = async () => ({ok: true});
const STAGES = [{id: 'find', label: 'Finding the headset', state: 'done'},
{id: 'identity', label: "Checking the headset's identity", state: 'failed'}];
const changed = {phase: 'failed', retry_at: 1012, device: {id: 'd1', name: 'Office Frame'}, network: {name: 'Home'},
stages: STAGES, probes: [{host: 'frame.local', detail: 'SSH identity changed'}],
error: {stage: 'identity', short: 'SSH identity changed', hostkey: {state: 'changed'},
message: 'This address answered with a different SSH identity from the one saved for Office Frame. '
+ 'If not, a different device may be answering here.'}};
'''
@unittest.skipUnless(shutil.which("node"), "Node runs the page's own functions")
class LinkUI(unittest.TestCase):
def run_page(self, checks):
code = (between("function netLabel(s) {", "function renderConnDlg() {")
+ between("async function useDevice(id) {", "async function devAction(")
+ between("function deviceStatus(d) {", "function renderDevices() {"))
script = SETUP + code + "\n(async () => {\n" + checks + "\n})().catch(e => { console.error(e); process.exitCode = 1; });"
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
def test_the_pill_leads_with_the_reason(self):
# The header pill cut off "Offline: checking the headset's identity failed · retry in…":
# the reason comes first, in a few words, and only the countdown gets cut.
self.run_page(r'''
link.s = changed;
renderPill();
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>SSH identity changed · retry in 12 s<\/span>/);
assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.');
// Without a short reason: the stage that failed.
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
renderPill();
assert.match($('conn').innerHTML, /<span>Checking the headset&#39;s identity failed<\/span>/);
''')
def test_the_banner_never_doubles_a_full_stop(self):
# "…may be answering here.." and "…(Developer Mode must be on).." (#87).
self.run_page(r'''
link.s = changed;
renderBanner();
assert.equal($('offDetail').textContent, 'Office Frame on Home. Tried frame.local: SSH identity changed. Trying again in 12 s.');
link.s = {...changed, probes: [
{host: 'frame.local', detail: "The Frame didn't accept this computer's SSH key. Set Up Connection pairs this computer with it again (Developer Mode must be on)."},
{host: '100.64.0.2', detail: 'No answer'}]};
renderBanner();
const text = $('offDetail').textContent;
assert.ok(!/\.\./.test(text), text);
assert.ok(text.includes("frame.local: the Frame didn't accept"), text);
assert.ok(text.includes('must be on); 100.64.0.2: no answer. Trying'), text);
assert.equal(asClause('SSH identity changed.'), 'SSH identity changed'); // abbreviations keep their case
assert.equal(asClause('Answered in 3 ms'), 'answered in 3 ms');
''')
def test_the_devices_list_keeps_the_whole_message_within_reach(self):
self.run_page(r'''
link.s = changed;
const [dot, text, full] = deviceStatus({active: true, addresses: []});
assert.equal(dot, 'off');
assert.equal(text, 'SSH identity changed');
assert.equal(full, changed.error.message); // the row's tooltip
''')
def test_using_a_headset_selects_it(self):
# After "Use this headset" on Office Frame, the last headset's details stayed on show.
self.run_page(r'''
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd1', name: 'Office Frame', active: false}];
dv.sel = 'd0';
await useDevice('d1');
assert.equal(dv.sel, 'd1');
assert.equal(dv.found, null);
assert.equal(dv.editing, null);
assert.equal(rendered, 1);
// Refused (an install is running): the selection stays, and the header shows the one in use.
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd2', name: 'Spare', active: false}];
dv.sel = 'd0'; refuse = true;
await useDevice('d2');
assert.equal(dv.sel, 'd0');
assert.equal($('devSel').value, 'd0');
''')
class HeaderLayout(unittest.TestCase):
def test_the_devices_tab_explanation_wraps(self):
# It was cut to one line with no way to read the rest.
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
def test_the_header_buttons_dont_give_way(self):
# Refresh overhung the right edge at 1280 px: the pill (and the headset picker) shrink, not the buttons.
header = between("<header>", "</header>")
end = header[header.index('<div class="hdr-end">'):]
for el in ('id="conn"', 'id="devSel"', 'id="battChip"', 'id="reportBtn"', 'id="refreshAll"'):
self.assertIn(el, end)
self.assertIn(".hdr-end > :not(.pill, .devsel) { flex: none; }", PAGE)
self.assertRegex(PAGE, r"\.hdr-end \{[^}]*min-width: 0;[^}]*margin-left: auto;")
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -7,6 +7,7 @@ input reaching the source, and closing.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import base64
import http.client
import json
+1
View File
@@ -1,4 +1,5 @@
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import argparse
import io
import json
+1
View File
@@ -1,4 +1,5 @@
"""Fake-Frame panel responses and the headset page's real HTTP guards."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
+122
View File
@@ -0,0 +1,122 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_connect # noqa: E402
import frame_devices # noqa: E402
import frame_host # noqa: E402
def inside(path, folder):
path, folder = os.path.realpath(path), os.path.realpath(folder)
return path == folder or path.startswith(folder + os.sep)
def snapshot(folder):
"""{relative path: (size, mtime)} for everything under FOLDER."""
out = {}
for base, dirs, files in os.walk(folder):
for name in dirs + files:
p = os.path.join(base, name)
try:
st = os.lstat(p)
except OSError:
continue
out[os.path.relpath(p, folder)] = (st.st_size, st.st_mtime_ns)
return out
class Sandbox(unittest.TestCase):
"""Tests once wrote a host-key pin into the real ~/.ssh/frame-control-hosts on every
run (a server started without FRAME_CONTROL_SSH_DIR imported the real ~/.ssh/config)."""
def test_home_and_ssh_dir_are_throwaway(self):
tmp = tempfile.gettempdir()
self.assertTrue(inside(Path.home(), tmp), Path.home())
self.assertTrue(inside(os.path.expanduser("~"), tmp))
for d in (frame_devices.ssh_dir(), frame_devices.pin_dir(), frame_connect.SSH_DIR,
os.environ["FRAME_CONTROL_SSH_DIR"], frame_host.cache_dir(), frame_host.data_dir()):
self.assertTrue(inside(d, tmp), d)
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
def test_cache_folders_on_every_platform_are_throwaway(self):
"""cache_dir() reads LOCALAPPDATA on Windows and XDG_CACHE_HOME on Linux, which a
moved HOME alone doesn't cover: servers sweep staging folders there."""
tmp = tempfile.gettempdir()
for platform in ("MAC", "WINDOWS", None):
with mock.patch.object(frame_host, "MAC", platform == "MAC"), \
mock.patch.object(frame_host, "WINDOWS", platform == "WINDOWS"):
self.assertTrue(inside(frame_host.cache_dir("apk-sources"), tmp), platform)
for name in ("LOCALAPPDATA", "APPDATA", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"):
self.assertTrue(inside(os.environ[name], tmp), name)
def test_end_to_end_tests_stay_out_of_it(self):
"""tests/e2e keeps the container's real home (OpenSSH reads ~/.ssh from the password
database, not $HOME), so nothing there may import sandbox, even through a test_* module."""
here = Path(__file__).resolve().parent
pattern = re.compile(r"^\s*(import sandbox|from sandbox |import test_|from test_)", re.M)
for f in [*(here / "e2e").glob("*.py"), *(here / "smoke").glob("*.py"), here / "apk_fixtures.py"]:
self.assertIsNone(pattern.search(f.read_text(encoding="utf-8")), f)
def test_end_to_end_tests_start_no_server_outside_their_container(self):
"""Plain discovery over tests/e2e, with or without FRAME_E2E=1, must skip everything
and never start ui/server.py: outside scripts/e2e.sh's container it would run on
this computer's real app data, ~/.ssh and cache."""
e2e = str(Path(__file__).resolve().parent / "e2e")
code = ("import io, sys, unittest; sys.path.insert(0, sys.argv[1]); import harness; calls = []\n"
"harness.Server.start = classmethod(lambda cls: calls.append(cls))\n"
"suite = unittest.defaultTestLoader.discover(sys.argv[1], top_level_dir=sys.argv[1])\n"
"r = unittest.TextTestRunner(stream=io.StringIO()).run(suite)\n"
"print(len(calls), r.testsRun, len(r.skipped), len(r.errors) + len(r.failures))")
for extra in ({}, {"FRAME_E2E": "1"}):
env = {k: v for k, v in os.environ.items() if not k.startswith("FRAME_E2E")}
env.update(extra, PYTHONDONTWRITEBYTECODE="1")
out = subprocess.run([sys.executable, "-c", code, e2e], env=env, capture_output=True, text=True,
timeout=120)
self.assertEqual(out.returncode, 0, out.stderr[-2000:])
started, ran, skipped, broken = map(int, out.stdout.split())
self.assertEqual((started, broken), (0, 0), (extra, out.stdout))
self.assertEqual(ran, skipped, (extra, out.stdout))
self.assertGreater(skipped, 0, extra)
def test_child_processes_inherit_it(self):
out = subprocess.run(
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_devices, frame_connect;"
" from pathlib import Path; print(Path.home()); print(frame_devices.ssh_dir()); print(frame_connect.SSH_DIR)",
str(ROOT / "ui")], env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
capture_output=True, text=True, check=True).stdout.split("\n")
for d in filter(None, out):
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
self.assertTrue(inside(d, tempfile.gettempdir()), d)
def test_real_ssh_folder_untouched_by_tests_that_start_servers(self):
"""Reruns, from the environment the suite was started in, the tests that used to
leak (servers started without FRAME_CONTROL_SSH_DIR) plus the device and connect
tests, and checks nothing under the real ~/.ssh changed."""
real = sandbox.REAL_SSH_DIR
if inside(real, tempfile.gettempdir()):
self.skipTest("this run's HOME is already a temporary folder")
before = snapshot(real)
env = {k: v for k, v in sandbox.ORIGINAL_ENV.items() if k != "FRAME_CONTROL_SSH_DIR"}
env["PYTHONDONTWRITEBYTECODE"] = "1"
r = subprocess.run([sys.executable, "-m", "unittest", "-q", "test_server.OneServer", "test_server.LocalMode",
"test_devices", "test_connect"],
cwd=str(Path(__file__).resolve().parent), env=env, capture_output=True, text=True,
timeout=300)
self.assertEqual(r.returncode, 0, r.stderr[-3000:])
after = snapshot(real)
changed = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k)
and not k.startswith("agent")) # ssh-agent sockets come and go on their own
self.assertEqual(changed, [], f"tests changed the real {real}")
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
from pathlib import Path
import sys
+134 -6
View File
@@ -138,7 +138,7 @@ class Gates(Base):
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
tm.diagnostic("POST /api/comfort status", RuntimeError("ssh exited 255")) # the command's own exit code?
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "other", "other", "other"])
["frame_unreachable", "internal_error", "other", "other"])
def test_page_events_are_checked(self):
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
@@ -164,6 +164,38 @@ class Lifecycle(Base):
tm.frame_seen("20260922.1", "3.8")
self.assertEqual(len(self.queued()), 1)
def test_frame_once_per_headset_with_saved_count(self):
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
tm.frame_seen("20260922.1", "3.8", "fedcba9876543210", 2)
props = [e["properties"] for e in self.queued()]
self.assertEqual([(p["headset_id"], p["headsets_saved"]) for p in props],
[("0123456789abcdef", 2), ("fedcba9876543210", 2)])
def test_frame_many_headsets_are_not_sent_twice(self):
ids = ["%016x" % n for n in range(25)]
for _ in range(2):
for h in ids:
tm.frame_seen("20260922.1", "3.8", h, 25)
self.assertEqual(len(self.queued()), 25)
def test_frame_headset_id_must_be_the_hash(self):
tm.frame_seen("20260922.1", "3.8", "/etc/machine-id contents", None)
p = self.queued()[0]["properties"]
self.assertNotIn("headset_id", p)
self.assertNotIn("headsets_saved", p)
class HeadsetId(unittest.TestCase):
def test_hash_of_machine_id_and_none_without_one(self):
import hashlib
import frame_status
with mock.patch.object(frame_status, "read", return_value="abc123"):
self.assertEqual(frame_status.headset_id(),
hashlib.sha256(b"frame-control-headset:abc123").hexdigest()[:16])
with mock.patch.object(frame_status, "read", return_value=None):
self.assertIsNone(frame_status.headset_id())
class Sending(Base):
def serve(self, status=200):
@@ -263,6 +295,72 @@ class Scrub(unittest.TestCase):
self.assertEqual(tm.categorize(FileNotFoundError(2, "No such file or directory", "ssh"))[0], "tool_missing")
self.assertEqual(tm.categorize("[WinError 2] The system cannot find the file specified")[0], "tool_missing")
def test_apk_install_failures_that_were_other(self):
"""What frame_android.install can raise, worded as it is, that 0.4.x sent as 'other'
(or as frame_unreachable, when it was the Steam client or a slow copy)."""
tail = 'Traceback (most recent call last):\n File "<stdin>", line 19, in target_ws\n'
cases = {
"steam_not_running": [
"SharedJSContext not found: is the Steam client running?",
tail + "urllib.error.URLError: <urlopen error [Errno 111] Connection refused>",
tail + "ConnectionRefusedError: [Errno 111] Connection refused",
],
"steam_not_responding": [
"websocket handshake failed",
tail + "TimeoutError: timed out",
tail + "socket.timeout: timed out",
tail + ' File "<stdin>", line 45, in _read\nEOFError',
],
"steam_js_error": ['JS error: {"exceptionId": 1, "text": "Uncaught", "exception": '
'{"description": "Error: Steam artwork API unavailable; installation is incomplete"}}'],
"artwork_failed": [
"Steam artwork renderer did not produce every slot",
tail + "ValueError: incomplete artwork render",
tail + "ValueError: hero render is 0 bytes of png",
"could not prepare artwork: unknown artwork slot",
tail + "ValueError: all five Steam artwork slots are required",
],
"copy_timeout": ["copying Big Game (timed out).apk to the Frame timed out",
"copying launch.sh to the Frame timed out"],
"copy_failed": [
"copying game.apk to the Frame failed: rsync: connection unexpectedly closed (0 bytes received "
"so far) [sender]\nrsync error: error in rsync protocol data stream (code 12) at io.c(231)",
"copying game.apk to the Frame failed: ",
],
"apk_unreadable": [
"x.apk: not a readable APK: [Errno 2] No such file or directory: 'x.apk'",
"x.apk: could not read the APK manifest: Error -3 while decompressing data: invalid block type",
"x.apk: the APK manifest has no package name",
"x.apk: lib/arm64-v8a/libx.so in the APK uses a compression Android does not",
"x.apk: AndroidManifest.xml in the APK is 40 MB, more than a real one (16 MB)",
],
}
for category, messages in cases.items():
for message in messages:
self.assertEqual(tm.categorize(message)[0], category, message)
# Still where they were: ssh's own failures, and a full disk during a copy.
self.assertEqual(tm.categorize("timed out talking to frame")[0], "frame_unreachable")
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: ssh: connect to host 10.0.0.2 "
"port 22: Connection refused")[0], "frame_unreachable")
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: rsync: write failed on "
"\"app.apk.part\": No space left on device (28)")[0], "frame_disk_full")
self.assertEqual(tm.categorize("download failed: timed out")[0], "download_failed")
def test_python_errors_are_internal_whatever_they_say(self):
import json as _json
for error in (KeyError("paths"), TypeError("'NoneType' object is not subscriptable"),
AttributeError("x"), _json.JSONDecodeError("Expecting value", "", 0),
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
KeyError("timed out")):
self.assertEqual(tm.categorize(error)[0], "internal_error", repr(error))
self.assertEqual(tm.categorize("KeyError: 'paths'")[0], "other") # only the exception itself
self.assertEqual(tm.categorize(OSError(28, "No space left on device"))[0], "frame_disk_full")
def test_privacy_doc_lists_every_category(self):
doc = (ROOT / "docs" / "privacy.md").read_text()
for name in {n for n, _ in tm.CATEGORIES} | {"internal_error", "other"}:
self.assertIn(f"`{name}`", doc)
class Compat(Base):
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
@@ -376,14 +474,17 @@ class LinkFailureProvenance(Base):
c.close()
return result
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
# Failure; after the status poll's first connection failure, theirs are held back too,
# also past the 10-minute window.
def serve(self):
self.httpd = self.server.ThreadingHTTPServer(("127.0.0.1", 0), self.server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close)
self.addCleanup(self.httpd.shutdown)
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
# Failure; after the first connection failure, theirs are held back too, also past the
# 10-minute window.
self.serve()
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
meta = {"label": "Test App", "game_id": 5, "shortcut": None}
with mock.patch.object(frame_host, "run_ssh", return_value=down), \
@@ -399,7 +500,34 @@ class LinkFailureProvenance(Base):
self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 502)
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
if e["event"] == "$exception"]
self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "other")])
# The comfort poll's own failure isn't one (see the next test).
self.assertEqual(sent, [("POST /api/android launch", "frame_unreachable"), ("POST /api/android launch", "other")])
def test_the_comfort_poll_meeting_an_unreachable_frame_is_not_sent(self):
# 0.4.2: most reports were this poll (every 5 s) finding the headset asleep, away or
# refusing ssh. Like GET /api/status, it isn't reported; a session started or cancelled
# by hand still is, once, and a real failure of the poll is too.
self.serve()
for stderr in ("ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n",
"banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
"ssh: Could not resolve hostname frame: nodename nor servname provided\r\n",
"@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\nHost key verification failed.\r\n",
"No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict "
"checking.\r\nHost key verification failed.\r\n",
"steamos@192.168.1.9: Permission denied (publickey,password).\r\n"):
down = subprocess.CompletedProcess(["ssh"], 255, "", stderr)
with mock.patch.object(frame_host, "run_ssh", return_value=down):
self.assertGreaterEqual(self.request("/api/comfort", {"action": "status"})[0], 500, stderr)
self.assertEqual(self.queued(), [])
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Host is down\r\n")
with mock.patch.object(frame_host, "run_ssh", return_value=down):
self.request("/api/comfort", {"action": "cancel"})
broken = subprocess.CompletedProcess(["ssh"], 1, "", "Traceback (most recent call last):\nOSError: boom\n")
with mock.patch.object(frame_host, "run_ssh", return_value=broken):
self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 502)
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
if e["event"] == "$exception"]
self.assertEqual(sent, [("POST /api/comfort cancel", "frame_unreachable"), ("POST /api/comfort status", "other")])
def test_download_failures_after_a_poll_failure_are_still_sent(self):
# Review round 3: these file names, in the real checksum message through the web-link worker,
+1
View File
@@ -2,6 +2,7 @@
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
+1
View File
@@ -1,4 +1,5 @@
"""Own VR telemetry, optional catalogue and entitlement guards; no headset needed."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import ctypes
import json
from pathlib import Path
+28 -5
View File
@@ -61,13 +61,20 @@ def ssh(cmd, input=None, timeout=120):
try:
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
# The Frame writes UTF-8, whatever this computer's code page (Windows would decode it as
# cp1252). errors='replace': stray bytes (a file name) mustn't turn a failed command, or
# one that worked, into a UnicodeDecodeError.
text = ({'text': True, 'encoding': 'utf-8', 'errors': 'replace'}
if isinstance(input, str) or input is None else {})
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
timeout=timeout, **text)
except subprocess.TimeoutExpired:
raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
if p.returncode != 0:
error = FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
stderr = p.stderr if isinstance(p.stderr, str) else (p.stderr or b'').decode(errors='replace')
# Bytes input gives bytes output: decode it, so the message isn't "b'...'".
stderr, stdout = (v if isinstance(v, str) else (v or b'').decode(errors='replace')
for v in (p.stderr, p.stdout))
error = FrameError((stderr or stdout or f'ssh exited {p.returncode}').strip()[-600:])
raise frame_host.link_failure(error) if frame_host.ssh_link_failed(p.returncode, stderr) else error
return p.stdout
@@ -136,11 +143,19 @@ def check_installable(info):
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
# The slowest transfer a copy is allowed before it times out, in bytes a second (about 4 Mbit/s,
# a slow or busy Wi-Fi link). A fixed 10 minutes failed any APK over about 300 MB at that rate.
COPY_MIN_RATE = 512 * 1024
def _copy(src, dest, executable=False, timeout=600):
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp.
timeout is the least time allowed; a large file gets longer (COPY_MIN_RATE)."""
name = os.path.basename(src)
try:
timeout = max(timeout, os.path.getsize(src) / COPY_MIN_RATE)
except OSError:
pass # rsync or scp says what's wrong with the file
rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
if rsync:
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
@@ -148,7 +163,8 @@ def _copy(src, dest, executable=False, timeout=600):
else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
@@ -279,6 +295,13 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
ssh(f'rm -rf {d}', timeout=30)
except FrameError:
pass
elif not ok:
# An update that failed: the app still works, but a partial copy of the new APK
# (scp leaves one; it can be gigabytes) mustn't stay beside it.
try:
ssh(f'rm -f {d}/app.apk.part', timeout=30)
except FrameError:
pass
+4 -1
View File
@@ -7,6 +7,7 @@ the resource table in resources.arsc.
"""
import struct
import zipfile
import zlib
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
@@ -221,7 +222,9 @@ def apk_info(path):
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
from frame_apk_vr import detection
vr_info = detection(manifest_data, names)
except (struct.error, IndexError, zipfile.BadZipFile) as e:
# A damaged download: corrupt deflate data (zlib.error), a member cut short (EOFError),
# undecodable text (UnicodeDecodeError) or an encrypted entry (RuntimeError).
except (struct.error, IndexError, zipfile.BadZipFile, zlib.error, EOFError, ValueError, RuntimeError) as e:
raise ApkError(f'could not read the APK manifest: {e}')
tags = {}
for tag, attrs in elements:
+2 -1
View File
@@ -31,7 +31,8 @@ import frame_host # noqa: E402
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
USER_FROM_ENV = "FRAME_USER" in os.environ
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
SSH_DIR = Path.home() / ".ssh"
# $FRAME_CONTROL_SSH_DIR in tests, as frame_devices.ssh_dir(), so they never touch the real one.
SSH_DIR = Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh")
KEY = SSH_DIR / "id_ed25519_frame"
# The devkit service only accepts ssh-rsa keys (write_key in Valve's
# steamos-devkit-service), so pairing uses a second key next to the ed25519 one.
+15 -4
View File
@@ -238,6 +238,13 @@ def scrub_failure(text, hosts=()):
return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
def failure_detail(error):
"""What an address that answered but failed SSH shows: the short reason when there is one
(the whole message is the banner's headline already), else the message."""
error = error or {}
return error.get("short") or error.get("message") or "SSH failed"
def failure_category(message, raw=""):
"""The telemetry error category (frame_unreachable, frame_auth, ...) for a failure: a fixed
word, never its text."""
@@ -810,7 +817,9 @@ class Link:
return "Connecting"
def fail(self, sid, message, raw="", **more):
"""A stage failed. `more`: what the page can offer to fix it (hostkey, auth)."""
"""A stage failed. `more`: what the page can offer to fix it (hostkey, auth), and `short`:
the reason in a few words, for the places a whole sentence doesn't fit (the header pill,
the address that failed)."""
self.stage(sid, "failed", message)
with self.cond:
self.state["error"] = dict({"stage": sid, "message": message, "raw": raw}, **more)
@@ -896,7 +905,7 @@ class Link:
self.learn(device, a["host"], net, None)
return True
with self.cond:
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
why_not = failure_detail(self.state["error"])
self.probe_update(i, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -946,7 +955,7 @@ class Link:
self.learn(device, a["host"], net, results[pick]["rtt_ms"])
return True
with self.cond:
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
why_not = failure_detail(self.state["error"])
self.probe_update(pick, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -1269,6 +1278,7 @@ class Link:
"If SteamOS was reinstalled or reset, or this is a new headset, check its new key and trust "
"it on the Devices tab. If not, a different device may be answering here.")
self.fail("identity", message, text,
short="Saved SSH identity missing" if lost else "SSH identity changed",
hostkey={"state": "missing" if lost else "changed", "type": shown[0] if shown else None,
"fingerprint": shown[1] if shown else None, "host": host})
return "next"
@@ -1279,7 +1289,8 @@ class Link:
if re.search(r"Permission denied", text) and not forward:
# Not something a retry fixes: the headset needs this computer's key (Set Up Connection).
self.fail("login", self.explain(text) or "The headset didn't accept this computer's key. Run Set Up "
"Connection: it pairs this computer with the headset again.", text, auth="denied")
"Connection: it pairs this computer with the headset again.", text, auth="denied",
short="This computer's key refused")
return "stop"
if step == "connected":
self.fail("login", "Logged in, but the shared SSH connection didn't start.", text)
+11 -1
View File
@@ -6,6 +6,7 @@ error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
"""
import ctypes as C
import glob
import hashlib
import math
import json
import os
@@ -121,7 +122,16 @@ def os_release():
k, _, v = line.partition("=")
out[k] = v.strip('"')
return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"),
"variant": out.get("VARIANT_ID")}
"variant": out.get("VARIANT_ID"), "headset": headset_id()}
def headset_id():
"""A one-way hash of the headset's /etc/machine-id: the same for this headset on every
computer, and the machine-id itself never leaves the headset. None without one."""
mid = read("/etc/machine-id")
if not mid:
return None
return hashlib.sha256(("frame-control-headset:" + mid).encode()).hexdigest()[:16]
def volume():
+40 -7
View File
@@ -50,6 +50,7 @@ SETTINGS = STATE / 'settings.json'
OUTBOX = STATE / 'outbox.jsonl'
SENT = STATE / 'sent.jsonl'
SENT_KEEP = 200
FRAMES_SEEN_KEEP = 100 # build/headset pairs already reported, enough for several headsets' updates
OUTBOX_MAX = 2000 # events kept while offline; the oldest go first
FLUSH_EVERY = 60
REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds
@@ -266,9 +267,23 @@ CATEGORIES = [
('layer_missing', re.compile(r'OpenXR compatibility layer')),
('apk_repack_failed', re.compile(r'could not prepare the APK for the Frame')),
('tool_missing', re.compile(r"\[WinError 2\]|No such file or directory: '(?:ssh|scp|rsync|adb)")),
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name')),
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name|'
r'not a readable APK|not an APK|APK manifest|in the APK (?:uses a compression|is )')),
('cant_run_on_frame', re.compile(r"can't run on the Frame")),
('steam_shortcut', re.compile(r'(?i)steam did not return a shortcut|shortcut list|no Steam shortcut')),
# The Frame-side scripts that talk to the Steam client's UI (frame/android/steam_shortcuts.py), which
# fail with their own words or a Python traceback, its last lines kept. Before frame_unreachable:
# "Connection refused" and "timed out" here are Steam's debug port on the Frame, not ssh.
('steam_not_running', re.compile(r'SharedJSContext not found|'
r'URLError: <urlopen error \[Errno 111\] Connection refused>|'
r'ConnectionRefusedError: \[Errno 111\]')),
('steam_not_responding', re.compile(r'websocket handshake failed|(?:socket\.timeout|TimeoutError): timed out|'
r'(?:\A|\n)EOFError\Z')),
('steam_js_error', re.compile(r'JS error: ')),
('artwork_failed', re.compile(r'incomplete artwork render|artwork renderer did not produce|'
r'render is \d+ bytes of|could not prepare artwork|all five Steam artwork slots')),
# Copying the APK (or its launcher) to the Frame: rsync or scp ran out of time.
('copy_timeout', re.compile(r'to the Frame timed out\Z')),
('frame_not_set_up', re.compile(r'(?i)Could not resolve hostname|no "?frame"? (?:SSH )?alias')),
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed|'
r'REMOTE HOST IDENTIFICATION HAS CHANGED|host key is known for')),
@@ -280,6 +295,8 @@ CATEGORIES = [
r'Network is unreachable|Host is down|asleep|kex_exchange|banner exchange|'
r'ssh: connect to host |mux_client_|client_loop: ')),
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
# rsync or scp failed for another reason ("connection unexpectedly closed", or no words at all).
('copy_failed', re.compile(r'\Acopying .+ to the Frame failed: ')),
('download_failed', re.compile(r'(?i)HTTP (?:Error )?\d{3}|URLError|download|certificate verify failed')),
('flatpak', re.compile(r'(?i)flatpak|flathub')),
('cancelled', re.compile(r'(?i)cancel')),
@@ -287,8 +304,16 @@ CATEGORIES = [
]
# Python's own errors for a mistake in Frame Control's code, whatever their message says.
BUG_ERRORS = (KeyError, IndexError, TypeError, AttributeError, NameError, AssertionError,
ZeroDivisionError, RecursionError, UnicodeError, json.JSONDecodeError)
def categorize(message):
"""(category, detail): a fixed category name, plus an Android installer code when there is one."""
"""(category, detail): a fixed category name, plus an Android installer code when there is one.
message may be the exception itself: one of BUG_ERRORS is internal_error."""
if isinstance(message, BUG_ERRORS):
return 'internal_error', None
text = str(message or '')
for name, pattern in CATEGORIES:
m = pattern.search(text)
@@ -366,16 +391,24 @@ def app_started():
_save(s)
def frame_seen(build, version):
"""The Frame's SteamOS build, once per build (public build numbers)."""
key = f'{build}/{version}'
def frame_seen(build, version, headset=None, saved=None):
"""The Frame's SteamOS build (public build numbers), once per build for each headset.
headset: the anonymous hash from frame_status.headset_id; saved: how many headsets
this copy of the app has set up."""
headset = headset if isinstance(headset, str) and re.fullmatch(r'[0-9a-f]{16}', headset) else None
key = f'{build}/{version}' + (f'/{headset}' if headset else '')
with _lock:
s = settings()
if not build or key in s['frames_seen']:
return
s['frames_seen'] = (s['frames_seen'] + [key])[-20:]
s['frames_seen'] = (s['frames_seen'] + [key])[-FRAMES_SEEN_KEEP:]
_save(s)
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
p = {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]}
if headset:
p['headset_id'] = headset
if isinstance(saved, int) and saved >= 0:
p['headsets_saved'] = saved
capture('frame_connected', p)
def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props):
+51 -22
View File
@@ -51,6 +51,10 @@
nav a:hover { color: var(--bright); }
nav a.on { color: var(--blue); border-bottom-color: var(--blue); }
.spacer { flex: 1; }
/* The connection pill and the buttons after it, pushed to the right: closer together than the tabs, and the
pill (not the buttons) gives way when the window is narrow, so nothing is pushed past the right edge. */
.hdr-end { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 1 auto; margin-left: auto; }
.hdr-end > :not(.pill, .devsel) { flex: none; }
.chip { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 3px;
background: var(--btn); color: var(--text); font-size: 13px; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
@@ -60,6 +64,7 @@
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1366px) { header { gap: 20px; } nav a { padding: 0 11px; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 820px) { .brand b { display: none; } }
nav svg { display: none; }
@@ -74,7 +79,7 @@
.page { display: flex; flex-direction: column; gap: 26px; }
.page:not(.on) { display: none; }
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
@media (max-width: 1150px) { nav kbd { display: none; } }
@media (max-width: 1366px) { nav kbd { display: none; } } /* the tabs' titles still name the key */
/* ---- offline banner ---- */
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
@@ -87,14 +92,14 @@
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); }
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 150px; flex: 0 1 auto; background: var(--btn); }
.pill .dot { flex: none; }
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; min-width: 90px; }
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
.dlg::backdrop { background: rgba(0,0,0,.55); }
@@ -132,7 +137,7 @@
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 8px; font: inherit; font-size: 13px; }
.addr .t { overflow-wrap: anywhere; }
.addr .s { white-space: normal; }
.addr .s, .dev-problem .s { white-space: normal; }
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
@@ -655,7 +660,7 @@
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
</nav>
<div class="spacer"></div>
<div class="hdr-end">
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
<span class="chip" id="battChip" title="Battery">—</span>
@@ -665,6 +670,7 @@
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
</button>
</div>
</header>
<main>
@@ -677,7 +683,8 @@
</div>
<div class="notice" id="privacyNotice" hidden>
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
your operating system, which tabs you use, and whether installs on the Frame worked. Never file names, paths,
your operating system, which tabs you use, whether installs on the Frame worked, and how many headsets you use
(a one-way hash of each headset's machine ID, the same on every computer, so they're counted once). Never file names, paths,
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
worked and the details of errors, which helps fix problems faster.</div>
<button class="small" id="noticeSettings">Privacy settings</button>
@@ -1142,7 +1149,8 @@
<div class="shelf-head"><h2>Privacy &amp; updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
worked (with an error category, never the message). F-Droid package names only.</span></label>
worked (with an error category, never the message). F-Droid package names only. Each headset's SteamOS
build, a one-way hash of its machine ID (to count headsets, not just installs), and how many you've set up.</span></label>
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
compatibility database, so the verdicts get better for everyone.</span></label>
@@ -1587,7 +1595,7 @@ $("vrUtilities").onclick = async e => {
await loadUtilities();
};
// The Frame owns the clock. Poll independently of status and the selected tab.
// The Frame owns the clock. Poll independently of the selected tab, from the first status check on.
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
async function localNotification(message, request = false) {
try {
@@ -1646,7 +1654,12 @@ function renderComfort(s) {
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
}
async function pollComfort() {
if (!comfortBusy) {
// Not while the Frame can't be reached: each poll would be another ssh attempt waiting to
// time out, and the banner already says why. Status checks bring it back (setOnline).
if (online === false) {
const message = "Session status appears when the Frame is connected.";
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
} else if (!comfortBusy) {
comfortBusy = true;
try { renderComfort(await api("/api/comfort", {action: "status"})); }
catch (e) {
@@ -1680,7 +1693,6 @@ $("castBtn").onclick = () => {
// Fullscreen is a direct user gesture; iPhone falls back to its inline viewer.
$("viewer").requestFullscreen?.().catch(() => {});
};
pollComfort();
// ---- battery ----
function battery(b, power) {
@@ -4495,7 +4507,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
}
setView("headset");
refresh().then(() => loadShots()); // after status, so app names resolve
refresh().then(() => { loadShots(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script>
@@ -4524,6 +4536,20 @@ function netLabel(s) {
}
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
// Why the last attempt failed, in a few words: the server's short reason, else the stage that failed.
function failureReason(s) {
const err = s.error || {};
if (err.short) return err.short;
const st = (s.stages || []).find(x => x.id === err.stage);
return st ? `${st.label} failed` : "Not connected";
}
// A sentence or a label as a clause inside another sentence: no closing full stop (the sentence
// adds its own) and a lower-case first letter, unless the first word is an abbreviation ("SSH").
function asClause(text) {
const t = String(text || "").trim().replace(/[.\s]+$/, "");
return /^[A-Z][a-z]/.test(t) ? t[0].toLowerCase() + t.slice(1) : t;
}
// The pill in the header: always there, and says what's happening right now.
function renderPill() {
const s = link.s;
@@ -4535,9 +4561,8 @@ function renderPill() {
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
} else if (s.phase === "failed") {
dot = "off";
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
const st = (s.stages || []).find(x => x.id === (s.error || {}).stage);
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "");
// The reason first, in a few words, so a narrow pill cuts the countdown rather than the reason.
line = failureReason(s) + (s.retry_at ? ` · retry in ${Math.max(0, Math.ceil(s.retry_at - serverNow()))} s` : "");
} else {
const st = activeStage(s);
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
@@ -4545,7 +4570,7 @@ function renderPill() {
: "Connecting…";
}
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
$("conn").title = `${dev}: ${line}. Click for details.`;
$("conn").title = `${dev}${s.phase === "failed" ? " is offline" : ""}: ${line}. Click for details.`;
return true;
}
window.connPill = renderPill;
@@ -4562,13 +4587,13 @@ function renderBanner() {
if (s.phase === "failed" && s.error) {
$("offline").hidden = false;
$("offMsg").textContent = s.error.message;
const tried = (s.probes || []).map(p => `${p.host}: ${p.detail.toLowerCase()}`).join("; ");
const tried = (s.probes || []).map(p => `${p.host}: ${asClause(p.detail)}`).join("; ");
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
$("offDetail").textContent = `${s.device ? s.device.name : "Frame"} on ${netLabel(s)}. ${tried ? "Tried " + tried + ". " : ""}` +
(left != null ? `Trying again in ${left} s.` : "");
} else if (s.phase === "connecting" && online === false) {
const st = activeStage(s);
$("offDetail").textContent = `Trying again: ${st ? st.label.toLowerCase() + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
$("offDetail").textContent = `Trying again: ${st ? asClause(st.label) + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
}
}
window.connBanner = renderBanner;
@@ -4746,7 +4771,10 @@ async function useDevice(id) {
if (!res) { // refused (an install is running): show the headset still in use
const current = dv.list.find(x => x.active);
if (current) $("devSel").value = current.id;
return;
}
// The Devices tab shows the headset now in use, not the one it was showing before.
if (dv.sel !== id) { dv.sel = id; dv.found = null; dv.editing = null; renderDevices(); }
}
async function devAction(label, body, btn) {
const res = await act(label, () => api("/api/devices", body), btn);
@@ -4758,7 +4786,8 @@ function deviceStatus(d) {
const s = link.s;
if (d.active && s) {
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`]
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
: s.phase === "failed" ? ["off", s.error ? s.error.short || s.error.message : "Offline", s.error && s.error.message]
: ["", "Connecting…"];
}
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
return ["", d.transient ? "Not set up in Frame Control" : last ? `Last connected ${ago(last)}` : "Not connected yet"];
@@ -4768,10 +4797,10 @@ function renderDevices() {
if (!dv.data) return;
$("devCount").textContent = dv.list.length;
$("devList").innerHTML = dv.list.map(d => {
const [dot, text] = deviceStatus(d);
const [dot, text, full] = deviceStatus(d);
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
<div class="s"${full ? ` title="${esc(full)}"` : ""}>ssh ${esc(d.alias)} · ${esc(text)}</div></div>
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
@@ -4822,10 +4851,10 @@ function renderDetail() {
<button class="small" data-mv="1" title="Try later"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
const err = d.active && link.s && link.s.phase === "failed" ? link.s.error : null;
const problem = err && err.hostkey ? `<div class="item" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
const problem = err && err.hostkey ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
? "Its saved SSH identity is missing" : "Its SSH identity has changed"}</div>
<div class="s">${esc(err.message)}</div></div><button class="small action" id="dvTrustNow">Check the new key…</button></div>`
: err && err.auth === "denied" ? `<div class="item" role="alert"><div class="grow"><div class="t">The headset didn't accept this computer's key</div>
: err && err.auth === "denied" ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">The headset didn't accept this computer's key</div>
<div class="s">Set Up Connection pairs this computer with it again. On the headset, Developer Mode must be on.</div></div>
<button class="small action" id="dvPair">Set Up Connection…</button></div>` : "";
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
+43 -20
View File
@@ -403,11 +403,38 @@ def status(_body):
s = json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20))
osr = s.get("os") if isinstance(s, dict) else None
if isinstance(osr, dict):
frame_telemetry.frame_seen(osr.get("build"), osr.get("version"))
# The headset's hash is for usage statistics only; the page and MCP clients don't get it.
frame_telemetry.frame_seen(osr.get("build"), osr.get("version"), osr.pop("headset", None),
len(LINK.reg.devices()) if LINK else None)
frame_report.frame.update(build=osr.get("build"), version=osr.get("version"))
return s
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
# `files` that aren't there (none: just check they all are). Private, and each file written
# whole or not at all, so a copy cut short is finished by the next call.
COMFORT_RUN = """import json, os, pathlib, subprocess, sys
os.umask(0o077)
files = %r
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
if files:
root.mkdir(parents=True, exist_ok=True)
for name, source in files.items():
path = root / name
if not path.exists():
tmp = root / (name + '.' + str(os.getpid()))
tmp.write_text(source)
tmp.replace(path)
if not all((root / name).is_file() for name in %r):
print('{"comfort": "missing"}')
sys.exit()
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
print(r.stdout, end='')
"""
def comfort(body):
try:
frame_comfort.validate(body)
@@ -415,25 +442,14 @@ def comfort(body):
raise Failure(str(e), 400)
# Content-addressed, user-only helper bundle. Desktop and phone use the same
# on-headset state/lock; no listener, service registration or third-party app.
import hashlib
files = {name: (HERE / name).read_text() for name in
("frame_comfort.py", "frame_status.py", "frame_steam.py")}
# The page polls this every few seconds, so run the copy already on the headset and
# send the sources only when that version isn't there yet.
files = {name: (HERE / name).read_text() for name in COMFORT_FILES}
version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16]
script = """import json, os, pathlib, subprocess, sys
os.umask(0o077)
files = %r
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
root.mkdir(parents=True, exist_ok=True)
for name, source in files.items():
path = root / name
if not path.exists():
tmp = root / (name + '.' + str(os.getpid()))
tmp.write_text(source)
tmp.replace(path)
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
print(r.stdout, end='')
""" % (files, version, json.dumps(body))
out = json.loads(ssh("python3 -", stdin=script, timeout=65))
out = json.loads(ssh("python3 -", stdin=COMFORT_RUN % ({}, version, COMFORT_FILES, json.dumps(body)), timeout=65))
if out == COMFORT_MISSING:
out = json.loads(ssh("python3 -", stdin=COMFORT_RUN % (files, version, COMFORT_FILES, json.dumps(body)),
timeout=65))
if out.get("error") and "active" not in out:
raise Failure(out["error"], 409)
return out
@@ -2280,6 +2296,10 @@ def connection_state():
# ---- HTTP ------------------------------------------------------------------
# POST reads the page repeats on a timer, as (path, action).
POLLS = {("/api/comfort", "status")}
def action_of(body):
"""The action a request asked for, for diagnostics: a short word, never user data."""
a = body.get("action") if isinstance(body, dict) else None
@@ -2533,7 +2553,10 @@ class Handler(BaseHTTPRequestHandler):
except ClientGone:
raise
except Failure as e:
if e.status >= 500:
# A poll that couldn't reach the headset is the headset asleep or away, which the page
# already shows; like GET /api/status, not an error to report.
polled = (path, action_of(body)) in POLLS and frame_telemetry.link_failed(e)
if e.status >= 500 and not polled:
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
self.send_error_json(str(e), e.status, e.apk)
except (ValueError, TypeError) as e: