mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-13 14:01:27 +02:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec2ff40c3 | ||
|
|
39e819872a | ||
|
|
ee7951e8d0 | ||
|
|
d456aa1f3d | ||
|
|
6c7b2ca22f | ||
|
|
8a06848b5f | ||
|
|
9cca67529b | ||
|
|
b656f80922 | ||
|
|
7cc418662d | ||
|
|
b2bbff4305 | ||
|
|
2cfcdaaf0e | ||
|
|
2960581541 | ||
|
|
8b1c290730 | ||
|
|
b64082c974 | ||
|
|
7c1d698400 | ||
|
|
b6406a90a4 | ||
|
|
9b26c907ce | ||
|
|
5f3798b79b | ||
|
|
b89538f506 |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
+16
-7
@@ -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
|
||||
|
||||
@@ -256,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)
|
||||
@@ -59,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)
|
||||
@@ -423,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'), \
|
||||
@@ -486,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:
|
||||
|
||||
@@ -93,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()
|
||||
|
||||
+17
-1
@@ -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)
|
||||
|
||||
@@ -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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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'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()
|
||||
+134
-6
@@ -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,
|
||||
|
||||
+28
-5
@@ -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
@@ -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:
|
||||
|
||||
+15
-4
@@ -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
@@ -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
@@ -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
@@ -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 & 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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user