diff --git a/app/package.json b/app/package.json index 208ff5e..4fa3791 100644 --- a/app/package.json +++ b/app/package.json @@ -50,6 +50,7 @@ "*.py", "*.html", "*.js", + "*.css", "telemetry.json" ] }, diff --git a/docs/frame-control.md b/docs/frame-control.md index d60af80..6d0d500 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -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 diff --git a/docs/img/control-center/desktop.png b/docs/img/control-center/desktop.png new file mode 100644 index 0000000..7f1bfed Binary files /dev/null and b/docs/img/control-center/desktop.png differ diff --git a/docs/img/control-center/phone.png b/docs/img/control-center/phone.png new file mode 100644 index 0000000..586d577 Binary files /dev/null and b/docs/img/control-center/phone.png differ diff --git a/docs/testing.md b/docs/testing.md index 82a1826..7234cd7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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`. diff --git a/ios/scripts/make_frame_bundle.py b/ios/scripts/make_frame_bundle.py index ef487a5..fdbb4d5 100644 --- a/ios/scripts/make_frame_bundle.py +++ b/ios/scripts/make_frame_bundle.py @@ -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"] diff --git a/tests/control_center.cjs b/tests/control_center.cjs new file mode 100644 index 0000000..f516741 --- /dev/null +++ b/tests/control_center.cjs @@ -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('',hostStart); + vm.runInContext(pageSource.slice(hostStart,hostEnd),context); + const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('',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(''),[]); + 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'); +}); diff --git a/tests/test_control_center.py b/tests/test_control_center.py new file mode 100644 index 0000000..5fdf433 --- /dev/null +++ b/tests/test_control_center.py @@ -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}]) diff --git a/tests/test_server.py b/tests/test_server.py index 6e2a133..7186af1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -79,6 +79,15 @@ class ServerGuards(unittest.TestCase): self.assertEqual(headers["X-Frame-Options"], "DENY") self.assertIn(b" 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; } } diff --git a/ui/control-center.js b/ui/control-center.js new file mode 100644 index 0000000..4a835d1 --- /dev/null +++ b/ui/control-center.js @@ -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 => ``; + 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) => `
GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.
Trip data unavailable
`; + const audio = `${item.description}
${specials[item.special] || ''}${item.guidance ? `Everything you can see, change and configure. Find a setting, check your headset, or pick up where you left off.
Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.
+ +Try a different word or browse all categories.