// PhoneBox web demo: answers the panel's and the settings page's API calls in the browser with made-up data,
// so both run on a static site (GitHub Pages) without a phone or an agent. Load it before app.js / the settings script.
// The panel and the settings page talk through a BroadcastChannel: layout changes and test notifications show up live.
(() => {
'use strict';
const ROOT = new URL('.', document.currentScript.src).href;
const VERSION = document.currentScript.dataset.version || 'demo'; // stamped by tools/pages/build.py from the agent's version
const realFetch = window.fetch.bind(window);
const bc = new BroadcastChannel('phonebox-demo');
const START = Date.now();
const now = () => Date.now();
const rnd = (a, b) => a + Math.random() * (b - a);
const wave = (period, lo, hi, phase = 0, noise = 0) =>
lo + (hi - lo) * (0.5 + 0.5 * Math.sin((2 * Math.PI * (now() - START)) / 1000 / period + phase)) + rnd(-noise, noise);
const SHORTCUTS = [
['terminal', 'Terminal', 'โจ๏ธ', '#4b5563'], ['code', 'VS Code', '๐', '#2f80ed'], ['browser', 'Browser', '๐', '#0ea5e9'],
['files', 'Files', '๐', '#e5b84b'], ['music', 'Spotify', '๐ง', '#1db954'], ['chat', 'Discord', '๐ฌ', '#6366f1'],
['snip', 'Snip', 'โ๏ธ', '#14b8a6'], ['taskmgr', 'Task Manager', '๐', '#22c55e'], ['mute', 'Mute', '๐', '#f59e0b'],
['screenoff', 'Screen Off', '๐', '#8b5cf6'], ['lock', 'Lock', '๐', '#ef4444'], ['settings', 'Settings', 'โ๏ธ', '#94a3b8'],
].map(([id, label, icon, color]) => ({ id, label, icon, color, cmd: id === 'mute' ? 'builtin:mute' : 'demo', args: null, toggle: false }));
const PAGES = ['home', 'dashboard', 'ai', 'work', 'system', 'procs', 'deck', 'music', 'status', 'ambient', 'world', 'daily', 'markets', 'feeds', 'focus', 'calm', 'clock', 'automation', 'planner', 'nightstand', 'weekend', 'focuswork',
'codexbar', 'devops', 'audio-deck', 'stream-deck', 'meeting-deck', 'development-deck', 'music-deck', 'system-deck'];
let catalog = null;
async function loadCatalog() {
if (catalog) return catalog;
const [widgets, templates] = await Promise.all([realFetch(ROOT + 'widgets.json').then((r) => r.json()), realFetch(ROOT + 'templates.json').then((r) => r.json())]);
return (catalog = { cols: 12, rows: 6, widgets, templates });
}
const store = {
get(k, d) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : d; } catch (_) { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (_) {} },
};
async function loadConfig() {
let c = store.get('pb-demo-config', null);
if (c) return c;
const { templates } = await loadCatalog();
const byId = Object.fromEntries(templates.map((t) => [t.id, t]));
const rule = (r) => ({ enabled: true, match: null, value: null, page: null, text: null, level: null, key: null, pushValue: null, whileActive: true, shortcut: null,
priority: 5, forSec: 0, cooldownMin: 0, days: null, during: null, countdownSec: null, op: null, arg: null, ...r });
const action = (id, label, kind, target, value = 0) => ({ id, label, kind, target, argument: null, value, device: null });
// every section the agent serves, with its defaults (Config.cs), so the settings page shows what a real one does
c = {
codexBar: { providers: ['claude', 'codex', 'antigravity'], rotate: true, pageSize: 2, rotateInterval: 8 },
controls: { allowListening: false, allowPower: false, actions: [
action('build', 'Build', 'task', 'npm run build'), action('tests', 'Run tests', 'task', 'npm test'),
action('web', 'Web dev server', 'server', 'npm run dev'), action('api', 'API dev server', 'server', 'dotnet watch'),
action('meet-mic', 'Mute in meeting', 'meeting', 'Ctrl+D'), action('meet-cam', 'Camera', 'meeting', 'Ctrl+E'), action('meet-hand', 'Raise hand', 'meeting', 'Ctrl+Alt+H'),
action('chime', 'Chime', 'sound', 'chime.wav', 75), action('applause', 'Applause', 'sound', 'applause.wav', 75),
], profiles: [{ id: 'meeting', label: 'Meeting', levels: [], actions: [], output: null, input: null, defaults: {} },
{ id: 'music', label: 'Music', levels: [], actions: [], output: null, input: null, defaults: {} }],
spotifyToken: null, spotifyDevice: null, mixerExecutable: null, mixerLibrary: null, spotifyClientId: null, spotifyRefreshToken: null },
deviceSerial: null, adbPath: null,
weather: { city: 'Tallinn', lat: 59.437, lon: 24.745 },
night: { enabled: false, start: '00:30', end: '07:30', brightness: 0.02 },
display: { appearance: null, showSeconds: true, hour12: false, idleReturnSec: 0, vibrate: true, volumeKeys: true, orientation: 'landscape', theme: 'default', accent: '#2dd4bf',
pixelShift: false, idleDimMin: 0,
artTint: true, autoBrightness: false, nightWhenDark: false, proximityWake: true, pin: false, homeScreen: false, keepAwake: true, followComputerLock: false,
homePage: null, switchCountdownSec: 3, touchGraceSec: 20, minDwellSec: 15, holdMaxMin: 30 },
lan: { enabled: true, port: 8766, token: 'demo', webhookToken: 'demo-webhook-key' },
updates: { auto: true, repo: 'ersinkoc/phonebox' },
claude: { phoneApprovals: true, timeoutSec: 90, onlyWhenAway: false },
pages: PAGES.filter((id) => byId[id]).map((id) => {
const t = byId[id], seen = {};
return { appearance: null, id, title: t.title, icon: t.icon, enabled: true, idleSec: null, widgets: t.widgets.map((w) => {
const n = (seen[w.type] = (seen[w.type] || 0) + 1);
const control = w.type === 'docker' || /^obs(-|$)/.test(w.type) ? { control: true } : {};
return { ...w, id: n === 1 ? w.type : `${w.type}-${n}`, options: { ...(w.options || {}), ...control } };
}) };
}),
shortcuts: SHORTCUTS,
rules: [
rule({ id: 'claude-needs-me', name: 'Claude needs me', when: 'session-waiting', then: 'goto', page: 'ai', priority: 8 }),
rule({ id: 'music', name: 'Music', when: 'media-playing', match: 'Spotify', then: 'goto', page: 'music', priority: 2 }),
rule({ id: 'in-a-meeting', name: 'In a meeting', when: 'calendar-event', then: 'push', key: 'onair', pushValue: '{detail}' }),
rule({ id: 'review-requested', name: 'Review requested', when: 'review-requested', then: 'notify', text: '{detail}', whileActive: false, days: 'mon,tue,wed,thu,fri', during: '09:00-18:00' }),
rule({ id: 'evenings', name: 'Evenings', when: 'time', match: '22:00-07:00', then: 'goto', page: 'calm', priority: 2 }),
],
charge: { enabled: true, min: 50, max: 80, method: 'ha', entity: 'switch.phone_charger', onShortcut: null, offShortcut: null },
phones: [], calendars: [],
homeAssistant: { url: null, token: null },
obs: { enabled: true, url: 'ws://127.0.0.1:4455', password: null },
};
c = JSON.parse(JSON.stringify(c, (_, v) => v === null ? undefined : v)); // the agent leaves unset (null) settings out
store.set('pb-demo-config', c);
return c;
}
async function config() {
const c = await loadConfig();
muteKeys = (c.shortcuts || []).filter((x) => x.cmd === 'builtin:mute').map((x) => x.id);
return c;
}
const cfgVersion = () => store.get('pb-demo-version', 1);
// every widget of the layout, Smart Stack children included (AgentConfig.Widgets)
const widgets = (c) => (c.pages || []).flatMap((p) => p.widgets || []).flatMap((w) => [w, ...(w.type === 'stack' ? (w.options?.widgets || []).filter((x) => x.enabled !== false) : [])]);
const allows = (c, test) => widgets(c).some((w) => test(w.type) && w.options?.control === true);
const isObs = (type) => type === 'obs' || type.startsWith('obs-');
// What the agent sends to a phone (AgentConfig.ToPublic): no commands, keys, tokens or what a rule matches on.
function publicConfig(c) {
const options = (w) => w.type === 'command' ? Object.fromEntries(Object.entries(w.options || {}).filter(([k]) => k === 'title' || k === 'interval')) : w.options ?? null;
return {
weather: c.weather, codexBar: c.codexBar, night: c.night, display: c.display,
pages: (c.pages || []).map((p) => ({ id: p.id, title: p.title, icon: p.icon, enabled: p.enabled !== false, idleSec: p.idleSec ?? null, appearance: p.appearance ?? null,
widgets: (p.widgets || []).map((w) => ({ id: w.id, type: w.type, x: w.x, y: w.y, w: w.w, h: w.h, appearance: w.appearance ?? null, options: options(w) })) })),
shortcuts: (c.shortcuts || []).map(({ id, label, icon, color, toggle, cmd }) => ({ id, label, icon, color, toggle: !!toggle || cmd === 'builtin:mute' })),
rules: (c.rules || []).map(({ id, name, enabled, when }) => ({ id, name, enabled: enabled !== false, when })),
charge: { enabled: !!c.charge?.enabled, min: c.charge?.min ?? 50, max: c.charge?.max ?? 80 },
};
}
// ---- webhook-style events (notifications, page switches) shared between tabs
const events = [];
let seq = 0;
function emit(kind, data, broadcast = true) {
const e = { seq: ++seq, kind, data, at: now() };
events.push(e);
if (events.length > 50) events.shift();
if (broadcast) bc.postMessage({ type: 'event', kind, data });
return e;
}
const push = {
temperature: { data: 21.5, at: now() - 180e3, expiresAt: null },
build: { data: { value: 'passed', sub: 'main ยท 3m 12s' }, at: now() - 240e3, expiresAt: null },
deploy: { data: { value: 'running', sub: 'production' }, at: now() - 60e3, expiresAt: null },
progress: { data: { value: 64 }, at: now() - 30e3, expiresAt: null },
todo: { data: { items: [{ label: 'Try the layout editor', value: 'now', level: 'warn' }, { label: 'Widgets', value: 'done', level: 'ok' },
{ label: 'Webhooks', value: 'done', level: 'ok' }, 'Water the plants'] }, at: now() - 600e3, expiresAt: null },
};
let pushVersion = 1;
let rulesPaused = 0;
let approvals = [];
const SAMPLE_PROMPTS = [
{ tool: 'Run a command', detail: 'git push origin main --force-with-lease', description: 'Push the rebased branch', project: 'api-gateway' },
{ tool: 'Edit a file', detail: 'src/routes/billing.ts\n\n export const limit = rateLimit({ windowMs: 60_000, max: 120 })', description: '', project: 'docs-site' },
{ tool: 'Fetch a web page', detail: 'https://docs.github.com/en/rest/pulls', description: '', project: 'blog-engine' },
];
function ask(sample, broadcast = true) {
const s = sample || SAMPLE_PROMPTS[Math.floor(Math.random() * SAMPLE_PROMPTS.length)];
const a = { id: Math.random().toString(16).slice(2, 10), source: 'Claude Code', session: 'demo', createdAt: now(), expiresAt: now() + 90e3, ...s };
approvals.push(a);
if (broadcast) bc.postMessage({ type: 'ask', sample: s, id: a.id });
emit('approval', { id: a.id }, false);
return a;
}
bc.onmessage = (m) => {
const d = m.data || {};
if (d.type === 'ask') { const a = ask(d.sample, false); a.id = d.id; }
if (d.type === 'decided') { approvals = approvals.filter((x) => x.id !== d.id); emit('approval-done', { id: d.id }, false); }
if (d.type === 'event') emit(d.kind, d.data, false);
if (d.type === 'push') setPush(d.key, d.value, d.ttl);
};
function setPush(key, value, ttl = null) { push[key] = { data: value, at: now(), expiresAt: ttl > 0 ? now() + ttl * 1000 : null }; pushVersion++; }
const livePush = () => { for (const k of Object.keys(push)) if (push[k].expiresAt && push[k].expiresAt <= now()) { delete push[k]; pushVersion++; } return push; };
// ---- made-up computer
const audio = { volume: 42, muted: false, canControl: true };
const TRACKS = [['Midnight Circuit', 'Neon Harbor', 'Low Tide Lights', 214000], ['Paper Lanterns', 'Neon Harbor', 'Low Tide Lights', 187000], ['Slow Orbit', 'Hollow Pines', 'Northern Rooms', 243000]];
const player = { track: 0, playing: true, posMs: 61000, at: now() };
const position = () => { const dur = TRACKS[player.track][3]; return player.playing ? (player.posMs + now() - player.at) % dur : Math.min(player.posMs, dur); };
function mediaAction(action, ms) {
const pos = position();
if (action === 'playpause' || action === 'play' || action === 'pause') { player.posMs = pos; player.at = now(); player.playing = action === 'playpause' ? !player.playing : action === 'play'; }
else if (action === 'next' || action === 'prev') { player.track = (player.track + (action === 'next' ? 1 : TRACKS.length - 1)) % TRACKS.length; player.posMs = 0; player.at = now(); }
else if (action === 'seek' && ms != null && ms >= 0) { player.posMs = ms; player.at = now(); }
else return false;
return true;
}
function state(since) {
const [title, artist, album, dur] = TRACKS[player.track];
return {
update: null, ts: now(), host: 'DESK-PC', cfgVersion: cfgVersion(), pushVersion, evSeq: seq, rules: ['music'], rulesPaused: rulesPaused > now() ? rulesPaused : null,
events: since == null ? [] : events.filter((e) => e.seq > since),
pc: {
cpu: Math.max(1, wave(37, 8, 46, 0, 6)), cpuName: 'AMD Ryzen 9 7950X 16-Core Processor', cpuTemp: wave(60, 52, 68),
ramUsedGb: wave(120, 21, 24), ramTotalGb: 64, netDownBps: Math.max(0, wave(23, 0.2e6, 9e6, 0, 1e6)), netUpBps: Math.max(0, wave(17, 40e3, 600e3, 1, 50e3)),
uptimeSec: 3 * 86400 + 5 * 3600 + Math.floor((now() - START) / 1000),
disks: [{ name: 'C:', usedGb: 612, totalGb: 1000 }, { name: 'D:', usedGb: 1450, totalGb: 2000 }, { name: 'E:', usedGb: 310, totalGb: 4000 }],
...(() => {
const gpus = [
{ name: 'NVIDIA GeForce RTX 4080', util: Math.max(0, wave(29, 4, 38, 2, 4)), temp: wave(50, 46, 61), power: wave(29, 38, 140, 2),
powerLimit: 320, clock: 2520, fan: 34, vramUsed: 5.8, vramTotal: 16, vendor: 'nvidia', source: 'nvidia-smi+windows', integrated: false,
memClock: 11201, sharedUsed: 0.3, sharedTotal: 31.9, powerPct: null, fanRpm: 1180, tempMax: 90, driver: '596.36', hotSpot: null, memTemp: null },
{ name: 'AMD Radeon(TM) Graphics', util: Math.max(0, wave(41, 0, 6, 0, 1)), temp: wave(60, 50, 58), power: null, powerLimit: null,
vramUsed: 0.1, vramTotal: 0.5, fan: null, clock: null, vendor: 'amd', source: 'windows', integrated: true, memClock: 2400,
sharedUsed: 0.2, sharedTotal: 31.9, powerPct: wave(37, 6, 14), fanRpm: null, tempMax: null, driver: '32.0.21045.11001', hotSpot: null, memTemp: null },
];
return { gpu: gpus[0], gpus };
})(),
},
media: { app: 'Spotify', title, artist, album, durMs: dur, posMs: position(), playing: player.playing, canSeek: true, artId: player.track + 1 },
audio: { ...audio },
ping: { host: '1.1.1.1', ms: wave(13, 9, 16), avgMs: 12, lossPct: 0 },
// the microphone is "in use" for 20 s of every minute, to show the On air widget
privacy: (() => { const on = (now() / 1000) % 60 < 20; return { mic: on, camera: false, micApps: on ? ['Zoom'] : [], cameraApps: [] }; })(),
obs: { streaming: !!obs.streamingSince, recording: !!obs.recordingSince, recordPaused: obs.paused },
toggles: { ...toggles, ...Object.fromEntries(muteKeys.map((id) => [id, audio.muted])) },
};
}
const toggles = {};
let muteKeys = ['mute']; // ids of the shortcuts whose command is builtin:mute; their state is the computer's
// made-up OBS: streaming for a while, buttons and scenes work
const obs = { streamingSince: now() - 754e3, recordingSince: 0, paused: false, scene: 'Desk', mic: false };
function obsState() {
return { enabled: true, control: true, snapshot: {
connected: true, error: null, streaming: !!obs.streamingSince, streamMs: obs.streamingSince ? now() - obs.streamingSince : 0,
recording: !!obs.recordingSince, recordPaused: obs.paused, recordMs: obs.recordingSince ? now() - obs.recordingSince : 0,
scene: obs.scene, scenes: ['Desk', 'Camera', 'Screen share', 'BRB'], micMuted: obs.mic, micName: 'Mic/Aux' } };
}
// made-up Home Assistant entities; switching flips them
const ha = {
'light.desk': { name: 'Desk lamp', state: 'on' }, 'light.ceiling': { name: 'Ceiling', state: 'off' },
'switch.coffee': { name: 'Coffee machine', state: 'off' }, 'sensor.office_temperature': { name: 'Office', state: '21.4', unit: 'ยฐC', deviceClass: 'temperature' },
'sensor.office_humidity': { name: 'Humidity', state: '43', unit: '%', deviceClass: 'humidity' }, 'binary_sensor.front_door': { name: 'Front door', state: 'off', deviceClass: 'door' },
'lock.front_door': { name: 'Door lock', state: 'locked' }, 'scene.movie': { name: 'Movie night', state: 'scening' },
};
function haStates(ids) {
const list = (ids.length ? ids : Object.keys(ha)).filter((id) => ha[id]).map((id) => ({ id, unit: null, deviceClass: null, icon: null, changed: now() - 6e5, ...ha[id] }));
return { configured: true, error: null, entities: list };
}
function haToggle(id) {
const e = ha[id];
if (!e) return;
if (id.startsWith('lock.')) e.state = e.state === 'locked' ? 'unlocked' : 'locked';
else if (/^(light|switch)\./.test(id)) e.state = e.state === 'on' ? 'off' : 'on';
}
// made-up containers; start / stop / restart change them
const containers = [
{ id: 'a1b2c3d4e5f6', name: 'shop-web-1', image: 'nginx:1.27', project: 'shop', state: 'running', status: 'Up 3 hours', health: null, cpu: 0.4, mem: '12MiB' },
{ id: 'b2c3d4e5f6a1', name: 'shop-api-1', image: 'node:22-alpine', project: 'shop', state: 'running', status: 'Up 3 hours (healthy)', health: 'healthy', cpu: 6.2, mem: '148MiB' },
{ id: 'c3d4e5f6a1b2', name: 'shop-db-1', image: 'postgres:17', project: 'shop', state: 'running', status: 'Up 3 hours', health: null, cpu: 1.1, mem: '86MiB' },
{ id: 'd4e5f6a1b2c3', name: 'shop-worker-1', image: 'node:22-alpine', project: 'shop', state: 'running', status: 'Up 12 minutes (unhealthy)', health: 'unhealthy', cpu: 31.5, mem: '402MiB' },
{ id: 'e5f6a1b2c3d4', name: 'redis', image: 'redis:7', project: null, state: 'exited', status: 'Exited (0) 2 days ago', health: null, cpu: null, mem: null },
];
function docker() {
const live = containers.map((c) => c.state === 'running' && c.cpu != null ? { ...c, cpu: Math.max(0.1, c.cpu * (0.8 + Math.random() * 0.4)) } : c);
return { snapshot: { available: true, error: null, containers: live, updated: now() } };
}
// made-up calendar around the current time: one meeting is on now
function calendar() {
const day = new Date(new Date().toDateString()).getTime(), H = 3600e3, M = 60e3, t = now();
const ev = (title, start, mins, cal, color, location, allDay = false) => ({ title, start, end: start + mins * M, allDay, location, calendar: cal, color });
const work = '#60a5fa', home = '#34d399';
const utcDay = (n) => { const d = new Date(); return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate() + n); };
return { updated: t - 4 * M, errors: [], events: [
ev('Design review', t - 20 * M, 45, 'Work', work, 'Room 4'),
ev('1:1 with Sam', t + 90 * M, 30, 'Work', work, null),
ev('Gym', day + 18.5 * H, 60, 'Personal', home, null),
ev('Release day', utcDay(1), 24 * 60, 'Work', work, null, true), // all-day: UTC midnight of the date, as the agent sends it
ev('Sprint planning', day + 33.5 * H, 60, 'Work', work, 'Big room'),
ev('Lunch with Alex', day + 36 * H, 60, 'Personal', home, 'Noodle bar'),
ev('Dentist', day + 2 * 24 * H + 10 * H, 45, 'Personal', home, null),
].sort((a, b) => a.start - b.start) };
}
function quotas() {
const n = now();
return { providers: [
{ id: 'claude', name: 'Claude', plan: 'Max 5x', updatedAt: n - 120e3, extra: '', error: null, codex: null, source: 'local', limits: [
{ label: '5-hour session', percent: 34, resetsAt: n + 2.6 * 3600e3, windowSec: 18000 },
{ label: 'Weekly', percent: 52, resetsAt: n + 3.2 * 86400e3, windowSec: 604800 },
{ label: 'Weekly ยท Fable', percent: 9, resetsAt: n + 3.2 * 86400e3, windowSec: 604800 }] },
{ id: 'codex', name: 'Codex', plan: 'Pro', updatedAt: n - 300e3, extra: 'Credits: 1,250', error: null, source: 'local',
codex: { model: 'Demo coding model', effort: 'high', totalTokens: 187000, totalInput: 150000, totalOutput: 37000, totalCached: 90000, totalReasoning: 12000, lastInput: 42000, contextWindow: 256000 }, limits: [
{ label: '5-hour session', percent: 12, resetsAt: n + 4.1 * 3600e3, windowSec: 18000 },
{ label: 'Weekly', percent: 27, resetsAt: n + 5.5 * 86400e3, windowSec: 604800 }] },
] };
}
function work() {
const n = now();
const s = (i, tool, name, status, mins, waitingFor = null) => ({ id: `${tool}:${i}`, tool, name, project: name, status, waitingFor, since: n - mins * 60e3, startedAt: n - 3 * 3600e3,
pid: 4000 + i * 211, outcome: null, model: null, effort: null, totalTokens: null, inputTokens: null, outputTokens: null, cachedInputTokens: null, reasoningTokens: null,
lastInputTokens: null, contextWindow: null, lastTool: null, turnStartedAt: null,
...(tool === 'codex' ? { model: 'Demo coding model', effort: 'high', totalTokens: 187000, inputTokens: 150000, outputTokens: 37000, cachedInputTokens: 90000, reasoningTokens: 12000, lastInputTokens: 42000, contextWindow: 256000, lastTool: 'read', turnStartedAt: n - 45000 } : {}) });
const pr = (repo, number, title, ci = null, review = null, draft = false, author = null) => ({ repo, number, title, ci, review, draft, author, updated: n - (number % 9 + 1) * 3600e3 });
return {
sessions: [s(1, 'claude', 'api-gateway', 'busy', 4), s(2, 'claude', 'docs-site', 'waiting', 2, 'approve a file edit'), s(3, 'codex', 'mobile-app', 'busy', 11),
s(4, 'claude', 'infra', 'shell', 1), s(5, 'claude', 'blog-engine', 'idle', 38), s(6, 'codex', 'data-pipeline', 'idle', 74), s(7, 'claude', 'design-system', 'idle', 125)],
github: { user: 'octocat', notifications: 7, error: null, updatedAt: n - 90e3,
reviewRequests: [pr('acme/api', 412, 'Rate limiter for public endpoints', null, null, false, 'hubot'), pr('acme/web', 1288, 'Dark mode for the dashboard', null, null, false, 'monalisa'),
pr('acme/infra', 77, 'Move staging to a new region', null, null, false, 'hubot')],
myPrs: [pr('acme/api', 409, 'Cache user sessions in Redis', 'SUCCESS', 'APPROVED'), pr('acme/web', 1281, 'Faster image uploads', 'PENDING'),
pr('acme/cli', 53, 'New "deploy" command', 'FAILURE', 'CHANGES_REQUESTED'), pr('acme/docs', 210, 'Getting started rewrite', null, null, true)] },
};
}
const PROCS = [['code', 'Visual Studio Code', 9, 6.2, 2100], ['chrome', 'Google Chrome', 14, 4.8, 3400], ['node', 'Node.js', 6, 3.9, 1200],
['claude', 'Claude', 7, 2.7, 1800], ['docker', 'Docker Desktop', 4, 2.1, 2600], ['spotify', 'Spotify', 5, 1.4, 420], ['discord', 'Discord', 6, 1.1, 610],
['explorer', 'Windows Explorer', 3, 0.9, 380], ['obs64', 'OBS Studio', 1, 0.8, 520], ['slack', 'Slack', 5, 0.6, 700], ['steam', 'Steam', 3, 0.4, 340],
['onedrive', 'Microsoft OneDrive', 1, 0.3, 120], ['python', 'Python', 2, 0.3, 160], ['figma', 'Figma', 3, 0.2, 640]];
const procs = () => ({ total: 312, groupCount: 141, groups: PROCS.map(([name, title, count, cpu, memMb]) =>
({ name, title, count, cpu: Math.round(cpu * rnd(0.8, 1.2) * 10) / 10, memMb, locked: name === 'explorer', hasIcon: false })) });
function procDetail(name) {
const p = PROCS.find((x) => x[0] === name) || PROCS[0];
return { name: p[0], title: p[1], locked: p[0] === 'explorer', instances: Array.from({ length: p[2] }, (_, i) => ({
pid: 4100 + i * 37, cpu: Math.round((p[3] / p[2]) * 10) / 10, memMb: Math.round(p[4] / p[2]), startedAt: now() - (i + 1) * 1800e3,
locked: p[0] === 'explorer', session: p[0] === 'claude' && i < 3 ? ['api-gateway', 'docs-site', 'infra'][i] : null, window: null, args: i ? '--type=renderer' : null })) };
}
const ART = 'data:image/svg+xml,' + encodeURIComponent('');
// ---- router
const json = (o, status = 200, headers = {}) => new Response(JSON.stringify(o), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers } });
const parse = (body) => { if (body == null || body === '') return null; try { return JSON.parse(body); } catch (_) { return String(body); } };
// Control Deck preview data only. Native integrations live in the desktop agent.
const deck = {
platform: 'windows', hardwareLoading: false, mediaError: null, ducking: false, sleep: [],
audio: { available: true, error: null, defaults:{'output:0':'speaker','output:1':'speaker','output:2':'speaker','input:0':'mic','input:1':'mic','input:2':'mic'}, devices: [
{ id: 'speaker', name: 'Desk speakers', direction: 'output', default: true, volume: 42, muted: false, peak: .45, channels: 2, canControl: true, channelVolumes:[42,42] },
{ id: 'headphones', name: 'Headphones', direction: 'output', default: false, volume: 60, muted: false, peak: .2, channels: 2, canControl: true, channelVolumes:[60,60] },
{ id: 'mic', name: 'Desk microphone', direction: 'input', default: true, volume: 75, muted: true, peak: .15, channels: 1, canControl: true, channelVolumes: null },
], sessions: [
{ id: 'spotify', name: 'Spotify', pid: 101, deviceId: 'speaker', volume: 65, muted: false, peak: .55 },
{ id: 'browser', name: 'Browser', pid: 102, deviceId: 'speaker', volume: 30, muted: false, peak: .25 },
] },
media: [
{ id: 'spotify', name: 'Spotify', title: 'Midnight Circuit', playing: true, position: 61000, duration: 214000, canSeek: true, canShuffle: true, canRepeat: true, shuffle: false, repeat: 'None' },
{ id: 'browser', name: 'Browser', title: 'Engineering session', playing: false, position: 10000, duration: 600000, canSeek: true, canShuffle: false, canRepeat: false, shuffle: false, repeat: 'None' },
],
monitors: [{ id: 'display', name: 'Desk display', brightness: 60, width: 2560, height: 1440, hz: 144 }],
powerPlans: [{ id: 'balanced', name: 'Balanced', kind: 'power-plan', state: 'Active', available: true }, { id: 'performance', name: 'High performance', kind: 'power-plan', state: 'Available', available: true },
{ id: 'saver', name: 'Power saver', kind: 'power-plan', state: 'Available', available: true }], midi: [],
};
const actionState = {}; // what a configured control last did: "Running", "Finished", "Stopped"โฆ
async function deckState() {
const c = await config(), controls = c.controls || {};
// the output device follows the computer's volume, and the first player is the one in /api/state
Object.assign(deck.audio.devices[0], { volume: audio.volume, muted: audio.muted });
Object.assign(deck.media[0], { title: TRACKS[player.track][0], playing: player.playing, position: position(), duration: TRACKS[player.track][3] });
return { ...deck, allowListening: !!controls.allowListening, allowPower: !!controls.allowPower,
// ControlDeck.Describe / DescribeShortcut: ids, labels and kinds only
actions: (controls.actions || []).filter((a) => a.enabled !== false).map((a) => ({ id: a.id, name: a.label, kind: a.kind, state: actionState[a.id] ?? (/^(app|project|shortcut|focus)$/.test(a.kind) ? (c.shortcuts.some((x) => x.id === a.target) ? 'Ready' : 'Shortcut is missing') : null),
available: !/^(app|project|shortcut|focus)$/.test(a.kind) || c.shortcuts.some((x) => x.id === a.target) })),
shortcuts: (c.shortcuts || []).map((x) => ({ id: x.id, name: x.label, kind: { 'builtin:keys': 'keys', 'builtin:text': 'text', 'builtin:macro': 'macro' }[x.cmd] || (String(x.cmd).startsWith('builtin:') ? 'shortcut' : 'app'), state: null, available: true })),
profiles: (controls.profiles || []).map((x) => ({ id: x.id, name: x.label, kind: 'profile', state: null, available: true })) };
}
const deckLeases = new Map();
// one made-up OBS behind both the OBS widget (/api/obs) and the Control Deck's OBS widgets (/api/controls/obs)
const deckObsExtra = { inputs: [{ id: 'Desktop Audio', name: 'Desktop Audio', volume: 70, muted: false }, { id: 'Mic/Aux', name: 'Mic/Aux', volume: 80, muted: false }],
sources: { Desk: [{ id: 1, name: 'Webcam', enabled: true }, { id: 2, name: 'Screen capture', enabled: true }, { id: 3, name: 'Lower third', enabled: false }],
Camera: [{ id: 4, name: 'Webcam', enabled: true }], 'Screen share': [{ id: 5, name: 'Screen capture', enabled: true }], BRB: [{ id: 6, name: 'Be right back card', enabled: true }] },
transitions: [{ transitionName: 'Fade', transitionKind: 'fade_transition' }, { transitionName: 'Cut', transitionKind: 'cut_transition' }], transition: 'Fade', duration: 300, replayActive: false, cameraActive: false };
function deckObsState() {
const o = obsState().snapshot, x = deckObsExtra;
x.inputs[1].muted = obs.mic; // the microphone the OBS widget mutes
return { connected: true, scene: o.scene, scenes: o.scenes, inputs: x.inputs, sources: x.sources[o.scene] || [], streaming: o.streaming, recording: o.recording, recordPaused: o.recordPaused,
streamMs: o.streamMs, recordMs: o.recordMs, transitions: x.transitions, transition: x.transition, duration: x.duration, replayAvailable: true, replayActive: x.replayActive, cameraAvailable: true, cameraActive: x.cameraActive };
}
function obsAct(action, scene) {
if (action === 'stream') obs.streamingSince = obs.streamingSince ? 0 : now();
else if (action === 'record') { obs.recordingSince = obs.recordingSince ? 0 : now(); obs.paused = false; }
else if (action === 'pause') { if (obs.recordingSince) obs.paused = !obs.paused; }
else if (action === 'mic') obs.mic = !obs.mic;
else if (action === 'scene') { if (obsState().snapshot.scenes.includes(scene)) obs.scene = scene; }
else return false;
return true;
}
const spotifyId = (i) => 'spotify:track:' + String(i).padStart(22, '0');
const spotifyTrack = (i) => ({ id: spotifyId(i), name: TRACKS[i][0], artist: TRACKS[i][1] });
const deckSpotify = () => ({ connected: true, current: spotifyTrack(player.track), playing: player.playing,
queue: TRACKS.map((_, i) => i).filter((i) => i !== player.track).map(spotifyTrack) });
async function controlCommand(kind, q) {
const id = q.get('id'), op = q.get('op'), value = q.has('value') ? +q.get('value') : null;
const c = await config();
if (kind === 'device' || kind === 'session') {
const item = (kind === 'device' ? deck.audio.devices : deck.audio.sessions).find(x => x.id === id);
if (!item) return { ok: false, error: 'Device unavailable' };
if (value != null) {
if(kind==='device' && (op==='left'||op==='right'))item.channelVolumes[op==='left'?0:1]=value;
else item.volume = Math.max(0, Math.min(100, value));
}
if (q.has('muted')) item.muted = q.get('muted') === 'true';
if (item === deck.audio.devices[0]) Object.assign(audio, { volume: item.volume, muted: item.muted });
} else if (kind === 'default') {
const item = deck.audio.devices.find(d => d.id === id); if (!item) return { ok: false, error: 'Device unavailable' };
const roles=op==='media'?[1]:op==='communications'?[2]:op==='console'?[0]:[0,1,2];
for(const role of roles)deck.audio.defaults[item.direction+':'+role]=id;
if(roles.includes(1))deck.audio.devices.filter(d => d.direction === item.direction).forEach(d => d.default = d.id === id);
} else if (kind === 'media') {
const item = deck.media.find(m => m.id === id); if (!item) return { ok: false, error: 'Player unavailable' };
if (item === deck.media[0]) { if (['playpause', 'next', 'prev', 'seek'].includes(op)) mediaAction(op, value); }
else if (op === 'playpause') item.playing = !item.playing;
else if (op === 'seek') item.position = value;
if (op === 'shuffle') item.shuffle = !!value;
if (op === 'repeat') item.repeat = ['None', 'Track', 'List'][value];
} else if (kind === 'ptt') {
if (op === 'begin') {
const mic = deck.audio.devices.find(d => d.id === id && d.direction === 'input'); if (!mic) return { ok: false, error: 'No microphone' };
const lease = 'demo-' + Math.random(); deckLeases.set(lease, { mic, muted: mic.muted }); mic.muted = false;
return { ok: true, lease };
}
const lease = deckLeases.get(q.get('lease')); if (!lease) return { ok: false, error: 'Expired lease' };
if (op === 'end') { lease.mic.muted = lease.muted; deckLeases.delete(q.get('lease')); }
} else if (kind === 'duck') deck.ducking = op !== 'off';
else if (kind === 'brightness') deck.monitors[0].brightness = Math.max(0, Math.min(100, value));
else if (kind === 'panic') { deck.audio.devices.forEach(d => d.muted = true); audio.muted = true; }
else if (kind === 'sleep') deck.sleep = value ? [{ id, at: now() + value * 60000 }] : [];
else if (kind === 'power-plan') {
if (!deck.powerPlans.some(x => x.id === id)) return { ok: false, error: 'Power plan unavailable' };
deck.powerPlans.forEach(x => x.state = x.id === id ? 'Active' : 'Available');
} else if (kind === 'profile') {
if (!(c.controls?.profiles || []).some(x => x.id === id)) return { ok: false, error: 'Profile unavailable' };
} else if (kind === 'action') {
// "shortcut:" runs one of the settings' shortcuts; anything else is a configured control
if (id?.startsWith('shortcut:')) return c.shortcuts.some(x => x.id === id.slice(9)) ? { ok: true } : { ok: false, error: 'Configured shortcut is missing' };
const a = (c.controls?.actions || []).find(x => x.id === id); if (!a) return { ok: false, error: 'Control is not configured' };
if (a.enabled === false && !(op === 'stop' && /^(task|server|sound|radio)$/.test(a.kind))) return { ok: false, error: 'Control is disabled' };
if (a.kind === 'server') actionState[id] = op === 'stop' ? 'Stopped' : 'Running';
else if (a.kind === 'task') { if (op === 'stop') actionState[id] = 'Stopped'; else { actionState[id] = 'Running'; setTimeout(() => { actionState[id] = 'Finished'; }, 4000); } }
else if (a.kind === 'radio') {
const stop = op === 'stop' || (op === 'toggle' && actionState[id] === 'Playing');
for (const r of (c.controls?.actions || []).filter(x => x.kind === 'radio')) actionState[r.id] = 'Stopped';
if (!stop) actionState[id] = 'Playing';
}
else if (a.kind === 'sound') { if (op === 'stop') delete actionState[id]; else actionState[id] = 'Playing'; }
} else if (kind === 'sound-stop') { for (const a of c.controls?.actions || []) if ((!id || a.id === id) && actionState[a.id] === 'Playing') delete actionState[a.id]; }
else if (kind === 'power-arm' || kind === 'power') return { ok: false, error: 'Power controls are disabled in desktop settings' };
else if (!['desktop', 'lock', 'monitor-off'].includes(kind)) return { ok: false, error: 'Unknown control' };
return { ok: true };
}
async function handle(method, path, q, body, headers) {
if (path === '/api/controls/obs') return json(deckObsState());
if (path.startsWith('/api/controls/obs/') && method === 'POST') {
// ObsMonitor.DeckAct: only when a widget allows control, and only for inputs, sources and scenes OBS really has
if (!allows(await config(), isObs)) return json({ ok: false, error: "Enable OBS control in the widget's desktop settings" });
const action = path.split('/').pop(), id = q.get('id'), value = q.has('value') ? +q.get('value') : null, enabled = q.has('enabled') ? q.get('enabled') === 'true' : null;
const x = deckObsExtra, input = x.inputs.find((i) => i.id === id), source = (x.sources[obs.scene] || []).find((i) => String(i.id) === id);
const refused = json({ ok: false, error: 'OBS refused this control' });
if (action === 'volume') { if (!input || !(value >= 0 && value <= 100)) return refused; input.volume = value; }
else if (action === 'mute') { if (!input || enabled == null) return refused; input.muted = enabled; if (input === x.inputs[1]) obs.mic = enabled; }
else if (action === 'scene') { if (!obsAct('scene', id) || obs.scene !== id) return refused; }
else if (action === 'source') { if (!source || enabled == null) return refused; source.enabled = enabled; }
else if (action === 'transition') { if (!x.transitions.some((t) => t.transitionName === id)) return refused; x.transition = id; }
else if (action === 'duration') { if (!(value >= 50 && value <= 10000)) return refused; x.duration = value; }
else if (action === 'record' || action === 'pause' || action === 'stream') obsAct(action);
else if (action === 'replay') x.replayActive = !x.replayActive;
else if (action === 'save-replay') { if (!x.replayActive) return refused; }
else if (action === 'camera') x.cameraActive = !x.cameraActive;
else return refused;
return json({ ok: true });
}
if (path === '/api/controls/spotify/state') return json(deckSpotify());
if (path.startsWith('/api/controls/spotify/action/') && method === 'POST') {
const action = path.split('/').pop();
if (action === 'play-queued') {
const i = TRACKS.findIndex((_, n) => spotifyId(n) === q.get('id'));
if (i < 0 || i === player.track) return json({ ok: false, error: 'That queue item is no longer available' });
Object.assign(player, { track: i, posMs: 0, at: now(), playing: true });
} else if (action !== 'favorite' && action !== 'playlist') return json({ ok: false, error: 'Unknown Spotify control' });
return json({ ok: true });
}
if (path === '/api/controls/spotify/connect') return json({ error: 'Spotify sign-in needs the installed computer agent' }, 400);
if (path === '/api/controls/spotify/disconnect') return json({ ok: true });
if (path === '/api/controls/capture') return json({ id: 'profile-' + Date.now().toString(36), label: 'Captured preview profile',
levels: [...deck.audio.devices.map((d) => ({ kind: 'device', id: d.id, volume: d.volume, muted: d.muted })), ...deck.audio.sessions.map((d) => ({ kind: 'session', id: d.id, volume: d.volume, muted: d.muted }))],
actions: [], output: null, input: null, defaults: { ...deck.audio.defaults } });
if (path.startsWith('/api/controls/task-output/')) return new Response(actionState[decodeURIComponent(path.split('/').pop())] ? '> made-up output: the demo runs no commands\n' : '', { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
if (path === '/api/controls/state') return json(await deckState());
if (path.startsWith('/api/controls/command/') && method === 'POST') return json(await controlCommand(path.split('/').pop(), q));
if (path === '/api/state') return json(state(q.has('ev') ? +q.get('ev') : null));
if (path === '/api/config') return json({ version: cfgVersion(), config: publicConfig(await config()) });
if (path === '/api/volume' && method === 'POST') {
// the agent answers with the new volume state (AudioSnapshot)
const op = q.get('op'), level = q.has('level') ? +q.get('level') : null;
if (op === 'mute') audio.muted = !audio.muted;
else if (op === 'up' || op === 'down') audio.volume = Math.max(0, Math.min(100, audio.volume + (op === 'up' ? 5 : -5)));
else if (Number.isFinite(level)) audio.volume = Math.max(0, Math.min(100, Math.round(level)));
else return json({}, 400);
return json({ ...audio });
}
if (path.startsWith('/api/media/') && method === 'POST') return json({}, mediaAction(path.slice(11), q.has('ms') ? +q.get('ms') : null) ? 200 : 400);
if (path === '/api/procs/kill' && method === 'POST') {
// KillResult; refused unless an enabled page has a processes widget that allows it (AgentConfig.AllowKill)
const c = await config(), allowed = (c.pages || []).filter((p) => p.enabled !== false).some((p) => widgets({ pages: [p] }).some((w) => w.type === 'processes' && w.options?.allowKill !== false));
if (!allowed) return json({ killed: 0, failed: 0, error: 'Terminating processes is disabled in the settings' }, 403);
const i = PROCS.findIndex((x) => x[0] === q.get('name'));
if (q.has('pid')) { const g = PROCS.find((x) => procDetail(x[0]).instances.some((n) => n.pid === +q.get('pid'))); if (g && g[0] !== 'explorer' && g[2] > 1) { g[2]--; return json({ killed: 1, failed: 0, error: null }); } if (g && g[0] !== 'explorer') { PROCS.splice(PROCS.indexOf(g), 1); return json({ killed: 1, failed: 0, error: null }); } }
else if (i >= 0 && PROCS[i][0] !== 'explorer') { const [g] = PROCS.splice(i, 1); return json({ killed: g[2], failed: 0, error: null }); }
return json({ killed: 0, failed: 0, error: i >= 0 || q.has('pid') ? 'That process is protected' : 'No such process' }, 409);
}
if (path.startsWith('/api/run/') && method === 'POST') {
// on / off keys flip in the demo; the mute key mutes the made-up computer
const id = decodeURIComponent(path.slice(9)), sc = (await config()).shortcuts.find((s) => s.id === id);
if (!sc) return json({ title: 'An error occurred while processing your request.', status: 500, detail: 'unknown shortcut' }, 500);
if (sc.cmd === 'builtin:mute') audio.muted = !audio.muted;
else if (sc.toggle) toggles[id] = !toggles[id];
return json({});
}
if (path === '/api/quotas') return json(quotas());
if (path === '/api/calendar') return json(calendar());
if (path === '/api/docker') return json({ ...docker(), control: allows(await config(), (t) => t === 'docker') });
if (path === '/api/obs') { const c = await config(); return json({ ...obsState(), enabled: c.obs?.enabled !== false, control: widgets(c).some((w) => w.type === 'obs' && w.options?.control === true) }); }
if (path === '/api/phones') return json([
{ id: '3f9a1c0d7b2e', model: 'UMIDIGI BISON', lastSeen: now() - 2000, via: 'USB', webView: 'com.google.android.webview/134.0.6998.135', webviewProblem: null, name: null, pages: null },
{ id: 'c81e728d9d4c', model: 'samsung SM-X200', lastSeen: now() - 140000, via: 'Wi-Fi', webView: 'com.google.android.webview/134.0.6998.135', webviewProblem: null, name: null, pages: null },
]);
if (path === '/api/batteries') return json({ devices: [
{ name: 'WH-1000XM5', level: 64, kind: 'headphones', charging: null },
{ name: 'MX Master 3S', level: 85, kind: 'mouse', charging: null },
{ name: 'MX Keys', level: 18, kind: 'keyboard', charging: null },
{ name: 'Xbox Wireless Controller', level: 40, kind: 'controller', charging: null },
] });
if (path.startsWith('/api/obs/') && method === 'POST') {
if (!allows(await config(), isObs)) return json({ error: "controlling OBS is turned off in the widget's settings" }, 400);
return obsAct(path.slice(9), q.get('scene')) ? json({ ok: true }) : json({ error: 'unknown action' }, 400);
}
if (path === '/api/ha/states') return json(haStates((q.get('ids') || '').split(',').filter(Boolean)));
if (path.startsWith('/api/ha/toggle/') && method === 'POST') { haToggle(decodeURIComponent(path.slice(15))); return json({ ok: true }); }
if (path.startsWith('/api/docker/') && method === 'POST') {
const [, , , id, action] = path.split('/');
if (!allows(await config(), (t) => t === 'docker')) return json({ error: "starting and stopping containers is turned off in the widget's settings" }, 400);
if (!['start', 'stop', 'restart'].includes(action)) return json({ error: 'unknown action' }, 400);
const c = containers.find((x) => x.id === id);
if (!c) return json({ error: 'unknown container' }, 400);
if (c && action === 'stop') Object.assign(c, { state: 'exited', status: 'Exited (0) 1 second ago', cpu: null, mem: null, health: null });
if (c && (action === 'start' || action === 'restart')) Object.assign(c, { state: 'running', status: 'Up 1 second', cpu: 2, mem: '40MiB', health: null });
return json({ ok: true });
}
if (path === '/api/work') return json(work());
if (path === '/api/procs') return json(procs());
if (path === '/api/procs/detail') return json(procDetail(q.get('name')));
if (path.startsWith('/api/widget/')) return json({ output: String(3 + Math.floor(Math.random() * 3)), at: now() - 8000, ok: true, running: false, durationMs: 412 });
if (path === '/api/approvals' && method === 'GET') return json({ items: approvals.filter((a) => a.expiresAt > now()) });
if (path.startsWith('/api/approvals/') && method === 'POST') {
const id = decodeURIComponent(path.slice(15));
if (!approvals.some((a) => a.id === id)) return json({}, 404);
approvals = approvals.filter((a) => a.id !== id);
bc.postMessage({ type: 'decided', id });
return json({ ok: true });
}
if (path === '/api/push' && method === 'GET') return json({ version: pushVersion, items: livePush() });
if (path === '/api/push' && method === 'DELETE') { for (const k of Object.keys(push)) delete push[k]; pushVersion++; return json({}); }
if (path.startsWith('/api/push/') && method === 'DELETE') { const key = decodeURIComponent(path.slice(10)); if (!push[key]) return json({}, 404); delete push[key]; pushVersion++; return json({}); }
if (path.startsWith('/api/push/') && method === 'POST') {
const key = decodeURIComponent(path.slice(10)), value = body == null || body === '' ? (q.has('value') ? parse(q.get('value')) : true) : parse(body), ttl = q.has('ttl') ? +q.get('ttl') : null;
setPush(key, value, ttl);
bc.postMessage({ type: 'push', key, value, ttl });
return json({ ok: true, key, version: pushVersion });
}
if (path === '/api/rules/test' && method === 'POST') {
// runs the rule's action once, like the agent's Test button
const r = parse(body) || {};
if (r.then === 'goto' && r.page) emit('goto', { page: r.page, rule: r.id || 'test', name: r.name || null, priority: r.priority ?? 5, countdown: r.countdownSec ?? null, hold: false });
else if (r.then === 'notify') emit('notify', { title: r.name || 'PhoneBox', text: (r.text || 'test').replace('{detail}', 'test'), level: r.level || 'info' });
else if (r.then === 'push' && r.key) {
const value = r.pushValue ?? true;
setPush(r.key, value);
bc.postMessage({ type: 'push', key: r.key, value });
}
return json({ ok: true });
}
if (path === '/api/rules/pause' && method === 'POST') {
const m = q.has('minutes') ? +q.get('minutes') : 60;
rulesPaused = m > 0 ? now() + m * 60e3 : 0;
return json({ pausedUntil: rulesPaused || null });
}
if (path === '/api/charge') return json({ phone: { level: 72, plugged: true, temp: 29, at: now() - 20e3, source: 'usb', usb: 'configured' }, power: null, switchedAt: null, error: null });
if (path === '/api/notify' && method === 'POST') {
const d = parse(body), payload = typeof d === 'object' && d && !Array.isArray(d) ? d : { text: d == null ? '' : String(d) };
if (!('text' in payload) && !('title' in payload)) return json({ error: 'give a text or a title' }, 400);
return json(emit('notify', payload));
}
if (path.startsWith('/api/goto/') && method === 'POST')
return json(emit('goto', { page: decodeURIComponent(path.slice(10)), countdown: q.has('countdown') ? Math.max(0, Math.min(30, +q.get('countdown') || 0)) : null }));
if (path === '/api/refresh' && method === 'POST') return json(emit('refresh'));
// settings page
if (path === '/api/catalog') return json(await loadCatalog());
if (path === '/api/settings') {
if (method === 'PUT') {
// a save over settings that changed since this page loaded them is refused, as by the agent (X-Config-Version)
const loaded = headers.get('X-Config-Version');
if (loaded && +loaded !== cfgVersion()) return json({ error: 'The settings were changed in another tab or window after this one loaded them. Reload the page to see them; the changes made here were not saved.' }, 409);
const c = parse(body);
if (!c || typeof c !== 'object') return json({ error: 'the settings are not valid JSON' }, 400);
(c.pages || []).forEach((p, i) => { p.id ||= `page-${i + 1}`; p.title ||= 'Page'; const seen = {}; (p.widgets || []).forEach((w) => { if (!w.id) { const n = (seen[w.type] = (seen[w.type] || 0) + 1); w.id = `${w.type}-${Date.now() % 100000}-${n}`; } }); });
(c.shortcuts || []).forEach((s, i) => { s.id ||= `shortcut-${i + 1}`; });
store.set('pb-demo-config', c);
store.set('pb-demo-version', cfgVersion() + 1);
return json({ version: cfgVersion() });
}
return json(await config(), 200, { 'X-Config-Version': String(cfgVersion()) });
}
if (path === '/api/info') return json({ version: VERSION, os: 'windows', configPath: 'browser storage (demo)', logPath: 'โ', adb: 'demo', connected: true, devices: ['DEMO0001'], appMissing: [], usbProblem: null, webviewProblem: null });
// Pairing over Wi-Fi: a placeholder pattern instead of real QR codes (the demo has no agent to pair with)
if (path === '/api/pair/start' && method === 'POST') {
const box = (label) => ``;
return json({ enabled: true, pc: 'DEMO-PC', hosts: ['192.168.1.20', '100.101.102.103'], port: 8766, expiresAt: now() + 300e3, claims: 0, attempt: 1, check: 'DE40 0001',
link: 'https://ersinkoc.github.io/phonebox/pair/#v=1&pc=DEMO-PC&fp=demo&port=8766&h=192.168.1.20&c=demo',
qr: box('demo pairing'), app: 'https://github.com/ersinkoc/phonebox/releases/latest', appQr: box('demo app') });
}
if (path === '/api/pair/status') return json({ claims: 0 });
if (path === '/api/pair/cancel' && method === 'POST') return json({});
if (path === '/api/lan') return json({ configured: true, active: true, port: 8766, restartNeeded: false, hosts: ['192.168.1.20', '100.101.102.103'], fingerprint: 'demo' });
if (path === '/api/update') return json({ status: { agentVersion: VERSION, appVersion: VERSION, latest: VERSION, releaseUrl: 'https://github.com/ersinkoc/phonebox/releases/latest', agentUpdate: false, appUpdate: false,
lastCheck: now() - 3600e3, state: 'idle', error: null }, auto: (await config()).updates?.auto !== false, installed: true });
if (path === '/api/claude/hook') return json({ installed: false, path: '~/.claude/settings.json' });
if (method !== 'GET') return json({ ok: true });
return json({ error: 'not in the demo' }, 404);
}
window.fetch = async (input, init = {}) => {
const url = new URL(typeof input === 'string' ? input : input.url, location.href);
const i = url.pathname.indexOf('/api/');
if (url.origin !== location.origin || i < 0) return realFetch(input, init);
return handle((init.method || 'GET').toUpperCase(), url.pathname.slice(i), url.searchParams, init.body, new Headers(init.headers || {}));
};
// Album art and radio logos are
, which fetch() does not see: swap their address for the demo cover.
const swapDemoImg = (img) => {
if (img.tagName === 'IMG' && /\/api\/(art|controls\/radio-logo)/.test(img.getAttribute('src') || '')) img.src = ART;
};
new MutationObserver((list) => {
for (const m of list) {
if (m.type === 'attributes') swapDemoImg(m.target);
else for (const n of m.addedNodes) if (n.nodeType === 1) { swapDemoImg(n); n.querySelectorAll?.('img').forEach(swapDemoImg); }
}
}).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['src'] });
// A pretend Android bridge for phone-only widgets.
if (!window.Android && document.currentScript.dataset.panel !== undefined)
window.Android = { battery: () => JSON.stringify({ level: 82, plugged: true, temp: 29.5 }), transport: () => 'usb', version: () => VERSION,
is24Hour: () => !new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions().hour12,
haptic() {}, buzz() {}, setBrightness() {}, setVolumeKeys() {} };
// Served by tools/demo-server.py: webhooks sent to that server with curl arrive here. Each tab applies them itself.
if (document.currentScript.dataset.relay !== undefined && window.EventSource) {
const es = new EventSource(ROOT + 'demo/events');
const on = (kind, fn) => es.addEventListener(kind, (m) => fn(JSON.parse(m.data)));
on('notify', (d) => emit('notify', d, false));
on('goto', (d) => emit('goto', d, false));
on('refresh', () => emit('refresh', null, false));
on('push', (d) => setPush(d.key, d.value));
on('ask', (d) => ask(d || undefined, false));
}
window.PhoneBoxDemo = { emit, ask, reset() { localStorage.removeItem('pb-demo-config'); store.set('pb-demo-version', cfgVersion() + 1); } };
})();