mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-15 00:01:17 +02:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b9bd2119c | ||
|
|
a8888cb2fc | ||
|
|
1f51cc89d2 | ||
|
|
bcdb4c3b78 | ||
|
|
e9b02d61e7 | ||
|
|
02419ffbd4 | ||
|
|
5db9631571 | ||
|
|
141b95ddde | ||
|
|
4ec2ff40c3 | ||
|
|
39e819872a | ||
|
|
ee7951e8d0 | ||
|
|
d456aa1f3d | ||
|
|
6c7b2ca22f | ||
|
|
8a06848b5f | ||
|
|
9cca67529b | ||
|
|
b656f80922 | ||
|
|
7cc418662d | ||
|
|
b2bbff4305 | ||
|
|
2cfcdaaf0e | ||
|
|
2960581541 | ||
|
|
8b1c290730 | ||
|
|
b64082c974 | ||
|
|
7c1d698400 | ||
|
|
b6406a90a4 | ||
|
|
9b26c907ce | ||
|
|
5f3798b79b | ||
|
|
b89538f506 |
No files matched your search
+3
-2
@@ -115,10 +115,11 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
|
||||
// which sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.6",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.6",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -50,6 +50,7 @@
|
||||
"*.py",
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
|
||||
+59
-3
@@ -17,11 +17,12 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
The window has six tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
Flatpaks, remote and power), **Devices** (your headsets and their addresses),
|
||||
and **Control** (the searchable Control Center, metrics, supported settings and
|
||||
headset guidance). Keys 1–6 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
@@ -96,6 +97,61 @@ counts them while they run.
|
||||
shut down open a terminal window because SteamOS asks for the sudo password
|
||||
over SSH.
|
||||
|
||||
## Control Center
|
||||
|
||||
Open **Control** or `#control` to search controls, status and help. Categories
|
||||
cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
|
||||
preferences. Search is local: it matches labels, descriptions and familiar terms
|
||||
such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
|
||||
Steam, app catalogues, analytics or models. Choosing a workflow link opens its
|
||||
existing screen; it does not install, cast, open a helper or change a setting.
|
||||
Home, Games, Android, Tools and Devices retain their existing features.
|
||||
|
||||
The overview and detail cards use the shared `/api/status` sample: charge and
|
||||
power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
|
||||
and VR timings. Values refresh with the normal 30-second poll. A failed status
|
||||
request, lost connection, headset switch or sample received more than 90 seconds
|
||||
ago clears current readings and disables audio writes. The client receipt time
|
||||
and failed-read state are retained through page startup and UI redraws. Only a
|
||||
new successful status read clears a failure; reusing the retained sample does not.
|
||||
The headset timestamp is shown without assuming
|
||||
its clock matches this computer. Missing sensors show **Unavailable**;
|
||||
they are not converted to zero. Compositor FPS is separate from application FPS;
|
||||
GPU time is render time, not GPU utilisation. The temperature is the hottest
|
||||
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||
not a guessed universal temperature threshold.
|
||||
|
||||
**Audio** controls the default output sink through the existing `/api/volume`
|
||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||
blocks duplicate/pending actions, and requests status after command acknowledgment
|
||||
for readback, draining an earlier poll first. Mute preserves an unapplied volume
|
||||
draft; switching headsets discards it. It reports
|
||||
unconfirmed or differing output separately from confirmed state. API validation
|
||||
rejects invalid types, out-of-range levels and invalid mute states before SSH.
|
||||
|
||||
Other settings link to the existing forms and their validation, progress,
|
||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
||||
sources and artwork. Android display links reveal **On your Frame** before
|
||||
scrolling to the form. Mac streaming remains searchable on all platforms, with
|
||||
its action shown only when the server is on macOS.
|
||||
|
||||
Hardware guidance is deliberately separate from controls. Microphone/routing,
|
||||
brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
|
||||
tracking-origin changes, saved spatial layouts, firmware updates and recovery
|
||||
have no verified remote setting here. Use the headset's own controls or the
|
||||
documented manual procedures. Movement comfort remains per-game; automatic
|
||||
sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
|
||||
Android data backup/restore remain script/MCP capabilities, not new UI switches.
|
||||
|
||||
**Verification:** the Control Center's JavaScript runs under Node with mocked
|
||||
API and DOM boundaries; the server tests mock SSH and reject invalid values
|
||||
before any device command. Packaging tests cover the shared CSS/JS in desktop
|
||||
and iPhone bundles. These checks establish wiring, search, navigation, offline,
|
||||
error and device-switch behavior; they do not establish wearer experience,
|
||||
physical settings persistence or a new hardware capability.
|
||||
|
||||
## How it works
|
||||
|
||||
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 483 KiB |
+26
-10
@@ -33,15 +33,28 @@ computer, exactly as they were sent.
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
|
||||
two-letter code such as `DE`, worked out on your computer from its time zone
|
||||
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
|
||||
Control is used without knowing your address. The time zone itself isn't
|
||||
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
|
||||
Copies that showed the first-run notice before it mentioned this (before
|
||||
0.4.6) show it again, and don't send a country until it has been seen.
|
||||
- `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
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `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` |
|
||||
| `app_installed` | First run | `country` |
|
||||
| `app_updated` | First run of a new version | `from_version`, `country` |
|
||||
| `app_opened` | At most once a day | `country` |
|
||||
| `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 +64,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
|
||||
|
||||
@@ -15,6 +15,29 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
The Control Center is covered by `tests/test_control_center.py` and its Node
|
||||
behavior suite `tests/control_center.cjs` (also run by unittest discovery).
|
||||
They exercise production search, metrics, navigation and action handlers with
|
||||
mocked API/DOM boundaries: volume Apply/mute/readback, offline and failed reads,
|
||||
stale samples, selected-headset capture, duplicate actions and validation before
|
||||
SSH. Integration regressions run the production shared refresh, Home renderer,
|
||||
host callback and Control Center bootstrap: a pre-command status request must be
|
||||
drained before audio readback starts, and delayed startup retains sample receipt
|
||||
age, failed-read state and early host capabilities. The actual Flatpak install
|
||||
redraw cannot revive a failed sample; a new successful poll restores metrics and
|
||||
audio after either redraw or delayed mount. Draft regressions cover a pending headset
|
||||
switch and mute followed by Apply. Static asset HTTP tests and desktop/iPhone bundle checks catch missing CSS
|
||||
or JavaScript. A focused run is:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_control_center.py -v
|
||||
node --test tests/control_center.cjs
|
||||
node --check ui/control-center.js
|
||||
```
|
||||
|
||||
Responsive layout, actual browser rendering and keyboard/touch behavior still
|
||||
need browser verification. No Control Center test connects to a real headset.
|
||||
|
||||
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
|
||||
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
|
||||
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
|
||||
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
|
||||
|
||||
From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
|
||||
names that link to them), as installed on macOS and most Linux systems. It prints the
|
||||
old names left out because their zone is shared by several countries: check any new
|
||||
one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
|
||||
|
||||
Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
|
||||
country = {}
|
||||
for line in (src / 'zone.tab').read_text().splitlines():
|
||||
if line and not line.startswith('#'):
|
||||
cc, _, zone = line.split('\t')[:3]
|
||||
country[zone] = cc
|
||||
links = {}
|
||||
for line in (src / 'tzdata.zi').read_text().splitlines():
|
||||
if line.startswith('L '):
|
||||
_, target, alias = line.split()
|
||||
links[alias] = target
|
||||
def resolve(z):
|
||||
for _ in range(5):
|
||||
z = links.get(z, z)
|
||||
return z
|
||||
|
||||
|
||||
# An old name links to a zone with the same clocks, which can be in another country
|
||||
# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
|
||||
# zone.tab zone sharing that target is in one country.
|
||||
countries_of = {}
|
||||
for zone, cc in country.items():
|
||||
countries_of.setdefault(resolve(zone), set()).add(cc)
|
||||
# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
|
||||
GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
|
||||
# Places that have left zone.tab, so their target's group looks like another country's.
|
||||
PLACES = {'Pacific/Johnston': 'UM'}
|
||||
dropped = []
|
||||
for alias in links:
|
||||
if alias in country or alias in GENERIC or alias.startswith('Etc/'):
|
||||
continue
|
||||
if alias in PLACES:
|
||||
country[alias] = PLACES[alias]
|
||||
continue
|
||||
ccs = countries_of.get(resolve(alias), set())
|
||||
if len(ccs) == 1:
|
||||
country[alias] = next(iter(ccs))
|
||||
else:
|
||||
dropped.append(alias)
|
||||
# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
|
||||
OLD_SPELLINGS = {
|
||||
'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
|
||||
'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
|
||||
'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
|
||||
'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
|
||||
'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
|
||||
'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
|
||||
'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
|
||||
'Pacific/Yap': 'Pacific/Chuuk'}
|
||||
for alias, zone in OLD_SPELLINGS.items():
|
||||
if alias in links and zone in country:
|
||||
country[alias] = country[zone]
|
||||
dropped.remove(alias)
|
||||
version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
|
||||
rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
|
||||
out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
|
||||
out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {{
|
||||
{rows}}}
|
||||
''')
|
||||
print(f'{len(country)} zones, tz {version} -> {out}')
|
||||
print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
|
||||
@@ -0,0 +1,471 @@
|
||||
// Real Control Center logic with a minimal DOM; no network, browser or headset.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const cc = require('../ui/control-center.js');
|
||||
|
||||
const sample = () => ({time:Date.now()/1000, hostname:'Test Frame', battery:{percent:99,status:'Discharging',watts:0,tempC:33,health:'Good'},
|
||||
power:{type:'C PD [PD_PPS]',watts:18}, disk:{home:{total:256*1073741824,free:128*1073741824}},
|
||||
memory:{total:16*1073741824,available:6*1073741824}, wifi:{ssid:'Test LAN',signal:80},
|
||||
performance:{compositorFps:90,appFps:45,frameMs:11.11,gpuMs:3,cpuPercent:12,gpuMHz:600},
|
||||
volume:{level:.4,muted:false}, os:{version:'0.4.1',build:'test-build'},services:{steamvr:true}, thermalAlerts:[],games:[],flatpaks:[]});
|
||||
function fixture() {
|
||||
const elements = new Map();
|
||||
function element(id) {
|
||||
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
|
||||
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
|
||||
return elements.get(id);
|
||||
}
|
||||
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
|
||||
const buttons = cc.categories.map(([id])=>{const e=element('category-'+id);e.dataset.ccCategory=id;e.querySelector=()=>element('count-'+id);return e;});
|
||||
const metrics = Object.keys(cc.metrics(null)).map(id=>{const e=element('metric-'+id);e.dataset.ccMetric=id;return e;});
|
||||
const page = element('controlCenter');
|
||||
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
|
||||
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
|
||||
return {document:{getElementById:element},element,cards,buttons};
|
||||
}
|
||||
function mounted(options = {}) {
|
||||
const f = fixture(), calls = [];
|
||||
let context = {online:true,generation:1,device:'frame-a'}, refreshes = 0, opened = 0;
|
||||
const ui = cc.mount({document:f.document,platform:'darwin',api:async(...args)=>{calls.push(args);return {message:'Volume updated'};},
|
||||
refresh:async()=>{refreshes++;},getContext:()=>context,openConnection:()=>opened++,...options});
|
||||
return {...f,ui,calls,get context(){return context;},set context(v){context=v;},get refreshes(){return refreshes;},get opened(){return opened;}};
|
||||
}
|
||||
const pageSource = fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||
const deferred = () => { let resolve, reject; const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject}; };
|
||||
const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
|
||||
// Execute the actual shared refresh/deduplication, host callback and mount
|
||||
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
|
||||
// rendering helpers, timers and transport are stubbed.
|
||||
function sharedPage({productionApi=false} = {}) {
|
||||
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
|
||||
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
|
||||
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
|
||||
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||||
runJob(title,id){context.installing.add(id);return job.promise;},
|
||||
api:(path,body,device)=>{
|
||||
if(path==='/api/host')return host.promise;
|
||||
if(path==='/api/status'){const request=deferred();requests.push(request);return request.promise;}
|
||||
writes.push({path,body,device});return Promise.resolve({message:'Volume updated'});
|
||||
},renderPerformance(){},setConn(){},setOnline(ok){vm.runInContext(`online = ${ok}`,context);},log(){},failed(){},
|
||||
watchConnection(){context.watched=true;}};
|
||||
vm.createContext(context);
|
||||
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
|
||||
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
|
||||
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
|
||||
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
|
||||
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
|
||||
if(productionApi) {
|
||||
// Keep api/onConnection/setOnline intact: defer fetch itself, not api(), so
|
||||
// rejection classification and the real switch/reset notifications run.
|
||||
context.fetch=(path,opts)=>{
|
||||
if(path==='/api/host')return host.promise.then(data=>({ok:true,status:200,json:async()=>data}));
|
||||
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||
};
|
||||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
vm.runInContext(`const UI_KEY = 'test-only';
|
||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
|
||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
|
||||
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
|
||||
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
|
||||
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
|
||||
vm.runInContext(pageSource.slice(apiStart,apiEnd)+pageSource.slice(onlineStart,onlineEnd)+pageSource.slice(connectionStart,connectionEnd),context);
|
||||
}
|
||||
const installStart=pageSource.indexOf('function installFlatpak(id) {'),installEnd=pageSource.indexOf('$("fpInstall").onclick',installStart);
|
||||
vm.runInContext(pageSource.slice(installStart,installEnd),context);
|
||||
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
|
||||
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
|
||||
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
|
||||
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
|
||||
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
|
||||
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
|
||||
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
|
||||
}
|
||||
const response = data => ({ok:true,status:200,json:async()=>data});
|
||||
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
|
||||
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
|
||||
const f=sharedPage({productionApi:true});
|
||||
if(!delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||
const old=f.refresh(),body=deferred();
|
||||
assert.equal(f.requests[0].path,'/api/status');
|
||||
if(rejection==='body') {
|
||||
f.requests[0].resolve({ok:true,status:200,json:()=>body.promise});await flush();
|
||||
}
|
||||
f.switchHeadset();
|
||||
assert.equal(f.requests.length,2,'real connected B callback starts its own status fetch');
|
||||
const next=sample();next.hostname='Second Frame';next.battery.percent=20;next.volume.level=.2;
|
||||
f.requests[1].resolve(response(next));await flush();
|
||||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||
assert.equal(vm.runInContext('stateRead.valid && online && devGen === 2',f.context),true);
|
||||
if(!delayedMount)assert.equal(f.element('metric-battery').textContent,'20%');
|
||||
(rejection==='body' ? body : f.requests[0]).reject(Error('Old A request failed'));await old;
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),true,'A must not invalidate B');
|
||||
assert.equal(vm.runInContext('stateRead.error',f.context),null);
|
||||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||
if(delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||
const assertB=()=>{
|
||||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||
assert.equal(f.element('metric-battery').textContent,'20%');
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/No live sample|local server|Old A/);
|
||||
};
|
||||
assertB();vm.runInContext('render(state)',f.context);assertB();
|
||||
assert.equal(f.requests.length,2,'preservation and redraw need no recovery poll');
|
||||
// An ordinary failure on B must still invalidate the current sample.
|
||||
const failed=f.refresh();
|
||||
if(rejection==='body')f.requests[2].resolve({ok:true,status:200,json:async()=>{throw Error('B body failed');}});
|
||||
else f.requests[2].reject(Error('B transport failed'));
|
||||
await failed;
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),false);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
});
|
||||
}
|
||||
test('production headset-free API keeps transport errors and responses across a switch', async t => {
|
||||
for(const failure of [false,true]) {
|
||||
const f=sharedPage({productionApi:true}),ui=f.mount();t.after(()=>ui.destroy());
|
||||
const pending=vm.runInContext('api("/api/devices")',f.context);
|
||||
const result=pending.then(value=>({value}),error=>({error}));
|
||||
f.switchHeadset();f.requests[1].resolve(response(sample()));await flush();
|
||||
if(failure)f.requests[0].reject(Error('Device list transport failed'));
|
||||
else f.requests[0].resolve(response({devices:['frame-b']}));
|
||||
const settled=await result;
|
||||
if(failure){assert.equal(settled.error.stale,undefined);assert.match(settled.error.message,/local server isn't running/);}
|
||||
else assert.deepEqual(settled.value,{devices:['frame-b']});
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),true);
|
||||
}
|
||||
});
|
||||
test('search finds settings by aliases, all words, category and guidance', () => {
|
||||
const find = (q,category='all')=>cc.catalog.filter(item=>cc.matches(item,q,category)).map(item=>item.id);
|
||||
assert.ok(find('DPI').includes('display'));
|
||||
assert.ok(find('sleep inactivity').includes('power'));
|
||||
assert.ok(find('MOTION SICKNESS').includes('playspace'));
|
||||
assert.ok(find('font','apps').includes('display'));
|
||||
assert.deepEqual(find('font','vr'),[]);
|
||||
assert.deepEqual(find('<script>alert(1)</script>'),[]);
|
||||
assert.ok(find(' ').length > 20);
|
||||
});
|
||||
test('metrics use real fields, preserve zero, and distinguish unavailable values', () => {
|
||||
const m = cc.metrics(sample());
|
||||
assert.equal(m.battery,'99%');
|
||||
assert.equal(m.power,'0 W');
|
||||
assert.match(m.batteryDetail,/not charging/);
|
||||
assert.equal(m.fps,'90'); assert.equal(m.appFps,'45');
|
||||
assert.equal(m.storage,'128 GiB'); assert.equal(m.memory,'10 GiB');
|
||||
assert.equal(m.thermal,'No hot / critical trip reported');
|
||||
assert.equal(cc.metrics(null).fps,'Unavailable');
|
||||
assert.equal(cc.metrics({performance:{appFps:null,gpuMHz:0,cpuPercent:0}}).appFps,'Unavailable');
|
||||
assert.equal(cc.metrics({performance:{cpuPercent:0}}).cpu,'0%');
|
||||
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
|
||||
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
|
||||
});
|
||||
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
|
||||
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
|
||||
for (const change of [{level:-.1},{level:1.1},{level:NaN},{level:Infinity},{level:'0.5'},{level:null},{muted:'false'},{unknown:true},{}]) {
|
||||
await assert.rejects(controller.audio(change),/valid mute state/);
|
||||
}
|
||||
context.online=false;await assert.rejects(controller.audio({level:.5}),/Connect and refresh/);
|
||||
context.online=true;context.fresh=false;await assert.rejects(controller.audio({muted:true}),/Connect and refresh/);
|
||||
assert.equal(calls.length,0);
|
||||
});
|
||||
test('audio uses the captured device, prevents duplicate writes, and ignores switched-headset success', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},complete,calls=[];
|
||||
const controller=cc.createController({api:(...args)=>{calls.push(args);return new Promise(r=>complete=r);},getContext:()=>context});
|
||||
const pending=controller.audio({level:.7});
|
||||
await assert.rejects(controller.audio({muted:true}),/still in progress/);
|
||||
assert.deepEqual(calls,[['/api/volume',{level:.7},'frame-a']]);
|
||||
context={...context,device:'frame-b',generation:2};complete({message:'Done'});
|
||||
assert.equal(await pending,null);assert.equal(controller.busy,false);
|
||||
});
|
||||
test('controller surfaces real API errors and allows a deliberate retry', async () => {
|
||||
let calls=0;
|
||||
const controller=cc.createController({api:async()=>{if(++calls===1)throw Error('SSH connection lost');return {message:'Done'};},
|
||||
getContext:()=>({online:true,fresh:true,sample:sample(),device:'frame-a',generation:1})});
|
||||
await assert.rejects(controller.audio({muted:true}),/SSH connection lost/);
|
||||
assert.equal(controller.busy,false);
|
||||
assert.deepEqual(await controller.audio({muted:true}),{message:'Done'});
|
||||
});
|
||||
test('mounted controls only write on Apply or Mute, show readback, and reset on switch', async t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.ui.status(sample());
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('ccVolume').value,40);
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
assert.equal(f.calls.length,0);
|
||||
assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||
f.ui.status(sample()); // a poll must not overwrite an unapplied value
|
||||
assert.equal(f.element('ccVolume').value,'65');
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.deepEqual(f.calls,[['/api/volume',{level:.65},'frame-a']]);
|
||||
assert.equal(f.refreshes,1);
|
||||
await f.element('ccMute').onclick();
|
||||
assert.deepEqual(f.calls[1],['/api/volume',{muted:true},'frame-a']);
|
||||
f.context={online:null,generation:2,device:'frame-b'};f.ui.reset();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
});
|
||||
test('offline/errors clear live readings, disable audio and keep companion navigation usable', async t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
f.context.online=false;f.ui.connection(false);
|
||||
assert.equal(f.element('metric-fps').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccVolume').disabled,true);
|
||||
await f.element('ccMute').onclick();
|
||||
assert.equal(f.calls.length,0);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Connect and refresh/);
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='privacy').hidden,false);
|
||||
f.ui.unavailable(Error('Local server is unavailable'));
|
||||
assert.match(f.element('ccSampleState').textContent,/Local server is unavailable/);
|
||||
f.context.online=true;f.ui.status(sample());
|
||||
assert.equal(f.element('ccVolume').disabled,false);
|
||||
});
|
||||
test('mounted audio failure is visible and never reports success', async t => {
|
||||
const f=mounted({api:async()=>{throw Error('Permission denied');}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/Couldn’t apply audio: Permission denied/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.refreshes,0);
|
||||
});
|
||||
test('audio confirmation depends on fresh readback rather than command acknowledgment', async t => {
|
||||
let expected=.65, mode='confirmed';
|
||||
const f=mounted({refresh:async()=>{
|
||||
if(mode==='offline')return f.ui.unavailable(Error('Headset unreachable'));
|
||||
const reading=sample();reading.volume.level=mode==='different' ? .64 : expected;f.ui.status(reading);
|
||||
}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||
mode='different';f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/reported output differs: 64%/);
|
||||
mode='offline';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/command sent; current output could not be confirmed/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
});
|
||||
test('pending audio never overwrites the new headset with old success or error', async t => {
|
||||
let complete;
|
||||
const f=mounted({api:()=>new Promise(resolve=>{complete=resolve;})});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
const pending=f.element('ccMute').onclick();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||
const newSample=sample();newSample.hostname='Second Frame';newSample.volume.muted=true;f.ui.status(newSample);
|
||||
complete({message:'Old headset muted'});await pending;
|
||||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
assert.equal(f.refreshes,0);
|
||||
assert.equal(f.element('ccMute').textContent,'Unmute');
|
||||
f.ui.unavailable({stale:true,message:'Switched headsets'});
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
assert.equal(f.element('ccMute').disabled,false);
|
||||
});
|
||||
test('pending old-headset volume cannot become a draft on the new headset', async t => {
|
||||
for(const failure of [false,true]) {
|
||||
const request=deferred(),f=mounted({api:()=>request.promise});t.after(()=>f.ui.destroy());
|
||||
f.ui.status(sample());f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const pending=f.element('ccApplyVolume').onclick();
|
||||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||
const next=sample();next.volume.level=.2;f.ui.status(next);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
if(failure)request.reject(Error('Old headset failed'));else request.resolve({message:'Done'});
|
||||
await pending;
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
assert.equal(f.element('ccVolumeValue').textContent,'20%');
|
||||
assert.equal(f.element('ccAudioReadback').textContent,'Reported 20% · unmuted');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
}
|
||||
});
|
||||
test('production host response works before and after mounting with the actual bridge shape', async t => {
|
||||
for(const early of [true,false]) {
|
||||
const f=sharedPage();let ui;
|
||||
if(!early){ui=f.mount();t.after(()=>ui.destroy());assert.equal(f.element('macLink').hidden,true);}
|
||||
f.host.resolve({os:'macOS',mobile:false});await flush();
|
||||
if(early){ui=f.mount();t.after(()=>ui.destroy());}
|
||||
assert.equal(f.context.watched,true);
|
||||
assert.equal(f.element('macLink').hidden,false);
|
||||
f.element('ccSearch').value='mac';f.element('ccSearch').input();
|
||||
assert.equal(f.element('macLink').hidden,false);
|
||||
ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||
}
|
||||
});
|
||||
test('production shared refresh drains a pre-command request then samples after acknowledgment', async t => {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||
const prior=f.refresh();assert.equal(f.requests.length,1);
|
||||
assert.equal(f.refresh(),prior); // the ordinary poll/manual deduplication still holds
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||
assert.equal(f.writes.length,1);assert.equal(f.requests.length,1);
|
||||
f.requests[0].resolve(sample());await prior;await flush();
|
||||
assert.equal(f.requests.length,2,'readback must issue a real post-command status request');
|
||||
assert.doesNotMatch(f.element('ccAudioResult').textContent,/Confirmed|reported output differs/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
const after=sample();after.volume.level=.65;f.requests[1].resolve(after);await command;
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
});
|
||||
test('production audio readback survives an old poll failure and treats its own failed read as unconfirmed', async t => {
|
||||
for(const failedReadback of [false,true]) {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||
const prior=f.refresh();f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||
f.requests[0].reject(Error('Pre-command poll failed'));await prior;await flush();
|
||||
assert.equal(f.requests.length,2);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
if(failedReadback)f.requests[1].reject(Error('Readback unavailable'));
|
||||
else {const after=sample();after.volume.level=.64;f.requests[1].resolve(after);}
|
||||
await command;
|
||||
assert.match(f.element('ccAudioResult').textContent,failedReadback ? /command sent; current output could not be confirmed/ : /reported output differs: 64%/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,failedReadback);
|
||||
}
|
||||
});
|
||||
test('production Flatpak redraw cannot revive a failed status; only a fresh successful poll recovers', async t => {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());
|
||||
const first=f.refresh(),reading=sample();f.requests[0].resolve(reading);await first;
|
||||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
const failed=f.refresh();f.requests[1].reject(Error('Synthetic status parsing error'));await failed;
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.installFlatpak(); // actual production render(state), with job held pending
|
||||
assert.equal(f.requests.length,2,'redraw must not start a replacement reading');
|
||||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
assert.match(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
const fresh=f.refresh();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true,'starting a poll does not clear failure');
|
||||
const next=sample();next.battery.percent=98;next.volume.level=.2;
|
||||
f.requests[2].resolve(next);await fresh;
|
||||
assert.equal(f.element('metric-battery').textContent,'98%');
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||
});
|
||||
test('delayed production bootstrap retains failed-read state and fresh-poll recovery', async t => {
|
||||
for(const priorSuccess of [true,false]) {
|
||||
const f=sharedPage();
|
||||
if(priorSuccess){const first=f.refresh();f.requests[0].resolve(sample());await first;}
|
||||
const failed=f.refresh();f.requests.at(-1).reject(Error('Status failed before mount'));await failed;
|
||||
const calls=f.requests.length,ui=f.mount();t.after(()=>ui.destroy());
|
||||
assert.equal(f.requests.length,calls);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
assert.match(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
const fresh=f.refresh();assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.requests.at(-1).resolve(sample());await fresh;
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||
}
|
||||
});
|
||||
test('same-object replay preserves invalidation and receipt age after a failed reading', t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();
|
||||
f.ui.status(reading);f.ui.unavailable(Error('Status failure'));
|
||||
f.ui.status(reading,Date.now()+1000);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.match(f.element('ccSampleState').textContent,/Status failure/);
|
||||
f.ui.status(sample());assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
});
|
||||
test('delayed production bootstrap preserves client receipt age despite headset clock skew', async t => {
|
||||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=sharedPage(),prior=f.refresh();const reading=sample();reading.time-=3600;
|
||||
f.requests[0].resolve(reading);await prior;
|
||||
now+=91000;const ui=f.mount();t.after(()=>ui.destroy());
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.match(f.element('ccSampleState').textContent,/expired/);
|
||||
const fresh=f.refresh();f.requests[1].resolve(sample());await fresh;
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
});
|
||||
test('sample replay cannot renew receipt age; fresh receipts tolerate missing or skewed headset time', t => {
|
||||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();f.ui.status(reading);
|
||||
now+=91000;f.ui.status(reading);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
for(const time of [undefined,null,'invalid',NaN,Infinity,now/1000-3600,now/1000+3600]) {
|
||||
f.ui.status({...sample(),time});
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Invalid Date/);
|
||||
}
|
||||
for(const receipt of [null,NaN,Infinity,now+1000,now-91000]) {
|
||||
f.ui.status(sample(),receipt);assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
}
|
||||
});
|
||||
test('mute-only readback preserves an unapplied volume until Apply sends it', async t => {
|
||||
let reading=sample();const f=mounted({api:async(path,change,device)=>{
|
||||
f.calls.push([path,change,device]);reading={...reading,volume:{...reading.volume,...change}};return {message:'Done'};
|
||||
},refresh:async()=>f.ui.status({...reading})});t.after(()=>f.ui.destroy());f.ui.status(reading);
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
await f.element('ccMute').onclick();
|
||||
assert.deepEqual(f.calls[0],['/api/volume',{muted:true},'frame-a']);
|
||||
assert.equal(f.element('ccVolume').value,'65');assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||
assert.match(f.element('ccAudioReadback').textContent,/Reported 40% · muted/);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 40%, muted/);
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.deepEqual(f.calls[1],['/api/volume',{level:.65},'frame-a']);
|
||||
assert.equal(f.element('ccVolume').value,65);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, muted/);
|
||||
});
|
||||
test('live samples expire even when the status poll is paused', t => {
|
||||
const original=Date.now;let now=100000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
now+=91000;
|
||||
// A connection update must also re-check freshness before enabling a control.
|
||||
f.ui.connection(true);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
});
|
||||
test('mounted filters show empty state, counts, reset and host-dependent features', t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());
|
||||
f.element('ccSearch').value='dpi';f.element('ccSearch').input();
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='display').hidden,false);
|
||||
assert.equal(f.element('ccResultCount').textContent,'1 section');
|
||||
f.element('ccSearch').value='nothing-matches-this';f.element('ccSearch').input();
|
||||
assert.equal(f.element('ccEmpty').hidden,false);
|
||||
f.element('ccResetFilter').onclick();assert.equal(f.element('ccEmpty').hidden,true);
|
||||
f.ui.host({os:'Windows'});assert.equal(f.element('macLink').hidden,true);
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='mac').hidden,false);
|
||||
f.ui.host({os:'macOS'});assert.equal(f.element('macLink').hidden,false);
|
||||
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
|
||||
});
|
||||
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
|
||||
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
|
||||
let activePage,scrolled;
|
||||
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||
const sections=new Map();
|
||||
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
|
||||
window:{scrollTo(){}},$:id=>{
|
||||
if(!ids.has(id))return null;
|
||||
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
|
||||
return sections.get(id);
|
||||
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||
const androidStart=html.indexOf('function showAndroidTab(');
|
||||
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
|
||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
|
||||
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
|
||||
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
|
||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
|
||||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||
if(!href.startsWith('#')||href==='#connection')continue;
|
||||
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
|
||||
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
|
||||
assert.equal(activePage,expected[id],href);
|
||||
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
|
||||
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
|
||||
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
|
||||
}
|
||||
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
|
||||
assert.equal(vm.runInContext('PAGES[5]',context),'control');
|
||||
});
|
||||
@@ -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)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Control Center API guards and shared-page behavior, without a real headset."""
|
||||
import sandbox # noqa: F401 (first: isolates app state, SSH and telemetry)
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import server
|
||||
import frame_status
|
||||
|
||||
|
||||
class AudioValidation(unittest.TestCase):
|
||||
def test_invalid_values_do_not_touch_device_even_with_valid_mute(self):
|
||||
invalid = [{}, {'level': None}, {'level': True}, {'level': '0.5'}, {'level': []},
|
||||
{'level': float('nan')}, {'level': float('inf')}, {'level': -1}, {'level': 2},
|
||||
{'muted': 'false'}, {'muted': 1}, {'muted': None}, {'action': 'mute'},
|
||||
{'level': -1, 'muted': True}, {'level': .5, 'muted': 'yes'}]
|
||||
for body in invalid:
|
||||
with self.subTest(body=body), mock.patch.object(server, 'ssh') as ssh:
|
||||
with self.assertRaises(server.Failure) as raised:
|
||||
server.set_volume(body)
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_supported_actions_use_wpctl_with_exact_values(self):
|
||||
for body, commands in [({'level': 0}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.00']),
|
||||
({'level': 1}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.00']),
|
||||
({'muted': False}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 0']),
|
||||
({'level': .65, 'muted': True}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 1',
|
||||
'wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.65'])]:
|
||||
with self.subTest(body=body), mock.patch.object(server, 'ssh', return_value='') as ssh:
|
||||
self.assertEqual(server.set_volume(body)['message'], 'Volume updated')
|
||||
self.assertEqual([c.args[0] for c in ssh.call_args_list], commands)
|
||||
|
||||
def test_device_error_propagates_without_claiming_success(self):
|
||||
with mock.patch.object(server, 'ssh', side_effect=server.Failure('Headset offline')):
|
||||
with self.assertRaisesRegex(server.Failure, 'Headset offline'):
|
||||
server.set_volume({'level': .5})
|
||||
|
||||
|
||||
class Coverage(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page JS')
|
||||
def test_control_center_behavior(self):
|
||||
result = subprocess.run(['node', '--test', str(ROOT / 'tests/control_center.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_shared_assets_ship_to_iphone_and_desktop(self):
|
||||
spec = importlib.util.spec_from_file_location('frame_bundle_cc', ROOT / 'ios/scripts/make_frame_bundle.py')
|
||||
bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bundle)
|
||||
files = set(bundle.files())
|
||||
for name in ('control-center.js', 'control-center.css', 'artwork-settings.js'):
|
||||
self.assertIn(ROOT / 'ui' / name, files)
|
||||
config = json.loads((ROOT / 'app/package.json').read_text())
|
||||
ui = next(item for item in config['build']['extraResources'] if item['from'] == '../ui')
|
||||
self.assertIn('*.js', ui['filter'])
|
||||
self.assertIn('*.css', ui['filter'])
|
||||
|
||||
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
|
||||
# Run the real aggregator with every external probe stubbed.
|
||||
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
|
||||
'disk', 'memory', 'max_temp', 'wifi', 'ip_addr', 'volume', 'port_listening', 'games', 'flatpaks')
|
||||
with mock.patch.multiple(frame_status, **{name: mock.DEFAULT for name in names}) as probes, \
|
||||
mock.patch.object(frame_status, 'thermal_alerts', return_value=[{'zone':'battery','tempC':45,'limitC':44}]):
|
||||
probes['read'].return_value = '120 0'
|
||||
probes['process_names'].return_value = set()
|
||||
result = frame_status.status()
|
||||
self.assertEqual(result['thermalAlerts'], [{'zone':'battery','tempC':45,'limitC':44}])
|
||||
@@ -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()
|
||||
@@ -79,6 +79,15 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn(b"<html", payload.lower())
|
||||
|
||||
def test_control_center_assets_served_with_correct_types(self):
|
||||
for path, mime, marker in (('/control-center.js', 'text/javascript', b'createController'),
|
||||
('/control-center.css', 'text/css', b'.cc-workspace')):
|
||||
with self.subTest(path=path):
|
||||
status, headers, payload = self.request('GET', path)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(mime, headers['Content-Type'])
|
||||
self.assertIn(marker, payload)
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
|
||||
+203
-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"])
|
||||
@@ -159,11 +159,79 @@ class Lifecycle(Base):
|
||||
e = self.queued()[-1]
|
||||
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
|
||||
|
||||
def test_opens_installs_and_updates_carry_the_country(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
|
||||
tm.app_started()
|
||||
self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
|
||||
[("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
|
||||
self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
|
||||
|
||||
def test_only_zones_in_the_tz_table_give_a_country(self):
|
||||
for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
|
||||
("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
|
||||
("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
|
||||
("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
|
||||
("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
|
||||
("Japan", "JP"), ("UTC", None), ("alice", None),
|
||||
("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
|
||||
self.assertEqual(tm.country(), want, tz)
|
||||
|
||||
def test_country_from_tz_or_localtime_in_a_checkout(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
|
||||
self.assertEqual(tm.country(), "JP")
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
|
||||
for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
|
||||
("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
|
||||
("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
|
||||
with mock.patch.object(tm.os.path, "realpath", return_value=target):
|
||||
self.assertEqual(tm.country(), want, target)
|
||||
|
||||
def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
|
||||
def test_frame_build_once(self):
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
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):
|
||||
@@ -198,6 +266,39 @@ class Sending(Base):
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
|
||||
|
||||
def test_an_older_notice_still_sends(self):
|
||||
got = self.serve()
|
||||
tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual(len(got), 1)
|
||||
|
||||
def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # as 0.4.5 saved it
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1) # the usual statistics still go
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
tm._save({**tm.settings(), "last_open_day": None}) # the next day
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
|
||||
|
||||
def test_turning_usage_on_still_waits_for_the_new_notice(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # 0.4.5, usage off
|
||||
tm.update_settings({"usage": True})
|
||||
self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
|
||||
|
||||
def test_a_failed_send_keeps_the_events(self):
|
||||
self.serve(status=500)
|
||||
tm.update_settings({"noticeShown": True})
|
||||
@@ -263,6 +364,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 +543,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 +569,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,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Local to the sixth tab: the existing Home, Games, Android and Tools keep
|
||||
their layouts. The same Steam palette gives the dashboard a quieter rhythm. */
|
||||
.cc { gap:24px; }
|
||||
.cc :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
|
||||
.cc-hero { display:flex; justify-content:space-between; align-items:center; gap:24px; padding:28px 30px;
|
||||
border:1px solid #66c0f421; border-radius:12px; background:radial-gradient(ellipse at 90% 0%,#1a9fff19,transparent 65%),linear-gradient(125deg,#21364a,#152230); }
|
||||
.cc-eyebrow { font-size:11px; font-weight:700; letter-spacing:2px; text-transform:uppercase; color:var(--link); margin-bottom:7px; }
|
||||
.cc-hero h1 { font-size:clamp(25px,3vw,36px); font-weight:500; letter-spacing:-.7px; margin:0 0 8px; color:white; }
|
||||
.cc-hero p { color:#adb9c5; margin:0; max-width:580px; }
|
||||
.cc-hero > div { min-width:0; }
|
||||
.cc-hero-mark { flex:none; width:114px; height:82px; color:var(--link); opacity:.85; }
|
||||
.cc-topline { display:flex; gap:14px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
|
||||
.cc-topline h2 { font-size:17px; font-weight:500; margin:0; color:var(--bright); min-width:0; max-width:100%; overflow-wrap:anywhere; }
|
||||
.cc-topline button { margin-left:auto; border-radius:6px; }
|
||||
.cc-sample { font-size:12px; color:var(--muted); }.cc-offline { color:#cfb787; }
|
||||
.cc-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
|
||||
.cc-metric { border:1px solid #ffffff0c; border-radius:9px; padding:18px 20px; background:linear-gradient(145deg,#25374a80,#18232f); min-width:0; }
|
||||
.cc-metric > span { color:#a7b8c8; font-size:12px; }.cc-metric > strong { display:block; color:white; font-size:clamp(20px,2.2vw,30px); font-weight:500; margin:7px 0 3px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||
.cc-metric > small { color:var(--muted); display:block; font-size:11.5px; }
|
||||
.cc-device-facts { margin:14px 0 0; padding:16px 20px; border:1px solid #ffffff0a; border-radius:9px; background:#10192366; }
|
||||
.cc-device-facts summary { cursor:pointer; color:#b8cbd9; font-size:12px; }.cc-device-facts dl { margin:18px 0 0; }
|
||||
.cc-readings { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px 18px; padding:0; }
|
||||
.cc-readings div { min-width:0; }.cc-readings dt { color:var(--muted); font-size:11px; }.cc-readings dd { margin:3px 0 0; color:#d9e7f2; font-size:13px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||
.cc-device-facts .cc-readings { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.cc-services { display:flex; flex-wrap:wrap; gap:12px 22px; border-top:1px solid #ffffff0b; margin-top:18px; padding-top:14px; }
|
||||
.cc-services > span { display:flex; gap:7px; align-items:center; font-size:11px; color:#b3c5d4; }.cc-services b { color:var(--muted); font-weight:400; }
|
||||
.cc-workspace { display:grid; grid-template-columns:220px minmax(0,1fr); gap:26px; align-items:start; }
|
||||
.cc-workspace > * { min-width:0; width:100%; max-width:100%; }
|
||||
.cc-sidebar { position:sticky; top:90px; }.cc-sidebar > p { margin:0 0 12px 12px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:1.5px; }
|
||||
.cc-categories { display:flex; flex-direction:column; gap:5px; min-width:0; max-width:100%; }.cc-categories button { display:flex; align-items:center; text-align:left; gap:10px; border-radius:7px; background:transparent; min-height:43px; padding:10px 12px; color:#a8b8c8; font-size:12px; }
|
||||
.cc-categories svg { width:17px; height:17px; flex:none; }.cc-category-count { margin-left:auto; font-size:11px; opacity:.65; }
|
||||
.cc-categories button:hover { background:#66c0f40a; color:white; }.cc-categories button[aria-pressed=true] { background:#1a9fff1a; color:var(--link); box-shadow:inset 2px 0 var(--blue); }
|
||||
.cc-status { margin-top:24px; padding:18px 14px; border-top:1px solid #ffffff10; }.cc-status > div { display:flex; align-items:center; gap:8px; font-size:12px; color:#c4d2dc; }.cc-status p { margin:10px 0 0; color:var(--muted); font-size:11.5px; line-height:1.6; }
|
||||
.cc-search-row { display:flex; align-items:center; gap:14px; margin-bottom:18px; }.cc-search { display:flex; align-items:center; gap:10px; border:1px solid #66c0f426; border-radius:8px; padding:0 12px; background:#0e1824; flex:1; min-width:0; }
|
||||
.cc-search:focus-within { border-color:var(--blue); }.cc-search svg { width:18px; color:var(--muted); flex:none; }.cc-search input { width:100%; min-width:0; padding:13px 0; border:0; background:transparent; font-size:13px; }.cc-search input:focus { outline:none; }.cc-search button { padding:3px 7px; background:transparent; color:var(--muted); font-size:18px; }.cc-results { color:var(--muted); font-size:11px; white-space:nowrap; }
|
||||
.cc-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }.cc-card { display:flex; flex-direction:column; min-width:0; padding:22px; background:linear-gradient(150deg,#202e3d,#17212c); border:1px solid #ffffff0e; border-radius:10px; }
|
||||
.cc-card-head { display:flex; gap:10px; align-items:center; justify-content:space-between; margin-bottom:16px; }.cc-icon { width:34px; height:34px; display:grid; place-items:center; background:#66c0f40c; border-radius:8px; color:#85bfe2; }.cc-icon svg { width:20px; height:20px; }
|
||||
.cc-kind { font-size:10px; padding:3px 7px; border-radius:4px; color:#a2b3c2; background:#ffffff06; letter-spacing:.2px; }.cc-kind-control { color:#92cdeb; background:#1a9fff12; }.cc-kind-headset { color:#d3c095; background:#d9a23a0b; }.cc-kind-read { color:#9fceb9; background:#59bf4009; }
|
||||
.cc-card h2 { font-size:17px; font-weight:500; color:white; margin:0 0 8px; }.cc-card > p { font-size:12.5px; color:#a4b4c3; line-height:1.65; margin:0 0 14px; }
|
||||
.cc-guidance { padding:11px 12px; border-left:2px solid #9e876544; border-radius:0 4px 4px 0; background:#ffffff03; color:#bcb7aa; font-size:11.5px; line-height:1.6; margin-bottom:14px; }
|
||||
.cc-links { display:flex; flex-wrap:wrap; gap:10px 18px; margin-top:auto; padding-top:8px; }.cc-links a { display:inline-flex; gap:8px; align-items:center; color:var(--link); font-size:12px; text-decoration:none; }.cc-links a:hover { color:white; text-decoration:underline; }.cc-links a span { font-size:14px; opacity:.65; }
|
||||
.cc-note { font-size:11px; line-height:1.6; color:var(--muted); }.cc-card p.cc-note { margin:10px 0 0; }.cc-audio label { display:flex; justify-content:space-between; font-size:12px; color:#b7c8d6; }.cc-audio output { color:var(--link); font-variant-numeric:tabular-nums; }.cc-audio input { width:100%; margin:15px 0; }.cc-audio-actions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }.cc-audio-actions button { border-radius:5px; }.cc-audio-actions span { flex-basis:100%; margin-top:4px; }.cc-audio input:disabled { opacity:.35; }
|
||||
.cc-empty { padding:40px 24px; border:1px dashed #66c0f433; border-radius:10px; text-align:center; color:var(--muted); }.cc-empty h2 { color:white; font-size:18px; font-weight:500; }.cc-empty button { margin-top:12px; border-radius:5px; }
|
||||
@media (min-width:641px) and (max-width:1250px) { .brand b { display:none; } header { gap:14px; } nav a { padding:0 9px; font-size:13px; } .pill { min-width:120px; } }
|
||||
@media (min-width:641px) and (max-width:900px) { html { scroll-padding-top:116px; } header { flex-wrap:wrap; height:auto; min-height:64px; padding-top:8px; padding-bottom:8px; gap:6px 12px; } nav { order:3; width:100%; height:36px; } nav a { flex:1; justify-content:center; padding:0 6px; } .hdr-end { flex:1; justify-content:flex-end; } .cc-sidebar { top:115px; } }
|
||||
@media (max-width:1100px) { .cc-workspace { grid-template-columns:185px minmax(0,1fr); gap:18px; }.cc-card { padding:18px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
||||
@media (max-width:900px) { .cc-workspace { grid-template-columns:minmax(0,1fr); }.cc-sidebar { position:static; }.cc-sidebar > p,.cc-status { display:none; }.cc-categories { flex-direction:row; overflow-x:auto; padding-bottom:6px; }.cc-categories button { flex:none; white-space:nowrap; }.cc-category-count { margin-left:2px; }.cc-categories button[aria-pressed=true] { box-shadow:inset 0 -2px var(--blue); }.cc-summary { gap:10px; }.cc-metric { padding:15px; } }
|
||||
@media (max-width:640px) { .cc { gap:20px; }.cc-hero { padding:22px 20px; }.cc-hero p { font-size:12px; }.cc-hero-mark { width:72px; height:55px; }.cc-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-metric > strong { font-size:24px; }.cc-topline { gap:8px; }.cc-sample { order:3; width:100%; }.cc-device-facts { padding:14px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-cards { grid-template-columns:minmax(0,1fr); gap:12px; }.cc-search-row { gap:8px; }.cc-results { font-size:10px; }.cc-card { padding:20px; }.cc-card h2 { font-size:18px; } }
|
||||
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-links a { min-height:44px; }.cc-audio-actions button,.cc-topline button { min-height:44px; }.cc-search button { min-width:36px; min-height:44px; } }
|
||||
@@ -0,0 +1,318 @@
|
||||
/* The Control Center uses the shared page's guarded api() and status poll.
|
||||
* Browsing never starts a stream, installs a helper or changes the headset. */
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.FrameControlCenter = factory();
|
||||
})(typeof window === 'object' ? window : globalThis, function () {
|
||||
'use strict';
|
||||
const categories = [
|
||||
['all', 'Everything', 'grid'], ['device', 'Device & audio', 'device'],
|
||||
['vr', 'VR & comfort', 'eye'], ['apps', 'Apps & files', 'apps'],
|
||||
['network', 'Connection & remote', 'network'], ['companion', 'Frame Control', 'settings'],
|
||||
];
|
||||
// Each destination is an existing workflow, with its own guards and confirmations.
|
||||
const catalog = [
|
||||
{id:'audio', category:'device', icon:'audio', title:'Audio', kind:'control', label:'Available here',
|
||||
description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.',
|
||||
keywords:'sound speakers headphones loud quiet', special:'audio'},
|
||||
{id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics',
|
||||
description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.',
|
||||
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
|
||||
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
|
||||
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
|
||||
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#apps','Linux apps'],['#power','SFTP & remote tools']]},
|
||||
{id:'microphone', category:'device', icon:'audio', title:'Microphone & audio routing', kind:'headset', label:'In the headset',
|
||||
description:'Frame Control controls the default output volume only. Microphone mute, input gain, output selection, balance and per-app mixers have no verified API here.',
|
||||
keywords:'mic input microphone audio sink output device gain mute eq equalizer spatial audio balance headphones',
|
||||
guidance:'Check the headset’s audio controls or the app’s own audio settings for these adjustments.'},
|
||||
{id:'power', category:'device', icon:'power', title:'Power & sleep', kind:'flow', label:'Existing controls',
|
||||
description:'Sleep, restart or shut down with confirmation and your Developer Mode password. A sleeping Frame must be woken on the device.',
|
||||
keywords:'battery suspend reboot off awake idle timeout keep awake inhibitor', links:[['#power','Power controls']],
|
||||
guidance:'Set automatic sleep in the headset: Steam Settings → Power → Sleep after inactivity. Keep-awake is available through the existing script/MCP tools; there is no switch here.'},
|
||||
{id:'system', category:'device', icon:'device', title:'SteamOS & hardware', kind:'headset', label:'In the headset',
|
||||
description:'Check your current OS build above. Manage system updates, Developer Mode and hardware settings in Steam Settings on the Frame.',
|
||||
keywords:'firmware update beta stable channel developer password bluetooth controller battery pairing calibration brightness refresh rate display IPD lenses passthrough camera eye hand tracking foveation recovery factory reset',
|
||||
guidance:'Brightness, refresh rate, optics, controllers, Bluetooth and passthrough have no verified remote controls here. Check the headset’s own controls. Recovery and factory reset are manual procedures.'},
|
||||
{id:'performance', category:'vr', icon:'pulse', title:'Performance monitor', kind:'read', label:'Read-only metrics',
|
||||
description:'CPU, GPU clock, app and compositor timings come from the Frame. Unavailable timings stay empty, including when SteamVR is in standby.',
|
||||
keywords:'fps frame rate gpu cpu heat temperature thermal sensor render memory', special:'performance', links:[['#vrTools','HUD & performance tools']]},
|
||||
{id:'view', category:'vr', icon:'eye', title:'Headset view & casting', kind:'flow', label:'Existing controls',
|
||||
description:'Capture the lenses or desktop, stream live video, and control a focused panel. Casting can reveal private content.',
|
||||
keywords:'screenshot camera video capture live stream desktop mirror control eye', links:[['#view','Open headset view'],['#shots','Screenshots']]},
|
||||
{id:'comfort', category:'vr', icon:'clock', title:'Family & comfort', kind:'flow', label:'Existing controls',
|
||||
description:'Set a session limit, breaks and check-ins, with battery and heat alerts. Headset reminders continue if your companion disconnects.',
|
||||
keywords:'parent child timer minutes notifications health break low battery', links:[['#comfort','Configure a session']],
|
||||
guidance:'A reminder, not a parental lock. Save and pause before the session returns to Steam Home.'},
|
||||
{id:'playspace', category:'vr', icon:'space', title:'Playspace & movement', kind:'headset', label:'In the headset',
|
||||
description:'Use SteamVR’s recenter and room setup. Choose seated play, snap-turn, teleport and vignettes in each game’s own settings.',
|
||||
keywords:'boundary guardian tracking origin floor standing seated motion sickness locomotion turn recenter room',
|
||||
guidance:'No verified universal locomotion switch or remote tracking-origin control is available.'},
|
||||
{id:'panels', category:'vr', icon:'grid', title:'Panels & workspace', kind:'flow', label:'Existing controls',
|
||||
description:'Find an open panel and bring it forward, or open the panel switcher in the headset. Move, size and dock panels with SteamVR’s dashboard.',
|
||||
keywords:'overlay window layout float world curvature docking theatre multitasking', links:[['#panels','Open panel switcher']],
|
||||
guidance:'Saved spatial layouts are not available. Panel placement stays in SteamVR.'},
|
||||
{id:'steam', category:'apps', icon:'game', title:'Steam library', kind:'flow', label:'Existing controls',
|
||||
description:'Browse owned games and Frame ratings, install with progress, or launch an installed game. Purchases and license prompts use Steam.',
|
||||
keywords:'games download install store owned compatibility rating verified proton', links:[['#library','Installed games'],['#getgames','Library & store']]},
|
||||
{id:'android', category:'apps', icon:'apps', title:'Android apps', kind:'flow', label:'Existing controls',
|
||||
description:'Discover apps, manage Lepton instances, and launch, stop, test or remove installed apps. View compatibility reports before installing.',
|
||||
keywords:'apk fdroid lepton application sideload sources repository reports', links:[['#appStore','Discover apps'],['#androidLibrary','Manage installed apps']]},
|
||||
{id:'sources', category:'apps', icon:'shield', title:'App sources & compatibility', kind:'flow', label:'Existing controls',
|
||||
description:'Choose catalogue sources or add a signed F-Droid repository from Sources. Review install availability and compatibility reports for the exact app.',
|
||||
keywords:'fdroid f-droid repository source fingerprint trust signature compatibility report apk sidequest data backup restore', links:[['#appStore','Catalogue & sources'],['#androidLibrary','Apps & reports']],
|
||||
guidance:'Android data backup and restore are script-only. This interface does not provide an app-data recovery workflow.'},
|
||||
{id:'display', category:'apps', icon:'display', title:'Android display & text', kind:'flow', label:'Existing controls',
|
||||
description:'Choose a running Android instance, then set resolution, UI density and text size, or reset to its defaults.',
|
||||
keywords:'dpi scaling font scale sharp native 1920 1080 2560 1440 resolution screen text accessibility', links:[['#display','Configure app display']],
|
||||
guidance:'Applies to that Lepton instance, not the headset’s lenses. Persistence after relaunch is unverified.'},
|
||||
{id:'sideload', category:'apps', icon:'game', title:'Sideloaded games', kind:'flow', label:'Existing controls',
|
||||
description:'Send a game folder, ZIP or executable. Frame Control selects Proton or the Linux runtime and adds a Steam library entry.',
|
||||
keywords:'windows exe zip linux devkit runtime shortcuts launch remove', links:[['#sideloaded','Manage titles'],['#transfer','Send a game']]},
|
||||
{id:'files', category:'apps', icon:'files', title:'Files & clipboard', kind:'flow', label:'Existing controls',
|
||||
description:'Send files to ~/Downloads, or send text to the Frame’s clipboard. Clipboard transfer needs the desktop panel open.',
|
||||
keywords:'upload transfer downloads paste copy text sftp storage', links:[['#transfer','Send files & text']]},
|
||||
{id:'media', category:'apps', icon:'play', title:'Video & stereo photos', kind:'flow', label:'Preview feature',
|
||||
description:'Send and play H.264/H.265 video, SBS/OU photos or small splats with the built-in player. Choose a layout and theatre surround.',
|
||||
keywords:'movie photo sbs ou 3d splat gaussian theatre media player', links:[['#media','Open media player']],
|
||||
guidance:'Seeking, subtitles, native HEIC/MPO and large splat scenes are not supported yet.'},
|
||||
{id:'flatpak', category:'apps', icon:'apps', title:'Linux desktop apps', kind:'flow', label:'Existing controls',
|
||||
description:'Install user Flatpaks by Flathub ID, choose quick picks like VLC and Moonlight, and manage installed apps.',
|
||||
keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#apps','Manage Linux apps']]},
|
||||
{id:'connection', category:'network', icon:'network', title:'Headsets & addresses', kind:'flow', label:'Existing controls',
|
||||
description:'Manage headsets, LAN, USB-C, mDNS and Tailscale addresses. Inspect connection attempts and choose the device you want to control.',
|
||||
keywords:'wifi wi-fi network ip ssh pairing keys discovery alias tailscale usb offline reconnect', links:[['#devices','Manage headsets','desktop'],['#connection','Connection details']],
|
||||
guidance:'Join Wi-Fi networks on the headset. Frame Control manages connection addresses; it does not change Wi-Fi credentials.'},
|
||||
{id:'remote', category:'network', icon:'display', title:'Remote access', kind:'flow', label:'Existing controls',
|
||||
description:'Open SSH, SFTP, Steam Link or remote desktop using the host’s existing tools. Developer Mode enables headset SSH and xrdp.',
|
||||
keywords:'terminal shell remote desktop rdp xrdp streaming steam link sftp', links:[['#power','Open remote tools']]},
|
||||
{id:'input', category:'network', icon:'keyboard', title:'Keyboard & trackpad', kind:'flow', label:'Existing controls',
|
||||
description:'Type and point in the Frame’s apps with KDE Connect, or use Control on the live panel view. First use sets up the input helper.',
|
||||
keywords:'mouse pointer keyboard trackpad touch click kde input', links:[['#pad','Open keyboard & trackpad'],['#view','Panel control']]},
|
||||
{id:'mac', category:'network', icon:'display', title:'Mac in the headset', kind:'flow', label:'macOS only', platform:'mac',
|
||||
description:'Stream Mac screens or individual windows into headset panels. Choose quality and give each window its own display.',
|
||||
keywords:'macos mac window screen sharing stream quality sharp balanced light permissions', links:[['#macview','Configure Mac streaming']],
|
||||
guidance:'Requires Frame Control running on a Mac. It is unavailable when the server runs on Windows, Linux or the headset.'},
|
||||
{id:'privacy', category:'companion', icon:'shield', title:'Privacy & sharing', kind:'flow', label:'Companion settings',
|
||||
description:'Choose anonymous usage statistics, compatibility sharing and error details separately. Inspect what was sent and manage your optional contact email.',
|
||||
keywords:'telemetry analytics diagnostics consent data tracking email contact opt out', links:[['#privacy','Privacy settings']]},
|
||||
{id:'artwork', category:'companion', icon:'image', title:'Library artwork', kind:'flow', label:'Companion settings',
|
||||
description:'Configure an optional SteamGridDB key or refresh artwork. Without a key, source images and generated art remain available.',
|
||||
keywords:'cover image steamgriddb api key library art refresh', links:[['#artworkSettings','Artwork settings']]},
|
||||
{id:'assistant', category:'companion', icon:'spark', title:'Assistant & agents', kind:'flow', label:'Opt-in tools',
|
||||
description:'Use your own model endpoint and review proposed actions. Nothing goes to a model until you opt in; protected actions need approval.',
|
||||
keywords:'ai mcp model endpoint agent approvals chat assistant', links:[['/assistant','Open assistant']]},
|
||||
{id:'support', category:'companion', icon:'help', title:'Updates & diagnostics', kind:'flow', label:'Companion settings',
|
||||
description:'Check Frame Control updates in the desktop app or prepare a private problem report. Review the diagnostic preview before sending.',
|
||||
keywords:'app version bug feedback support update log error report licenses', links:[['#privacy','Updates & problem reports']]},
|
||||
];
|
||||
const paths = {
|
||||
grid:'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
|
||||
device:'M3 7h18v10H3z M8 11v2 M16 11v2', eye:'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0',
|
||||
apps:'M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z M17 14v6 M14 17h6',
|
||||
network:'M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01',
|
||||
settings:'M4 6h16 M4 12h16 M4 18h16 M8 3v6 M16 9v6 M10 15v6',
|
||||
audio:'M3 9h4l5-4v14l-5-4H3z M16 8a6 6 0 0 1 0 8 M19 5a10 10 0 0 1 0 14',
|
||||
power:'M12 2v10 M6 5a9 9 0 1 0 12 0', pulse:'M2 12h5l3-8 4 16 3-8h5',
|
||||
clock:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2',
|
||||
space:'M3 8l9-5 9 5v8l-9 5-9-5z M3 8l9 5 9-5 M12 13v8',
|
||||
game:'M6 8h12l3 9-3 2-4-4h-4l-4 4-3-2z M7 10v4 M5 12h4 M16 11h.01 M18 13h.01',
|
||||
display:'M3 4h18v13H3z M8 21h8 M12 17v4', files:'M3 5h7l2 3h9v12H3z',
|
||||
play:'M8 4l12 8-12 8z', keyboard:'M2 6h20v12H2z M6 10h.01 M10 10h.01 M14 10h.01 M18 10h.01 M6 14h12',
|
||||
shield:'M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6z M8 12l3 3 5-6',
|
||||
image:'M3 3h18v18H3z M3 17l6-6 5 5 3-3 4 4 M16 7h.01',
|
||||
spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3z',
|
||||
help:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M9 8a3 3 0 0 1 6 1c0 2-3 2-3 5 M12 17h.01',
|
||||
};
|
||||
const icon = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[name] || paths.settings}"/></svg>`;
|
||||
function matches(item, query, category) {
|
||||
const text = [item.title, item.description, item.keywords, item.guidance, item.label,
|
||||
categories.find(([id])=>id === item.category)?.[1]].join(' ').toLowerCase();
|
||||
return (category === 'all' || item.category === category) && query.toLowerCase().trim().split(/\s+/).every(word => text.includes(word));
|
||||
}
|
||||
const number = (value, suffix = '', digits = 0) => Number.isFinite(value) ? value.toLocaleString(undefined, {maximumFractionDigits:digits}) + suffix : 'Unavailable';
|
||||
const gib = value => Number.isFinite(value) ? number(value / 1073741824, ' GiB', 1) : 'Unavailable';
|
||||
const duration = seconds => Number.isFinite(seconds) && seconds >= 0 ? seconds < 3600 ? `${Math.floor(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.floor(seconds % 3600 / 60)} min` : 'Unavailable';
|
||||
function metrics(sample) {
|
||||
const s = sample || {}, b = s.battery || {}, p = s.performance || {}, h = s.disk?.home, m = s.memory;
|
||||
const capacity = b.percent;
|
||||
const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5;
|
||||
const charging = b.status === 'Charging';
|
||||
const full = b.status === 'Full' || (!!s.power && capacity >= 100);
|
||||
const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' :
|
||||
full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery';
|
||||
return {
|
||||
battery:number(capacity, '%'), batteryDetail:batteryState,
|
||||
storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable',
|
||||
memory:m && Number.isFinite(m.total) && Number.isFinite(m.available) ? gib(m.total - m.available) : 'Unavailable',
|
||||
memoryDetail:m ? `${gib(m.total)} total · system memory used` : 'Memory sensor unavailable',
|
||||
rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available),
|
||||
fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS',
|
||||
power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable',
|
||||
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable',
|
||||
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
|
||||
wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable',
|
||||
uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable',
|
||||
temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1),
|
||||
appFps:number(p.appFps, '', 1), frame:number(p.frameMs, ' ms', 2), render:number(p.gpuMs, ' ms', 2), compositorCpu:number(p.compositorCpuMs, ' ms', 2),
|
||||
thermal:!Array.isArray(s.thermalAlerts) ? 'Trip data unavailable' : s.thermalAlerts.length ? s.thermalAlerts.map(a => `${a.zone || 'Sensor'} ${number(a.tempC, ' °C')} ≥ ${number(a.limitC, ' °C')}`).join('; ') : 'No hot / critical trip reported',
|
||||
};
|
||||
}
|
||||
// Strict validation and captured device identity apply even if a stale enabled
|
||||
// button is clicked during a disconnect, headset switch, or pending request.
|
||||
function createController({api, getContext}) {
|
||||
let busy = false;
|
||||
return {
|
||||
get busy() { return busy; },
|
||||
async audio(change) {
|
||||
if (busy) throw new Error('An audio change is still in progress.');
|
||||
const context = getContext();
|
||||
if (context.online !== true || !context.fresh || !context.sample?.volume) throw new Error('Connect and refresh the Frame before changing audio.');
|
||||
const keys = Object.keys(change);
|
||||
if (!keys.length || keys.some(k => !['level','muted'].includes(k)) ||
|
||||
('level' in change && (typeof change.level !== 'number' || !Number.isFinite(change.level) || change.level < 0 || change.level > 1)) ||
|
||||
('muted' in change && typeof change.muted !== 'boolean')) throw new Error('Use a volume from 0 to 100% and a valid mute state.');
|
||||
busy = true;
|
||||
try {
|
||||
const result = await api('/api/volume', change, context.device);
|
||||
if (getContext().generation !== context.generation) return null;
|
||||
return result;
|
||||
} finally { busy = false; }
|
||||
},
|
||||
};
|
||||
}
|
||||
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform}) {
|
||||
const get = id => document.getElementById(id), page = get('controlCenter');
|
||||
let category = 'all', sample = null, receivedAt = null, available = false, error = '', dirty = false, sampleGeneration = null, audioPending = false;
|
||||
const metric = (id, label) => `<div><dt>${label}</dt><dd data-cc-metric="${id}">Unavailable</dd></div>`;
|
||||
const perf = `<dl class="cc-readings">${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.</p><p class="cc-note" data-cc-metric="thermal">Trip data unavailable</p>`;
|
||||
const audio = `<div class="cc-audio"><label for="ccVolume">Output volume <output id="ccVolumeValue" for="ccVolume">—</output></label><input id="ccVolume" type="range" min="0" max="100" step="1" value="0" disabled><div class="cc-audio-actions"><button class="action small" id="ccApplyVolume" disabled>Apply volume</button><button class="small" id="ccMute" disabled>Mute</button><span id="ccAudioReadback" class="cc-note">Waiting for an audio reading</span></div><p class="cc-note" id="ccAudioResult" role="status"></p></div>`;
|
||||
const health = `<dl class="cc-readings">${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
const storage = `<dl class="cc-readings">${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>`<button type="button" data-cc-category="${id}" aria-pressed="${id === 'all'}">${icon(symbol)}<span>${label}</span><span class="cc-category-count"></span></button>`).join('');
|
||||
const specials = {audio, performance:perf, health, storage};
|
||||
get('ccCards').innerHTML = catalog.map(item=>`<section class="cc-card" data-cc-card="${item.id}"><div class="cc-card-head"><span class="cc-icon">${icon(item.icon)}</span><span class="cc-kind cc-kind-${item.kind}">${item.label}</span></div><h2>${item.title}</h2><p>${item.description}</p>${specials[item.special] || ''}${item.guidance ? `<div class="cc-guidance">${item.guidance}</div>` : ''}${item.links ? `<div class="cc-links">${item.links.map(([href,label,desktop])=>`<a href="${href}"${desktop ? ' class="desk-only"' : ''}${item.platform === 'mac' ? ' data-cc-mac-link' : ''}>${label}<span aria-hidden="true">↗</span></a>`).join('')}</div>` : ''}</section>`).join('');
|
||||
// Host-only features stay searchable with a reason; only their unsupported
|
||||
// action disappears. The host API, rather than viewport/UA, decides availability.
|
||||
let macSupported = platform === 'darwin';
|
||||
const visibleCatalog = catalog;
|
||||
function filter() {
|
||||
const query = get('ccSearch').value;
|
||||
let count = 0;
|
||||
catalog.forEach(item => {
|
||||
const visible = visibleCatalog.includes(item) && matches(item, query, category);
|
||||
page.querySelector(`[data-cc-card="${item.id}"]`).hidden = !visible;
|
||||
if (visible) count++;
|
||||
});
|
||||
get('ccResultCount').textContent = `${count} ${count === 1 ? 'section' : 'sections'}`;
|
||||
get('ccEmpty').hidden = count > 0;
|
||||
get('ccClear').hidden = !query;
|
||||
page.querySelectorAll('[data-cc-category]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.ccCategory === category));
|
||||
button.querySelector('.cc-category-count').textContent = visibleCatalog.filter(item=>matches(item, query, button.dataset.ccCategory)).length;
|
||||
});
|
||||
page.querySelectorAll('[data-cc-mac-link]').forEach(link=>{link.hidden = !macSupported;});
|
||||
}
|
||||
get('ccSearch').addEventListener('input', filter);
|
||||
function resetFilter() { get('ccSearch').value = ''; category = 'all'; filter(); get('ccSearch').focus(); }
|
||||
get('ccClear').onclick = () => { get('ccSearch').value = ''; filter(); get('ccSearch').focus(); };
|
||||
get('ccResetFilter').onclick = resetFilter;
|
||||
get('ccCategories').onclick = event => {
|
||||
const button = event.target.closest('[data-cc-category]');
|
||||
if (!button) return;
|
||||
category = button.dataset.ccCategory; filter();
|
||||
};
|
||||
page.querySelector('[href="#connection"]').onclick = event => { event.preventDefault(); openConnection(); };
|
||||
// The headset clock can differ from this client's. Age uses the client
|
||||
// receipt time, retained by the shared poll when it buffers an early sample.
|
||||
const isFresh = () => available && Number.isFinite(receivedAt) && Date.now() >= receivedAt && Date.now() - receivedAt <= 90000;
|
||||
const context = () => ({...getContext(), sample, fresh:isFresh() && sampleGeneration === getContext().generation});
|
||||
const controller = createController({api, getContext:context});
|
||||
function audioAvailability() {
|
||||
const enabled = context().online === true && context().fresh && !!sample?.volume && !controller.busy && !audioPending;
|
||||
['ccVolume','ccApplyVolume','ccMute'].forEach(id=>{get(id).disabled = !enabled;});
|
||||
}
|
||||
function renderSample() {
|
||||
const values = metrics(isFresh() ? sample : null);
|
||||
page.querySelectorAll('[data-cc-metric]').forEach(el=>{el.textContent = values[el.dataset.ccMetric];});
|
||||
get('ccDeviceName').textContent = isFresh() ? sample.hostname || 'Steam Frame' : 'Steam Frame';
|
||||
const sampledAt = Number.isFinite(sample?.time) && sample.time > 0 && sample.time * 1000 <= 8640000000000000 ? new Date(sample.time * 1000) : null;
|
||||
get('ccSampleState').textContent = isFresh() ? `${sampledAt ? 'Sampled ' + sampledAt.toLocaleTimeString() : 'Received ' + new Date(receivedAt).toLocaleTimeString()} · updates every 30 seconds` :
|
||||
error || (receivedAt !== null ? 'Sample expired · refresh to read current metrics' : 'Connect your Frame to read live metrics');
|
||||
get('ccSampleState').classList.toggle('cc-offline', !isFresh());
|
||||
get('ccStatusLabel').textContent = isFresh() ? 'Connected' : 'No live sample';
|
||||
get('ccStatusDot').classList.toggle('on', isFresh());
|
||||
get('ccStatusNote').textContent = isFresh() ? 'Read-only status over SSH. Browse freely; controls run only when you use them.' :
|
||||
'Settings guides and companion tools remain available. Wake the Frame on the device, then reconnect.';
|
||||
get('ccBuild').textContent = isFresh() ? `SteamOS ${values.os} · build ${values.build}` : 'System information unavailable';
|
||||
const services = isFresh() ? sample.services || {} : {};
|
||||
get('ccServices').innerHTML = [['steamvr','SteamVR'],['desktop','Desktop'],['lepton','Lepton · port 5555'],['rdp','RDP process']].map(([key,label])=>`<span><i class="dot ${services[key] === true ? 'on' : ''}"></i>${label}<b>${typeof services[key] === 'boolean' ? services[key] ? 'Running' : 'Not detected' : 'Unavailable'}</b></span>`).join('');
|
||||
if (sample?.volume && isFresh()) {
|
||||
const pct = Math.round(sample.volume.level * 100);
|
||||
if (!dirty && !controller.busy && !audioPending) get('ccVolume').value = Math.min(100,Math.max(0,pct));
|
||||
get('ccVolumeValue').textContent = `${get('ccVolume').value}%`;
|
||||
get('ccAudioReadback').textContent = `Reported ${pct}% · ${sample.volume.muted ? 'muted' : 'unmuted'}`;
|
||||
get('ccMute').textContent = sample.volume.muted ? 'Unmute' : 'Mute';
|
||||
} else {
|
||||
get('ccAudioReadback').textContent = isFresh() ? 'Audio reading unavailable' : 'Waiting for an audio reading';
|
||||
get('ccVolumeValue').textContent = '—';
|
||||
}
|
||||
audioAvailability();
|
||||
}
|
||||
async function changeAudio(change) {
|
||||
if (audioPending) return;
|
||||
const generation = getContext().generation;
|
||||
audioPending = true;
|
||||
get('ccAudioResult').textContent = 'Applying…';
|
||||
try {
|
||||
const pending = controller.audio(change);
|
||||
audioAvailability();
|
||||
const result = await pending;
|
||||
if (!result || generation !== getContext().generation) return;
|
||||
if ('level' in change) dirty = false;
|
||||
get('ccAudioResult').textContent = `${result.message || 'Audio updated'}. Refreshing the reading…`;
|
||||
await refreshAudio();
|
||||
if (generation !== getContext().generation) return;
|
||||
if (!isFresh() || !sample?.volume) {
|
||||
get('ccAudioResult').textContent = 'Audio command sent; current output could not be confirmed. Reconnect and refresh.';
|
||||
} else {
|
||||
const reported = sample.volume;
|
||||
const matched = (!('level' in change) || Math.round(reported.level * 100) === Math.round(change.level * 100)) &&
|
||||
(!('muted' in change) || reported.muted === change.muted);
|
||||
get('ccAudioResult').textContent = `${matched ? 'Confirmed' : 'Command sent · reported output differs'}: ${Math.round(reported.level * 100)}%, ${reported.muted ? 'muted' : 'unmuted'}.${matched ? '' : ' Refresh to check again.'}`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (generation !== getContext().generation) return;
|
||||
get('ccAudioResult').textContent = `Couldn’t apply audio: ${e.message}`;
|
||||
} finally { audioPending = false; renderSample(); }
|
||||
}
|
||||
get('ccVolume').oninput = () => { dirty = true; get('ccVolumeValue').textContent = `${get('ccVolume').value}%`; };
|
||||
get('ccApplyVolume').onclick = () => changeAudio({level:Number(get('ccVolume').value) / 100});
|
||||
get('ccMute').onclick = () => changeAudio({muted:!sample?.volume?.muted});
|
||||
get('ccRefresh').onclick = async () => {
|
||||
get('ccRefresh').disabled = true;
|
||||
try { await refresh(); } finally { get('ccRefresh').disabled = false; }
|
||||
};
|
||||
filter(); renderSample();
|
||||
// Expire UI readings even when the shared poll is paused in a hidden tab.
|
||||
const timer = setInterval(()=>{if (receivedAt !== null && available && !isFresh()) { available = false; renderSample(); }}, 5000);
|
||||
return {
|
||||
host(value) { macSupported = !value.mobile && value.os === 'macOS'; filter(); },
|
||||
status(value, receipt = Date.now(), read = {valid: true, error: null}) {
|
||||
// A redraw is not a successful read: preserve receipt age and any local
|
||||
// invalidation. Shared validity also carries failures across late mount.
|
||||
const replay = sample === value;
|
||||
receivedAt = replay ? (Number.isFinite(receivedAt) && Number.isFinite(receipt) ? Math.min(receivedAt, receipt) : receivedAt) : receipt;
|
||||
sample = value;
|
||||
if (!read.valid) {
|
||||
available = false; error = `No live sample${read.error?.message ? ' · ' + read.error.message : ''}`;
|
||||
} else if (!replay) { available = true; error = ''; }
|
||||
sampleGeneration = getContext().generation; renderSample();
|
||||
},
|
||||
unavailable(reason) { if (reason?.stale) return; available = false; error = `No live sample${reason?.message ? ' · ' + reason.message : ''}`; renderSample(); },
|
||||
connection(ok) { if (!ok) { available = false; renderSample(); } else audioAvailability(); },
|
||||
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; renderSample(); },
|
||||
destroy() { clearInterval(timer); },
|
||||
};
|
||||
}
|
||||
return {catalog, categories, matches, metrics, createController, mount};
|
||||
});
|
||||
+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)
|
||||
|
||||
+12
-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():
|
||||
@@ -292,6 +302,7 @@ def status():
|
||||
"disk": {"root": disk("/"), "home": disk("/home")},
|
||||
"memory": memory(),
|
||||
"temp": max_temp(),
|
||||
"thermalAlerts": thermal_alerts(),
|
||||
"wifi": wifi(),
|
||||
"ip": ip_addr(),
|
||||
"volume": volume(),
|
||||
|
||||
+82
-19
@@ -18,7 +18,9 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the
|
||||
same project whatever is chosen here.
|
||||
|
||||
Events are identified by a random id made on first run, not by the person or
|
||||
computer, and sent without person profiles or GeoIP. Nothing is sent without a
|
||||
computer, and sent without person profiles or GeoIP. Opens, installs and updates
|
||||
carry a country instead, worked out on this computer from its time zone setting
|
||||
(frame_timezones.py), to see roughly where Frame Control is used. Nothing is sent without a
|
||||
project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source
|
||||
checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or
|
||||
$FRAME_CONTROL_TELEMETRY=0.
|
||||
@@ -43,6 +45,7 @@ from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import frame_host
|
||||
import frame_timezones
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
STATE = frame_host.data_dir('telemetry')
|
||||
@@ -50,6 +53,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
|
||||
@@ -64,10 +68,11 @@ EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth')
|
||||
DEFAULT_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
LEVELS = ('usage', 'compat', 'diagnostics')
|
||||
NOTICE_VERSION = 2 # raise when the notice lists something new: it's shown again (2: country)
|
||||
# Events the page may send through /api/telemetry, and the properties each may carry.
|
||||
PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'},
|
||||
'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}}
|
||||
TABS = {'home', 'games', 'android', 'tools'}
|
||||
TABS = {'home', 'games', 'android', 'tools', 'devices', 'control'}
|
||||
|
||||
_lock = threading.RLock()
|
||||
_send_lock = threading.Lock() # held while sending; consent changes wait for it
|
||||
@@ -104,7 +109,7 @@ def blocked():
|
||||
|
||||
def _defaults():
|
||||
return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False,
|
||||
'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'frames_seen': [], 'compat_sent': []}
|
||||
|
||||
|
||||
@@ -146,12 +151,12 @@ def update_settings(changes):
|
||||
under way finishes first, so nothing leaves after this returns."""
|
||||
with _send_lock, _lock:
|
||||
s = settings()
|
||||
if 'noticeShown' in changes:
|
||||
s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown']
|
||||
if changes.get('noticeShown'):
|
||||
s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION
|
||||
for level in LEVELS:
|
||||
if level in changes:
|
||||
s[level] = bool(changes[level])
|
||||
s['notice_shown'] = True
|
||||
s['notice_shown'] = True # only the notice itself moves notice_version on
|
||||
_save(s)
|
||||
_drop_unwanted(s)
|
||||
if changes.get('compat'):
|
||||
@@ -160,10 +165,16 @@ def update_settings(changes):
|
||||
return state()
|
||||
|
||||
|
||||
def _notice_current(s):
|
||||
"""Whether the notice this person saw lists everything that's sent now. If not, the page
|
||||
shows it again, and what it added waits until it has been seen."""
|
||||
return s['notice_shown'] and s['notice_version'] >= NOTICE_VERSION
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows: the choices, why sending is blocked, and what was sent."""
|
||||
s = settings()
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'],
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': _notice_current(s),
|
||||
'diagnostics': s['diagnostics'],
|
||||
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
|
||||
'sent': list(reversed(_read_lines(SENT)))[:50]}
|
||||
@@ -266,9 +277,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 +305,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 +314,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)
|
||||
@@ -347,35 +382,59 @@ def page_event(body):
|
||||
return {'queued': capture(name, props)}
|
||||
|
||||
|
||||
def country():
|
||||
"""The two-letter country of the computer's time zone setting, or None. Only names in the
|
||||
tz database's table count, so nothing else in the setting can be sent. The app passes the
|
||||
zone from Electron, which knows it on every OS; from a checkout it's read from /etc/localtime."""
|
||||
tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':')
|
||||
if not tz:
|
||||
try:
|
||||
tz = os.path.realpath('/etc/localtime').split('/zoneinfo/', 1)[1]
|
||||
except (OSError, IndexError):
|
||||
return None
|
||||
return frame_timezones.COUNTRY.get(tz)
|
||||
|
||||
|
||||
def app_started():
|
||||
"""Once per server start: first install, an update, and one open a day."""
|
||||
"""Once per server start: first install, an update, and one open a day, with the country
|
||||
(left out when sending if the notice this person saw came before it: flush)."""
|
||||
if blocked():
|
||||
return
|
||||
with _lock:
|
||||
s = settings()
|
||||
version, today = app_version(), time.strftime('%Y-%m-%d')
|
||||
cc = country()
|
||||
where = {'country': cc} if cc else {}
|
||||
if not s['installed_sent']:
|
||||
capture('app_installed')
|
||||
capture('app_installed', where)
|
||||
s['installed_sent'] = True
|
||||
elif s['last_version'] and s['last_version'] != version:
|
||||
capture('app_updated', {'from_version': s['last_version']})
|
||||
capture('app_updated', {'from_version': s['last_version'], **where})
|
||||
if s['last_open_day'] != today:
|
||||
capture('app_opened')
|
||||
capture('app_opened', where)
|
||||
s['last_open_day'] = today
|
||||
s['last_version'] = version
|
||||
_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):
|
||||
@@ -547,10 +606,14 @@ def flush(timeout=20):
|
||||
if blocked() or not settings()['notice_shown']:
|
||||
return 0
|
||||
with _lock:
|
||||
_drop_unwanted(settings())
|
||||
s = settings()
|
||||
_drop_unwanted(s)
|
||||
batch = _read_lines(OUTBOX)[:100]
|
||||
if not batch:
|
||||
return 0
|
||||
if not _notice_current(s): # what a newer notice added stays here until it's been seen
|
||||
for e in batch:
|
||||
e['properties'].pop('country', None)
|
||||
try:
|
||||
post(batch, timeout)
|
||||
except SendError:
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Time zone name -> two-letter country, from the IANA tz database 2026c (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {
|
||||
'Africa/Abidjan': 'CI',
|
||||
'Africa/Accra': 'GH',
|
||||
'Africa/Addis_Ababa': 'ET',
|
||||
'Africa/Algiers': 'DZ',
|
||||
'Africa/Asmara': 'ER',
|
||||
'Africa/Asmera': 'ER',
|
||||
'Africa/Bamako': 'ML',
|
||||
'Africa/Bangui': 'CF',
|
||||
'Africa/Banjul': 'GM',
|
||||
'Africa/Bissau': 'GW',
|
||||
'Africa/Blantyre': 'MW',
|
||||
'Africa/Brazzaville': 'CG',
|
||||
'Africa/Bujumbura': 'BI',
|
||||
'Africa/Cairo': 'EG',
|
||||
'Africa/Casablanca': 'MA',
|
||||
'Africa/Ceuta': 'ES',
|
||||
'Africa/Conakry': 'GN',
|
||||
'Africa/Dakar': 'SN',
|
||||
'Africa/Dar_es_Salaam': 'TZ',
|
||||
'Africa/Djibouti': 'DJ',
|
||||
'Africa/Douala': 'CM',
|
||||
'Africa/El_Aaiun': 'EH',
|
||||
'Africa/Freetown': 'SL',
|
||||
'Africa/Gaborone': 'BW',
|
||||
'Africa/Harare': 'ZW',
|
||||
'Africa/Johannesburg': 'ZA',
|
||||
'Africa/Juba': 'SS',
|
||||
'Africa/Kampala': 'UG',
|
||||
'Africa/Khartoum': 'SD',
|
||||
'Africa/Kigali': 'RW',
|
||||
'Africa/Kinshasa': 'CD',
|
||||
'Africa/Lagos': 'NG',
|
||||
'Africa/Libreville': 'GA',
|
||||
'Africa/Lome': 'TG',
|
||||
'Africa/Luanda': 'AO',
|
||||
'Africa/Lubumbashi': 'CD',
|
||||
'Africa/Lusaka': 'ZM',
|
||||
'Africa/Malabo': 'GQ',
|
||||
'Africa/Maputo': 'MZ',
|
||||
'Africa/Maseru': 'LS',
|
||||
'Africa/Mbabane': 'SZ',
|
||||
'Africa/Mogadishu': 'SO',
|
||||
'Africa/Monrovia': 'LR',
|
||||
'Africa/Nairobi': 'KE',
|
||||
'Africa/Ndjamena': 'TD',
|
||||
'Africa/Niamey': 'NE',
|
||||
'Africa/Nouakchott': 'MR',
|
||||
'Africa/Ouagadougou': 'BF',
|
||||
'Africa/Porto-Novo': 'BJ',
|
||||
'Africa/Sao_Tome': 'ST',
|
||||
'Africa/Timbuktu': 'ML',
|
||||
'Africa/Tripoli': 'LY',
|
||||
'Africa/Tunis': 'TN',
|
||||
'Africa/Windhoek': 'NA',
|
||||
'America/Adak': 'US',
|
||||
'America/Anchorage': 'US',
|
||||
'America/Anguilla': 'AI',
|
||||
'America/Antigua': 'AG',
|
||||
'America/Araguaina': 'BR',
|
||||
'America/Argentina/Buenos_Aires': 'AR',
|
||||
'America/Argentina/Catamarca': 'AR',
|
||||
'America/Argentina/ComodRivadavia': 'AR',
|
||||
'America/Argentina/Cordoba': 'AR',
|
||||
'America/Argentina/Jujuy': 'AR',
|
||||
'America/Argentina/La_Rioja': 'AR',
|
||||
'America/Argentina/Mendoza': 'AR',
|
||||
'America/Argentina/Rio_Gallegos': 'AR',
|
||||
'America/Argentina/Salta': 'AR',
|
||||
'America/Argentina/San_Juan': 'AR',
|
||||
'America/Argentina/San_Luis': 'AR',
|
||||
'America/Argentina/Tucuman': 'AR',
|
||||
'America/Argentina/Ushuaia': 'AR',
|
||||
'America/Aruba': 'AW',
|
||||
'America/Asuncion': 'PY',
|
||||
'America/Atikokan': 'CA',
|
||||
'America/Atka': 'US',
|
||||
'America/Bahia': 'BR',
|
||||
'America/Bahia_Banderas': 'MX',
|
||||
'America/Barbados': 'BB',
|
||||
'America/Belem': 'BR',
|
||||
'America/Belize': 'BZ',
|
||||
'America/Blanc-Sablon': 'CA',
|
||||
'America/Boa_Vista': 'BR',
|
||||
'America/Bogota': 'CO',
|
||||
'America/Boise': 'US',
|
||||
'America/Buenos_Aires': 'AR',
|
||||
'America/Cambridge_Bay': 'CA',
|
||||
'America/Campo_Grande': 'BR',
|
||||
'America/Cancun': 'MX',
|
||||
'America/Caracas': 'VE',
|
||||
'America/Catamarca': 'AR',
|
||||
'America/Cayenne': 'GF',
|
||||
'America/Cayman': 'KY',
|
||||
'America/Chicago': 'US',
|
||||
'America/Chihuahua': 'MX',
|
||||
'America/Ciudad_Juarez': 'MX',
|
||||
'America/Coral_Harbour': 'CA',
|
||||
'America/Cordoba': 'AR',
|
||||
'America/Costa_Rica': 'CR',
|
||||
'America/Coyhaique': 'CL',
|
||||
'America/Creston': 'CA',
|
||||
'America/Cuiaba': 'BR',
|
||||
'America/Curacao': 'CW',
|
||||
'America/Danmarkshavn': 'GL',
|
||||
'America/Dawson': 'CA',
|
||||
'America/Dawson_Creek': 'CA',
|
||||
'America/Denver': 'US',
|
||||
'America/Detroit': 'US',
|
||||
'America/Dominica': 'DM',
|
||||
'America/Edmonton': 'CA',
|
||||
'America/Eirunepe': 'BR',
|
||||
'America/El_Salvador': 'SV',
|
||||
'America/Ensenada': 'MX',
|
||||
'America/Fort_Nelson': 'CA',
|
||||
'America/Fort_Wayne': 'US',
|
||||
'America/Fortaleza': 'BR',
|
||||
'America/Glace_Bay': 'CA',
|
||||
'America/Godthab': 'GL',
|
||||
'America/Goose_Bay': 'CA',
|
||||
'America/Grand_Turk': 'TC',
|
||||
'America/Grenada': 'GD',
|
||||
'America/Guadeloupe': 'GP',
|
||||
'America/Guatemala': 'GT',
|
||||
'America/Guayaquil': 'EC',
|
||||
'America/Guyana': 'GY',
|
||||
'America/Halifax': 'CA',
|
||||
'America/Havana': 'CU',
|
||||
'America/Hermosillo': 'MX',
|
||||
'America/Indiana/Indianapolis': 'US',
|
||||
'America/Indiana/Knox': 'US',
|
||||
'America/Indiana/Marengo': 'US',
|
||||
'America/Indiana/Petersburg': 'US',
|
||||
'America/Indiana/Tell_City': 'US',
|
||||
'America/Indiana/Vevay': 'US',
|
||||
'America/Indiana/Vincennes': 'US',
|
||||
'America/Indiana/Winamac': 'US',
|
||||
'America/Indianapolis': 'US',
|
||||
'America/Inuvik': 'CA',
|
||||
'America/Iqaluit': 'CA',
|
||||
'America/Jamaica': 'JM',
|
||||
'America/Jujuy': 'AR',
|
||||
'America/Juneau': 'US',
|
||||
'America/Kentucky/Louisville': 'US',
|
||||
'America/Kentucky/Monticello': 'US',
|
||||
'America/Knox_IN': 'US',
|
||||
'America/Kralendijk': 'BQ',
|
||||
'America/La_Paz': 'BO',
|
||||
'America/Lima': 'PE',
|
||||
'America/Los_Angeles': 'US',
|
||||
'America/Louisville': 'US',
|
||||
'America/Lower_Princes': 'SX',
|
||||
'America/Maceio': 'BR',
|
||||
'America/Managua': 'NI',
|
||||
'America/Manaus': 'BR',
|
||||
'America/Marigot': 'MF',
|
||||
'America/Martinique': 'MQ',
|
||||
'America/Matamoros': 'MX',
|
||||
'America/Mazatlan': 'MX',
|
||||
'America/Mendoza': 'AR',
|
||||
'America/Menominee': 'US',
|
||||
'America/Merida': 'MX',
|
||||
'America/Metlakatla': 'US',
|
||||
'America/Mexico_City': 'MX',
|
||||
'America/Miquelon': 'PM',
|
||||
'America/Moncton': 'CA',
|
||||
'America/Monterrey': 'MX',
|
||||
'America/Montevideo': 'UY',
|
||||
'America/Montreal': 'CA',
|
||||
'America/Montserrat': 'MS',
|
||||
'America/Nassau': 'BS',
|
||||
'America/New_York': 'US',
|
||||
'America/Nipigon': 'CA',
|
||||
'America/Nome': 'US',
|
||||
'America/Noronha': 'BR',
|
||||
'America/North_Dakota/Beulah': 'US',
|
||||
'America/North_Dakota/Center': 'US',
|
||||
'America/North_Dakota/New_Salem': 'US',
|
||||
'America/Nuuk': 'GL',
|
||||
'America/Ojinaga': 'MX',
|
||||
'America/Panama': 'PA',
|
||||
'America/Pangnirtung': 'CA',
|
||||
'America/Paramaribo': 'SR',
|
||||
'America/Phoenix': 'US',
|
||||
'America/Port-au-Prince': 'HT',
|
||||
'America/Port_of_Spain': 'TT',
|
||||
'America/Porto_Acre': 'BR',
|
||||
'America/Porto_Velho': 'BR',
|
||||
'America/Puerto_Rico': 'PR',
|
||||
'America/Punta_Arenas': 'CL',
|
||||
'America/Rainy_River': 'CA',
|
||||
'America/Rankin_Inlet': 'CA',
|
||||
'America/Recife': 'BR',
|
||||
'America/Regina': 'CA',
|
||||
'America/Resolute': 'CA',
|
||||
'America/Rio_Branco': 'BR',
|
||||
'America/Rosario': 'AR',
|
||||
'America/Santa_Isabel': 'MX',
|
||||
'America/Santarem': 'BR',
|
||||
'America/Santiago': 'CL',
|
||||
'America/Santo_Domingo': 'DO',
|
||||
'America/Sao_Paulo': 'BR',
|
||||
'America/Scoresbysund': 'GL',
|
||||
'America/Shiprock': 'US',
|
||||
'America/Sitka': 'US',
|
||||
'America/St_Barthelemy': 'BL',
|
||||
'America/St_Johns': 'CA',
|
||||
'America/St_Kitts': 'KN',
|
||||
'America/St_Lucia': 'LC',
|
||||
'America/St_Thomas': 'VI',
|
||||
'America/St_Vincent': 'VC',
|
||||
'America/Swift_Current': 'CA',
|
||||
'America/Tegucigalpa': 'HN',
|
||||
'America/Thule': 'GL',
|
||||
'America/Thunder_Bay': 'CA',
|
||||
'America/Tijuana': 'MX',
|
||||
'America/Toronto': 'CA',
|
||||
'America/Tortola': 'VG',
|
||||
'America/Vancouver': 'CA',
|
||||
'America/Virgin': 'VI',
|
||||
'America/Whitehorse': 'CA',
|
||||
'America/Winnipeg': 'CA',
|
||||
'America/Yakutat': 'US',
|
||||
'America/Yellowknife': 'CA',
|
||||
'Antarctica/Casey': 'AQ',
|
||||
'Antarctica/Davis': 'AQ',
|
||||
'Antarctica/DumontDUrville': 'AQ',
|
||||
'Antarctica/Macquarie': 'AU',
|
||||
'Antarctica/Mawson': 'AQ',
|
||||
'Antarctica/McMurdo': 'AQ',
|
||||
'Antarctica/Palmer': 'AQ',
|
||||
'Antarctica/Rothera': 'AQ',
|
||||
'Antarctica/South_Pole': 'AQ',
|
||||
'Antarctica/Syowa': 'AQ',
|
||||
'Antarctica/Troll': 'AQ',
|
||||
'Antarctica/Vostok': 'AQ',
|
||||
'Arctic/Longyearbyen': 'SJ',
|
||||
'Asia/Aden': 'YE',
|
||||
'Asia/Almaty': 'KZ',
|
||||
'Asia/Amman': 'JO',
|
||||
'Asia/Anadyr': 'RU',
|
||||
'Asia/Aqtau': 'KZ',
|
||||
'Asia/Aqtobe': 'KZ',
|
||||
'Asia/Ashgabat': 'TM',
|
||||
'Asia/Ashkhabad': 'TM',
|
||||
'Asia/Atyrau': 'KZ',
|
||||
'Asia/Baghdad': 'IQ',
|
||||
'Asia/Bahrain': 'BH',
|
||||
'Asia/Baku': 'AZ',
|
||||
'Asia/Bangkok': 'TH',
|
||||
'Asia/Barnaul': 'RU',
|
||||
'Asia/Beirut': 'LB',
|
||||
'Asia/Bishkek': 'KG',
|
||||
'Asia/Brunei': 'BN',
|
||||
'Asia/Calcutta': 'IN',
|
||||
'Asia/Chita': 'RU',
|
||||
'Asia/Choibalsan': 'MN',
|
||||
'Asia/Chongqing': 'CN',
|
||||
'Asia/Chungking': 'CN',
|
||||
'Asia/Colombo': 'LK',
|
||||
'Asia/Dacca': 'BD',
|
||||
'Asia/Damascus': 'SY',
|
||||
'Asia/Dhaka': 'BD',
|
||||
'Asia/Dili': 'TL',
|
||||
'Asia/Dubai': 'AE',
|
||||
'Asia/Dushanbe': 'TJ',
|
||||
'Asia/Famagusta': 'CY',
|
||||
'Asia/Gaza': 'PS',
|
||||
'Asia/Harbin': 'CN',
|
||||
'Asia/Hebron': 'PS',
|
||||
'Asia/Ho_Chi_Minh': 'VN',
|
||||
'Asia/Hong_Kong': 'HK',
|
||||
'Asia/Hovd': 'MN',
|
||||
'Asia/Irkutsk': 'RU',
|
||||
'Asia/Istanbul': 'TR',
|
||||
'Asia/Jakarta': 'ID',
|
||||
'Asia/Jayapura': 'ID',
|
||||
'Asia/Jerusalem': 'IL',
|
||||
'Asia/Kabul': 'AF',
|
||||
'Asia/Kamchatka': 'RU',
|
||||
'Asia/Karachi': 'PK',
|
||||
'Asia/Kashgar': 'CN',
|
||||
'Asia/Kathmandu': 'NP',
|
||||
'Asia/Katmandu': 'NP',
|
||||
'Asia/Khandyga': 'RU',
|
||||
'Asia/Kolkata': 'IN',
|
||||
'Asia/Krasnoyarsk': 'RU',
|
||||
'Asia/Kuala_Lumpur': 'MY',
|
||||
'Asia/Kuching': 'MY',
|
||||
'Asia/Kuwait': 'KW',
|
||||
'Asia/Macao': 'MO',
|
||||
'Asia/Macau': 'MO',
|
||||
'Asia/Magadan': 'RU',
|
||||
'Asia/Makassar': 'ID',
|
||||
'Asia/Manila': 'PH',
|
||||
'Asia/Muscat': 'OM',
|
||||
'Asia/Nicosia': 'CY',
|
||||
'Asia/Novokuznetsk': 'RU',
|
||||
'Asia/Novosibirsk': 'RU',
|
||||
'Asia/Omsk': 'RU',
|
||||
'Asia/Oral': 'KZ',
|
||||
'Asia/Phnom_Penh': 'KH',
|
||||
'Asia/Pontianak': 'ID',
|
||||
'Asia/Pyongyang': 'KP',
|
||||
'Asia/Qatar': 'QA',
|
||||
'Asia/Qostanay': 'KZ',
|
||||
'Asia/Qyzylorda': 'KZ',
|
||||
'Asia/Rangoon': 'MM',
|
||||
'Asia/Riyadh': 'SA',
|
||||
'Asia/Saigon': 'VN',
|
||||
'Asia/Sakhalin': 'RU',
|
||||
'Asia/Samarkand': 'UZ',
|
||||
'Asia/Seoul': 'KR',
|
||||
'Asia/Shanghai': 'CN',
|
||||
'Asia/Singapore': 'SG',
|
||||
'Asia/Srednekolymsk': 'RU',
|
||||
'Asia/Taipei': 'TW',
|
||||
'Asia/Tashkent': 'UZ',
|
||||
'Asia/Tbilisi': 'GE',
|
||||
'Asia/Tehran': 'IR',
|
||||
'Asia/Tel_Aviv': 'IL',
|
||||
'Asia/Thimbu': 'BT',
|
||||
'Asia/Thimphu': 'BT',
|
||||
'Asia/Tokyo': 'JP',
|
||||
'Asia/Tomsk': 'RU',
|
||||
'Asia/Ujung_Pandang': 'ID',
|
||||
'Asia/Ulaanbaatar': 'MN',
|
||||
'Asia/Ulan_Bator': 'MN',
|
||||
'Asia/Urumqi': 'CN',
|
||||
'Asia/Ust-Nera': 'RU',
|
||||
'Asia/Vientiane': 'LA',
|
||||
'Asia/Vladivostok': 'RU',
|
||||
'Asia/Yakutsk': 'RU',
|
||||
'Asia/Yangon': 'MM',
|
||||
'Asia/Yekaterinburg': 'RU',
|
||||
'Asia/Yerevan': 'AM',
|
||||
'Atlantic/Azores': 'PT',
|
||||
'Atlantic/Bermuda': 'BM',
|
||||
'Atlantic/Canary': 'ES',
|
||||
'Atlantic/Cape_Verde': 'CV',
|
||||
'Atlantic/Faeroe': 'FO',
|
||||
'Atlantic/Faroe': 'FO',
|
||||
'Atlantic/Jan_Mayen': 'SJ',
|
||||
'Atlantic/Madeira': 'PT',
|
||||
'Atlantic/Reykjavik': 'IS',
|
||||
'Atlantic/South_Georgia': 'GS',
|
||||
'Atlantic/St_Helena': 'SH',
|
||||
'Atlantic/Stanley': 'FK',
|
||||
'Australia/ACT': 'AU',
|
||||
'Australia/Adelaide': 'AU',
|
||||
'Australia/Brisbane': 'AU',
|
||||
'Australia/Broken_Hill': 'AU',
|
||||
'Australia/Canberra': 'AU',
|
||||
'Australia/Currie': 'AU',
|
||||
'Australia/Darwin': 'AU',
|
||||
'Australia/Eucla': 'AU',
|
||||
'Australia/Hobart': 'AU',
|
||||
'Australia/LHI': 'AU',
|
||||
'Australia/Lindeman': 'AU',
|
||||
'Australia/Lord_Howe': 'AU',
|
||||
'Australia/Melbourne': 'AU',
|
||||
'Australia/NSW': 'AU',
|
||||
'Australia/North': 'AU',
|
||||
'Australia/Perth': 'AU',
|
||||
'Australia/Queensland': 'AU',
|
||||
'Australia/South': 'AU',
|
||||
'Australia/Sydney': 'AU',
|
||||
'Australia/Tasmania': 'AU',
|
||||
'Australia/Victoria': 'AU',
|
||||
'Australia/West': 'AU',
|
||||
'Australia/Yancowinna': 'AU',
|
||||
'Brazil/Acre': 'BR',
|
||||
'Brazil/DeNoronha': 'BR',
|
||||
'Brazil/East': 'BR',
|
||||
'Brazil/West': 'BR',
|
||||
'Canada/Atlantic': 'CA',
|
||||
'Canada/Central': 'CA',
|
||||
'Canada/Eastern': 'CA',
|
||||
'Canada/Mountain': 'CA',
|
||||
'Canada/Newfoundland': 'CA',
|
||||
'Canada/Pacific': 'CA',
|
||||
'Canada/Saskatchewan': 'CA',
|
||||
'Canada/Yukon': 'CA',
|
||||
'Chile/Continental': 'CL',
|
||||
'Chile/EasterIsland': 'CL',
|
||||
'Cuba': 'CU',
|
||||
'Egypt': 'EG',
|
||||
'Eire': 'IE',
|
||||
'Europe/Amsterdam': 'NL',
|
||||
'Europe/Andorra': 'AD',
|
||||
'Europe/Astrakhan': 'RU',
|
||||
'Europe/Athens': 'GR',
|
||||
'Europe/Belfast': 'GB',
|
||||
'Europe/Belgrade': 'RS',
|
||||
'Europe/Berlin': 'DE',
|
||||
'Europe/Bratislava': 'SK',
|
||||
'Europe/Brussels': 'BE',
|
||||
'Europe/Bucharest': 'RO',
|
||||
'Europe/Budapest': 'HU',
|
||||
'Europe/Busingen': 'DE',
|
||||
'Europe/Chisinau': 'MD',
|
||||
'Europe/Copenhagen': 'DK',
|
||||
'Europe/Dublin': 'IE',
|
||||
'Europe/Gibraltar': 'GI',
|
||||
'Europe/Guernsey': 'GG',
|
||||
'Europe/Helsinki': 'FI',
|
||||
'Europe/Isle_of_Man': 'IM',
|
||||
'Europe/Istanbul': 'TR',
|
||||
'Europe/Jersey': 'JE',
|
||||
'Europe/Kaliningrad': 'RU',
|
||||
'Europe/Kiev': 'UA',
|
||||
'Europe/Kirov': 'RU',
|
||||
'Europe/Kyiv': 'UA',
|
||||
'Europe/Lisbon': 'PT',
|
||||
'Europe/Ljubljana': 'SI',
|
||||
'Europe/London': 'GB',
|
||||
'Europe/Luxembourg': 'LU',
|
||||
'Europe/Madrid': 'ES',
|
||||
'Europe/Malta': 'MT',
|
||||
'Europe/Mariehamn': 'AX',
|
||||
'Europe/Minsk': 'BY',
|
||||
'Europe/Monaco': 'MC',
|
||||
'Europe/Moscow': 'RU',
|
||||
'Europe/Nicosia': 'CY',
|
||||
'Europe/Oslo': 'NO',
|
||||
'Europe/Paris': 'FR',
|
||||
'Europe/Podgorica': 'ME',
|
||||
'Europe/Prague': 'CZ',
|
||||
'Europe/Riga': 'LV',
|
||||
'Europe/Rome': 'IT',
|
||||
'Europe/Samara': 'RU',
|
||||
'Europe/San_Marino': 'SM',
|
||||
'Europe/Sarajevo': 'BA',
|
||||
'Europe/Saratov': 'RU',
|
||||
'Europe/Simferopol': 'UA',
|
||||
'Europe/Skopje': 'MK',
|
||||
'Europe/Sofia': 'BG',
|
||||
'Europe/Stockholm': 'SE',
|
||||
'Europe/Tallinn': 'EE',
|
||||
'Europe/Tirane': 'AL',
|
||||
'Europe/Tiraspol': 'MD',
|
||||
'Europe/Ulyanovsk': 'RU',
|
||||
'Europe/Uzhgorod': 'UA',
|
||||
'Europe/Vaduz': 'LI',
|
||||
'Europe/Vatican': 'VA',
|
||||
'Europe/Vienna': 'AT',
|
||||
'Europe/Vilnius': 'LT',
|
||||
'Europe/Volgograd': 'RU',
|
||||
'Europe/Warsaw': 'PL',
|
||||
'Europe/Zagreb': 'HR',
|
||||
'Europe/Zaporozhye': 'UA',
|
||||
'Europe/Zurich': 'CH',
|
||||
'GB': 'GB',
|
||||
'GB-Eire': 'GB',
|
||||
'Hongkong': 'HK',
|
||||
'Iceland': 'IS',
|
||||
'Indian/Antananarivo': 'MG',
|
||||
'Indian/Chagos': 'IO',
|
||||
'Indian/Christmas': 'CX',
|
||||
'Indian/Cocos': 'CC',
|
||||
'Indian/Comoro': 'KM',
|
||||
'Indian/Kerguelen': 'TF',
|
||||
'Indian/Mahe': 'SC',
|
||||
'Indian/Maldives': 'MV',
|
||||
'Indian/Mauritius': 'MU',
|
||||
'Indian/Mayotte': 'YT',
|
||||
'Indian/Reunion': 'RE',
|
||||
'Iran': 'IR',
|
||||
'Israel': 'IL',
|
||||
'Jamaica': 'JM',
|
||||
'Japan': 'JP',
|
||||
'Kwajalein': 'MH',
|
||||
'Libya': 'LY',
|
||||
'Mexico/BajaNorte': 'MX',
|
||||
'Mexico/BajaSur': 'MX',
|
||||
'Mexico/General': 'MX',
|
||||
'NZ': 'NZ',
|
||||
'NZ-CHAT': 'NZ',
|
||||
'Navajo': 'US',
|
||||
'PRC': 'CN',
|
||||
'Pacific/Apia': 'WS',
|
||||
'Pacific/Auckland': 'NZ',
|
||||
'Pacific/Bougainville': 'PG',
|
||||
'Pacific/Chatham': 'NZ',
|
||||
'Pacific/Chuuk': 'FM',
|
||||
'Pacific/Easter': 'CL',
|
||||
'Pacific/Efate': 'VU',
|
||||
'Pacific/Enderbury': 'KI',
|
||||
'Pacific/Fakaofo': 'TK',
|
||||
'Pacific/Fiji': 'FJ',
|
||||
'Pacific/Funafuti': 'TV',
|
||||
'Pacific/Galapagos': 'EC',
|
||||
'Pacific/Gambier': 'PF',
|
||||
'Pacific/Guadalcanal': 'SB',
|
||||
'Pacific/Guam': 'GU',
|
||||
'Pacific/Honolulu': 'US',
|
||||
'Pacific/Johnston': 'UM',
|
||||
'Pacific/Kanton': 'KI',
|
||||
'Pacific/Kiritimati': 'KI',
|
||||
'Pacific/Kosrae': 'FM',
|
||||
'Pacific/Kwajalein': 'MH',
|
||||
'Pacific/Majuro': 'MH',
|
||||
'Pacific/Marquesas': 'PF',
|
||||
'Pacific/Midway': 'UM',
|
||||
'Pacific/Nauru': 'NR',
|
||||
'Pacific/Niue': 'NU',
|
||||
'Pacific/Norfolk': 'NF',
|
||||
'Pacific/Noumea': 'NC',
|
||||
'Pacific/Pago_Pago': 'AS',
|
||||
'Pacific/Palau': 'PW',
|
||||
'Pacific/Pitcairn': 'PN',
|
||||
'Pacific/Pohnpei': 'FM',
|
||||
'Pacific/Ponape': 'FM',
|
||||
'Pacific/Port_Moresby': 'PG',
|
||||
'Pacific/Rarotonga': 'CK',
|
||||
'Pacific/Saipan': 'MP',
|
||||
'Pacific/Samoa': 'AS',
|
||||
'Pacific/Tahiti': 'PF',
|
||||
'Pacific/Tarawa': 'KI',
|
||||
'Pacific/Tongatapu': 'TO',
|
||||
'Pacific/Truk': 'FM',
|
||||
'Pacific/Wake': 'UM',
|
||||
'Pacific/Wallis': 'WF',
|
||||
'Pacific/Yap': 'FM',
|
||||
'Poland': 'PL',
|
||||
'Portugal': 'PT',
|
||||
'ROC': 'TW',
|
||||
'ROK': 'KR',
|
||||
'Singapore': 'SG',
|
||||
'Turkey': 'TR',
|
||||
'US/Alaska': 'US',
|
||||
'US/Aleutian': 'US',
|
||||
'US/Arizona': 'US',
|
||||
'US/Central': 'US',
|
||||
'US/East-Indiana': 'US',
|
||||
'US/Eastern': 'US',
|
||||
'US/Hawaii': 'US',
|
||||
'US/Indiana-Starke': 'US',
|
||||
'US/Michigan': 'US',
|
||||
'US/Mountain': 'US',
|
||||
'US/Pacific': 'US',
|
||||
'US/Samoa': 'AS',
|
||||
'W-SU': 'RU',
|
||||
}
|
||||
+155
-33
@@ -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; } }
|
||||
@@ -449,6 +454,12 @@
|
||||
backdrop-filter: none; background: rgb(23, 29, 37); }
|
||||
.brand svg { width: 26px; height: 26px; }
|
||||
header .chip { height: 30px; padding: 0 10px; }
|
||||
/* Let connection text yield before battery and header actions. */
|
||||
.hdr-end { flex: 1; gap: 6px; }
|
||||
.hdr-end .pill, .hdr-end .devsel { min-width: 0; }
|
||||
.hdr-end .pill { padding: 0 8px; gap: 6px; }
|
||||
.hdr-end .pill > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
#reportBtn, #refreshAll { width: 38px; padding: 0; justify-content: center; }
|
||||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
||||
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
|
||||
@@ -475,6 +486,9 @@
|
||||
.and-btns { padding-left: 0; }
|
||||
.banner { flex-wrap: wrap; }
|
||||
.banner .s { white-space: normal; }
|
||||
/* Reserve the message row; connection actions wrap below it. */
|
||||
#offline { align-items: flex-start; }
|
||||
#offline > .grow { flex: 1 0 calc(100% - 36px); overflow-wrap: anywhere; }
|
||||
button { height: 38px; }
|
||||
button.small { height: 32px; }
|
||||
.actions button { height: 46px; }
|
||||
@@ -636,6 +650,7 @@
|
||||
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
|
||||
|
||||
</style>
|
||||
<link rel="stylesheet" href="/control-center.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -654,8 +669,9 @@
|
||||
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
|
||||
<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>
|
||||
<a href="#control" title="Search controls, settings and live metrics (6)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18M8 3v6M16 9v6M10 15v6"/></svg>Control<kbd>6</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 +681,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 +694,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, your country (from the computer's time zone setting), 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>
|
||||
@@ -705,6 +723,42 @@
|
||||
<button class="action small" id="offRetry">Retry now</button>
|
||||
</div>
|
||||
|
||||
<div class="page cc" data-page="control" id="controlCenter">
|
||||
<section class="cc-hero" aria-labelledby="ccTitle">
|
||||
<div><div class="cc-eyebrow">Your Frame, at a glance</div><h1 id="ccTitle">Control Center</h1><p>Everything you can see, change and configure. Find a setting, check your headset, or pick up where you left off.</p></div>
|
||||
<svg class="cc-hero-mark" viewBox="0 0 120 84" fill="none" aria-hidden="true"><path d="M14 28C14 15 106 15 106 28v23c0 18-33 23-39 11h-14c-6 12-39 7-39-11z" stroke="currentColor" stroke-width="1.5"/><path d="M26 32c0-6 25-6 25 0v15c0 11-25 11-25 0zM69 32c0-6 25-6 25 0v15c0 11-25 11-25 0z" stroke="currentColor" stroke-opacity=".4"/><path d="M7 33h7m92 0h7M48 18l4-7h16l4 7" stroke="currentColor" stroke-width="1.5"/><circle cx="60" cy="38" r="2" fill="currentColor"/></svg>
|
||||
</section>
|
||||
<section aria-labelledby="ccDeviceName">
|
||||
<div class="cc-topline"><h2 id="ccDeviceName">Steam Frame</h2><span class="cc-sample" id="ccSampleState" role="status">Connect your Frame to read live metrics</span><button class="small" id="ccRefresh">Refresh metrics</button></div>
|
||||
<div class="cc-summary">
|
||||
<div class="cc-metric"><span>Battery</span><strong data-cc-metric="battery">Unavailable</strong><small data-cc-metric="batteryDetail">Connect to read charge and power</small></div>
|
||||
<div class="cc-metric"><span>Storage free</span><strong data-cc-metric="storage">Unavailable</strong><small data-cc-metric="storageDetail">User storage unavailable</small></div>
|
||||
<div class="cc-metric"><span>Memory used</span><strong data-cc-metric="memory">Unavailable</strong><small data-cc-metric="memoryDetail">Memory sensor unavailable</small></div>
|
||||
<div class="cc-metric"><span>Compositor FPS</span><strong data-cc-metric="fps">Unavailable</strong><small data-cc-metric="fpsDetail">Compositor FPS · not game FPS</small></div>
|
||||
</div>
|
||||
<details class="cc-device-facts"><summary id="ccBuild">System information unavailable</summary>
|
||||
<dl class="cc-readings">
|
||||
<div><dt>Wi-Fi network</dt><dd data-cc-metric="wifi">Unavailable</dd></div><div><dt>Wi-Fi signal</dt><dd data-cc-metric="signal">Unavailable</dd></div>
|
||||
<div><dt>IP address</dt><dd data-cc-metric="ip">Unavailable</dd></div><div><dt>Uptime</dt><dd data-cc-metric="uptime">Unavailable</dd></div>
|
||||
<div><dt>Battery power · signed</dt><dd data-cc-metric="power">Unavailable</dd></div><div><dt>Charger</dt><dd data-cc-metric="charger">Unavailable</dd></div>
|
||||
<div><dt>Battery temperature</dt><dd data-cc-metric="batteryTemp">Unavailable</dd></div><div><dt>Battery health</dt><dd data-cc-metric="batteryHealth">Unavailable</dd></div>
|
||||
<div><dt>Battery estimate</dt><dd data-cc-metric="estimate">Unavailable</dd></div>
|
||||
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.</p>
|
||||
<div class="cc-services" id="ccServices"></div>
|
||||
</details>
|
||||
</section>
|
||||
<div class="cc-workspace">
|
||||
<aside class="cc-sidebar" aria-label="Control Center categories"><p>Browse controls</p><div class="cc-categories" id="ccCategories"></div>
|
||||
<div class="cc-status"><div><i class="dot" id="ccStatusDot"></i><span id="ccStatusLabel">No live sample</span></div><p id="ccStatusNote">Read-only status over SSH. Controls run only when you use them.</p></div>
|
||||
</aside>
|
||||
<div>
|
||||
<div class="cc-search-row"><div class="cc-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></svg><input id="ccSearch" type="search" placeholder="Search settings, controls and guides…" aria-label="Search Control Center" autocomplete="off"><button id="ccClear" type="button" aria-label="Clear search" hidden>×</button></div><span class="cc-results" id="ccResultCount" role="status" aria-live="polite"></span></div>
|
||||
<div class="cc-cards" id="ccCards"></div>
|
||||
<div class="cc-empty" id="ccEmpty" hidden><h2>No matching controls</h2><p>Try a different word or browse all categories.</p><button class="small" id="ccResetFilter">Reset search & category</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page on" data-page="home">
|
||||
<div class="grid-top" id="view">
|
||||
<section class="panel">
|
||||
@@ -1141,8 +1195,9 @@
|
||||
<section class="panel" id="privacy">
|
||||
<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>
|
||||
<span class="sub">Installs, opens, version, operating system, country (from the time zone setting, to see roughly where it's used), tabs used, and whether installs on the Frame
|
||||
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>
|
||||
@@ -1386,7 +1441,7 @@ const HINTS = {
|
||||
flat: "The app panel in use in the headset, flat and still: it doesn't move as the wearer looks around. Pick another panel to watch it. Control lets you tap or click right where you want on it.",
|
||||
};
|
||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", panel: "Desktop", shot: "Screenshot" };
|
||||
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||||
let state = null, stateReceivedAt = null, stateRead = {valid: false, error: null}, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||||
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
|
||||
|
||||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||||
@@ -1424,6 +1479,7 @@ $("bottombar").onclick = () => {
|
||||
const HOST = { computer: "computer" };
|
||||
function applyHostWording(h) {
|
||||
Object.assign(HOST, h);
|
||||
window.frameControlCenter?.host(h);
|
||||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||||
document.body.classList.toggle("mobile", !!h.mobile);
|
||||
$("shotsFolder").hidden = !!h.mobile;
|
||||
@@ -1454,6 +1510,13 @@ let devGen = 0;
|
||||
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
|
||||
async function api(path, body, device = window.connDevice) {
|
||||
const gen = devGen;
|
||||
const checkGeneration = () => {
|
||||
if (gen !== devGen && !HEADSET_FREE.test(path)) {
|
||||
const err = new Error("Switched headsets");
|
||||
err.offline = err.stale = true; // leave the panel for the new headset's answer
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
// Changes name the headset the page was showing, so the server refuses one meant
|
||||
// for a headset it has since switched away from.
|
||||
const dev = device ? { "X-Frame-Device": device } : {};
|
||||
@@ -1462,15 +1525,12 @@ async function api(path, body, device = window.connDevice) {
|
||||
let r;
|
||||
try { r = await fetch(path, opts); }
|
||||
catch {
|
||||
checkGeneration(); // a late transport failure belongs to the old headset too
|
||||
throw new Error(window.frameApp?.platform === "ios" ? "Lost the connection to the Frame. The app reconnects on its own."
|
||||
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
|
||||
}
|
||||
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
||||
if (gen !== devGen && !HEADSET_FREE.test(path)) {
|
||||
const err = new Error("Switched headsets");
|
||||
err.offline = err.stale = true; // failed() leaves the panel alone: the new headset's answer fills it
|
||||
throw err;
|
||||
}
|
||||
checkGeneration();
|
||||
if (!r.ok) {
|
||||
const err = new Error(data.error || `HTTP ${r.status}`);
|
||||
err.offline = !!data.offline;
|
||||
@@ -1509,6 +1569,7 @@ let online = null;
|
||||
function setOnline(ok, why) {
|
||||
const was = online;
|
||||
online = ok;
|
||||
window.frameControlCenter?.connection(ok);
|
||||
$("offline").hidden = ok;
|
||||
if (!ok) {
|
||||
$("offMsg").textContent = why || "Can't reach the Frame";
|
||||
@@ -1540,11 +1601,15 @@ function refresh() {
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
state = await api("/api/status");
|
||||
stateReceivedAt = Date.now();
|
||||
stateRead = {valid: true, error: null};
|
||||
render(state);
|
||||
setConn(true, state.hostname);
|
||||
setOnline(true);
|
||||
} catch (e) {
|
||||
renderPerformance(null);
|
||||
if (!e.stale) stateRead = {valid: false, error: e};
|
||||
window.frameControlCenter?.unavailable(e);
|
||||
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
|
||||
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
|
||||
} finally {
|
||||
@@ -1554,6 +1619,12 @@ function refresh() {
|
||||
})();
|
||||
return refreshing;
|
||||
}
|
||||
async function refreshAfterAudio() {
|
||||
// A request already underway may contain pre-command audio. Preserve normal
|
||||
// poll deduplication, but drain it before requesting the command's readback.
|
||||
if (refreshing) await refreshing;
|
||||
return refresh();
|
||||
}
|
||||
|
||||
// ---- our VR HUD and optional third-party catalogue ----
|
||||
function renderPerformance(s) {
|
||||
@@ -1587,7 +1658,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 +1717,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 +1756,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) {
|
||||
@@ -1727,6 +1802,7 @@ function battery(b, power) {
|
||||
|
||||
function render(s) {
|
||||
renderPerformance(s);
|
||||
window.frameControlCenter?.status(s, stateReceivedAt, stateRead);
|
||||
battery(s.battery, s.power);
|
||||
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
||||
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
||||
@@ -3282,7 +3358,19 @@ async function addSource(body) {
|
||||
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
|
||||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
|
||||
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
|
||||
document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();});
|
||||
function showAndroidTab(tab) {
|
||||
const browse = tab === 'browse';
|
||||
$('appStore').hidden = !browse; $('androidLibrary').hidden = browse;
|
||||
$('repNew').hidden = browse; $('sourcesOpen').hidden = !browse;
|
||||
document.querySelectorAll('[data-store-tab]').forEach(button => {
|
||||
const selected = button.dataset.storeTab === tab;
|
||||
button.classList.toggle('on', selected); button.setAttribute('aria-pressed', String(selected));
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick = () => {
|
||||
showAndroidTab(button.dataset.storeTab);
|
||||
if (button.dataset.storeTab === 'library') loadAndroid();
|
||||
});
|
||||
loadSources().then(()=>searchSources());
|
||||
|
||||
// ---- Android apps (own Lepton instance each) + catalogue ----
|
||||
@@ -3916,8 +4004,8 @@ $("macview").onclick = async e => {
|
||||
|
||||
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
||||
const PAGES = ["home", "games", "android", "tools"];
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", vrTools: "home", comfort: "home", pad: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", androidLibrary: "android", appStore: "android", transfer: "tools", media: "tools", artworkSettings: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
|
||||
let page = "home";
|
||||
function showPage() {
|
||||
const id = location.hash.slice(1);
|
||||
@@ -3927,6 +4015,8 @@ function showPage() {
|
||||
document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page));
|
||||
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
|
||||
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
||||
if (id === "display" || id === "androidLibrary") showAndroidTab("library");
|
||||
else if (id === "appStore") showAndroidTab("browse");
|
||||
const section = !PAGES.includes(id) && id && $(id);
|
||||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
||||
if (page === "tools") { loadMacView(); loadPanelSwitcher(); }
|
||||
@@ -4495,7 +4585,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 +4614,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 +4639,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 +4648,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 +4665,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;
|
||||
@@ -4611,8 +4714,11 @@ function onConnection(s) {
|
||||
if (link.device !== null && devId !== link.device) {
|
||||
log(`Now using ${s.device.name}`, "ok");
|
||||
state = null;
|
||||
stateReceivedAt = null;
|
||||
stateRead = {valid: false, error: null};
|
||||
online = null;
|
||||
devGen++; // answers still on their way are about the other headset
|
||||
window.frameControlCenter?.reset();
|
||||
refreshing = null; // and the next status check must be a new one
|
||||
if (live) toggleLive(false); // the other headset's video or captures
|
||||
clearTimeout(volTimer); volTimer = null; // a volume change still waiting to be sent
|
||||
@@ -4719,7 +4825,7 @@ function setUpActive() {
|
||||
$("connSetup").onclick = setUpActive;
|
||||
|
||||
// ---- Devices tab ----
|
||||
PAGES.push("devices");
|
||||
PAGES.push("devices", "control");
|
||||
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
|
||||
async function loadDevices() {
|
||||
if (!link.live) return;
|
||||
@@ -4746,7 +4852,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 +4867,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 +4878,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 +4932,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>
|
||||
@@ -5023,11 +5133,23 @@ if (window.frameApp && window.frameApp.onUseDevice) window.frameApp.onUseDevice(
|
||||
window.addEventListener("hashchange", () => { if (location.hash === "#devices") loadDevices(); });
|
||||
showPage(); // #devices is a page now
|
||||
api("/api/host").then(h => {
|
||||
Object.assign(HOST, h);
|
||||
window.frameControlCenter?.host(h);
|
||||
if (h.mobile) return;
|
||||
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
|
||||
watchConnection();
|
||||
}).catch(() => watchConnection());
|
||||
</script>
|
||||
<script src="/artwork-settings.js"></script>
|
||||
<script src="/control-center.js"></script>
|
||||
<script>
|
||||
window.frameControlCenter = FrameControlCenter.mount({document, api, refresh, refreshAudio: refreshAfterAudio,
|
||||
getContext: () => ({sample: state, online, generation: devGen, device: window.connDevice}),
|
||||
openConnection: () => $("conn").click(), platform: window.frameApp?.platform || ""});
|
||||
window.frameControlCenter.host(HOST);
|
||||
if (state) window.frameControlCenter.status(state, stateReceivedAt, stateRead);
|
||||
else if (stateRead.error) window.frameControlCenter.unavailable(stateRead.error);
|
||||
if (online === false) window.frameControlCenter.connection(false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+54
-22
@@ -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
|
||||
@@ -683,9 +699,14 @@ def set_volume(body):
|
||||
# Validate everything before touching the headset.
|
||||
level = None
|
||||
if "level" in body:
|
||||
level = float(body["level"])
|
||||
if not 0 <= level <= 1:
|
||||
value = body["level"]
|
||||
if type(value) not in (int, float) or not 0 <= value <= 1:
|
||||
raise Failure("level must be 0..1", 400)
|
||||
level = float(value)
|
||||
if not body or set(body) - {"level", "muted"}:
|
||||
raise Failure("Provide level or muted", 400)
|
||||
if "muted" in body and type(body["muted"]) is not bool:
|
||||
raise Failure("muted must be true or false", 400)
|
||||
if "muted" in body:
|
||||
ssh(f"wpctl set-mute @DEFAULT_AUDIO_SINK@ {1 if body['muted'] else 0}")
|
||||
if level is not None:
|
||||
@@ -2280,6 +2301,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
|
||||
@@ -2394,6 +2419,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||
elif path == "/control-center.js":
|
||||
self.send_bytes((HERE / 'control-center.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||
elif path == "/control-center.css":
|
||||
self.send_bytes((HERE / 'control-center.css').read_bytes(), 'text/css; charset=utf-8')
|
||||
elif path == "/assistant":
|
||||
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
|
||||
self.send_bytes(page.encode(), "text/html; charset=utf-8")
|
||||
@@ -2533,7 +2562,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