Compare commits

...
Author SHA1 Message Date
Alex Southwell ba0ac6022b Merge pull request #96 from saphid/feat/frame-control-center
Add comprehensive Steam Frame Control Center
2026-10-14 18:51:07 +11:00
saphid 8b9bd2119c Add searchable Steam Frame Control Center with live metrics 2026-10-14 18:40:59 +11:00
Alex Southwell a8888cb2fc Merge pull request #94 from saphid/feat/telemetry-timezone
Usage statistics: send a country (from the time zone setting), and show the notice again when it changes
2026-10-14 13:22:27 +11:00
saphidandClaude Opus 5.5 1f51cc89d2 Frame Control 0.4.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:14:46 +11:00
saphidandClaude Opus 5.5 bcdb4c3b78 Usage statistics: no country for clock-rule zones, and Johnston Atoll is UM
From review: EET and WET link to Athens and Lisbon but name clock rules,
not Greece or Portugal, so they and the other rule names (CET, EST5EDT, …)
send nothing. Pacific/Johnston has left zone.tab, which made its Honolulu
group look only American; it's set to UM by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:01:46 +11:00
saphidandClaude Opus 5.5 e9b02d61e7 Usage statistics: don't give an old zone name the country of the zone it now links to
From review: tzdata links an old name to any zone with the same clocks,
even in another country (Africa/Asmera, Eritrea, links to Africa/Nairobi).
An old name now gets a country only when every zone sharing its target is
in that country, or when it's a hand-checked old name for a place listed in
zone.tab. The rest (UTC, CET, GMT, …) send nothing. Every zone Node's ICU
reports has an entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:55:18 +11:00
saphidandClaude Opus 5.5 02419ffbd4 Usage statistics: send a country, not the time zone; only the notice itself counts as seen
From review: the time zone setting could hold any text (a TZ like
home/alex/private passed the name check), so the app now looks the zone up
in a table made from the tz database (scripts/timezones.py) and sends only
its two-letter country; anything not in the table sends nothing.

Turning a setting on no longer counts as having seen the new notice, and
the country is taken out at send time until it has been, so it can't
leave early whatever order things happen in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:48:46 +11:00
saphidandClaude Opus 5.5 5db9631571 Usage statistics: send the time zone, and show the notice again when it lists something new
Opens, installs and updates carry the computer's time zone name (from
Electron, which knows it on every OS), so we can see roughly which
countries Frame Control is used in without ever seeing an address.

The first-run notice now has a version. Copies that saw the older notice
see it again, and keep sending their usual statistics, but leave the time
zone out until they've seen the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:41:19 +11:00
21 changed files with 1874 additions and 38 deletions

No files matched your search

+3 -2
View File
@@ -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);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.5",
"version": "0.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.5",
"version": "0.4.6",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.5",
"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
View File
@@ -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

+10 -3
View File
@@ -33,6 +33,13 @@ 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
@@ -44,9 +51,9 @@ computer, exactly as they were sent.
| 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 | |
| `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 |
+23
View File
@@ -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`.
+1 -1
View File
@@ -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"]
+77
View File
@@ -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')
+471
View File
@@ -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');
});
+75
View File
@@ -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}])
+9
View File
@@ -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"):
+69
View File
@@ -159,6 +159,42 @@ 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")
@@ -230,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})
+49
View File
@@ -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; } }
+318
View File
@@ -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};
});
+1
View File
@@ -302,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(),
+42 -12
View File
@@ -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')
@@ -65,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
@@ -105,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': []}
@@ -147,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'):
@@ -161,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]}
@@ -372,20 +382,36 @@ 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)
@@ -580,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:
+547
View File
@@ -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',
}
+105 -12
View File
@@ -454,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); }
@@ -480,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; }
@@ -641,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>
@@ -659,6 +669,7 @@
<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="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>
@@ -683,7 +694,7 @@
</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, whether installs on the Frame worked, and how many headsets you use
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>
@@ -712,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 &amp; category</button></div>
</div>
</div>
</div>
<div class="page on" data-page="home">
<div class="grid-top" id="view">
<section class="panel">
@@ -1148,7 +1195,7 @@
<section class="panel" id="privacy">
<div class="shelf-head"><h2>Privacy &amp; updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
<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>
@@ -1394,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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
@@ -1432,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;
@@ -1462,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 } : {};
@@ -1470,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;
@@ -1517,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";
@@ -1548,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 {
@@ -1562,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) {
@@ -1739,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();
@@ -3294,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 ----
@@ -3928,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);
@@ -3939,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(); }
@@ -4636,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
@@ -4744,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;
@@ -5052,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>
+11 -2
View File
@@ -699,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:
@@ -2414,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")