diff --git a/app/package-lock.json b/app/package-lock.json index dbd09df..5391a62 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index 25d5f41..c5d486d 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/docs/privacy.md b/docs/privacy.md index 3412850..8bf434d 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -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 diff --git a/tests/test_comfort.py b/tests/test_comfort.py index 43a456f..d2e0e93 100644 --- a/tests/test_comfort.py +++ b/tests/test_comfort.py @@ -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) diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py index ef008d6..1866540 100644 --- a/tests/test_comfort_ui.py +++ b/tests/test_comfort_ui.py @@ -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) diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index 08cd4eb..014696b 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -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: diff --git a/tests/test_frame_apk.py b/tests/test_frame_apk.py index 850d118..01d29c2 100644 --- a/tests/test_frame_apk.py +++ b/tests/test_frame_apk.py @@ -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(' { + 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, /Office Frame<\/b>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, /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'