// PhoneBox desk panel: core, pages, navigation and data from the agent. The widgets live in widgets/*.js;
// they import what they need from here, so this module must not run code that needs them while it loads
// (the start section at the end runs after every module has loaded).
import { W } from './registry.js';
import './appearance.js';
import { cached } from './widgets/helpers.js';
import './widgets/basic.js';
import './widgets/stack.js';
import './widgets/time.js';
import './widgets/pc.js';
import './widgets/sky.js';
import './widgets/web.js';
import './widgets/controls.js';
// PhoneBox desk panel. Pages are 12 × 6 grids of widgets, designed in the agent's settings page.
// The panel talks to the PhoneBox agent through "/agent/*", which the Android shell proxies over USB
// (adb reverse) or Wi-Fi to the computer.
export const API = '/agent/api';
// The phone's native proxy only forwards requests that carry this header (or image GETs), so script in a sandboxed
// Custom HTML widget cannot reach the agent: adding the header to a cross-origin request needs a CORS preflight,
// which the proxy refuses. See MainActivity.fromPanel.
export const PANEL_HEADERS = { 'X-PhoneBox-Panel': '1' };
export const LOCALE = 'en-GB';
export const COLS = 12;
export const ROWS = 6;
export const $ = (id) => document.getElementById(id);
// The native bridge (window.Android) is in every frame, sandboxed widgets included, so each call carries a key that only
// the panel's own frames receive (MainActivity.bridgeKey). This wrapper adds it.
const BRIDGE = ['battery', 'buzz', 'haptic', 'is24Hour', 'pcs', 'selectPc', 'forgetPc', 'setBrightness', 'setHomeScreen', 'setOrientation',
'setPinned', 'setVolumeKeys', 'transport', 'sensors', 'version'];
export const bridge = typeof Android === 'undefined' ? null : Object.fromEntries(BRIDGE.filter((m) => typeof Android[m] === 'function')
.map((m) => [m, (...args) => Android[m](window.__phoneboxKey || '', ...args)]));
// Live preview inside the agent's layout editor: the layout comes from the editor (unsaved), not from the agent.
export const PREVIEW = new URLSearchParams(location.search).has('preview') && window.parent !== window;
/** Used until the agent has sent its settings once (first start without a connection). */
export const FALLBACK_PAGES = [{
id: 'home', title: 'Home', icon: '🏠', enabled: true, widgets: [
{ id: 'clock', type: 'clock', x: 0, y: 0, w: 6, h: 4, options: {} },
{ id: 'media', type: 'media', x: 6, y: 0, w: 6, h: 4, options: {} },
{ id: 'cpu', type: 'stat', x: 0, y: 4, w: 3, h: 2, options: { metric: 'cpu' } },
{ id: 'gpu', type: 'stat', x: 3, y: 4, w: 3, h: 2, options: { metric: 'gpu' } },
{ id: 'ram', type: 'stat', x: 6, y: 4, w: 3, h: 2, options: { metric: 'ram' } },
{ id: 'net', type: 'stat', x: 9, y: 4, w: 3, h: 2, options: { metric: 'net' } },
],
}];
export const DEFAULT_CFG = {
weather: { city: 'Tallinn', lat: 59.437, lon: 24.745 },
night: { enabled: true, start: '00:30', end: '07:30', brightness: 0.02 },
display: { showSeconds: true, idleReturnSec: 90, vibrate: true, volumeKeys: true },
pages: FALLBACK_PAGES,
shortcuts: [],
};
export 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 (_) {} },
};
export let cfg = PREVIEW ? { ...DEFAULT_CFG, pages: [] } : { ...DEFAULT_CFG, ...store.get('cfg', {}) };
export let cfgVersion = null;
export let online = null;
export let lastTouch = Date.now();
export let lastUserTouch = 0; // last real touch or key (rules wait a while after it)
export let wakeUntil = 0;
export let rulesPaused = null; // unix ms until which the agent's rules are paused
export let nightOn = null;
export let evSeq = null; // last agent event seen (notifications, page switches…)
export let pushVersion = null;
/** Latest data from the agent and the web; widgets read from here. */
export const D = { state: null, quotas: null, work: null, procs: null, push: {}, weather: null, calendar: null, notes: store.get('notes', []) };
export const HIST = 90;
export const hist = { cpu: [], gpu: [], ram: [], vram: [], down: [], up: [], ping: [], gputemp: [], cputemp: [], hotspot: [], gpupower: [], gpuclock: [] };
export const pushHist = (k, v) => { const a = hist[k]; a.push(v ?? 0); if (a.length > HIST) a.shift(); };
// ---------------------------------------------------------------- helpers
/** fetch() to the panel's local/USB feed proxy when the phone cannot reach an allowed public HTTPS feed directly. */
export async function safeFetch(url, opts = {}) {
try {
return await fetch(url, opts);
} catch (e) {
if (!opts.signal?.aborted && typeof url === 'string' && url.startsWith('https://')) {
return await fetch(`${API}/proxy?url=${encodeURIComponent(url)}`, {
...opts,
headers: { ...(opts.headers || {}), ...PANEL_HEADERS },
});
}
throw e;
}
}
export async function api(path, method = 'GET', timeout = 2500) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), timeout);
try {
const r = await fetch(API + path, { method, signal: ctl.signal, cache: 'no-store', headers: PANEL_HEADERS });
if (!r.ok) throw new Error('HTTP ' + r.status);
const ct = r.headers.get('content-type') || '';
return ct.includes('json') ? r.json() : r.text();
} finally { clearTimeout(t); }
}
export const haptic = () => { try { bridge && bridge.haptic(); } catch (_) {} };
export const buzz = (p) => { try { if (bridge && !nightOn && cfg.display?.vibrate !== false) bridge.buzz(p); } catch (_) {} };
export const transport = () => { try { return bridge ? bridge.transport() : ''; } catch (_) { return ''; } };
/** h('div', 'cls', child, 'text', [more]) */
export function h(tag, cls, ...kids) {
const e = document.createElement(tag);
if (cls) e.className = cls;
for (const k of kids.flat(3)) if (k != null && k !== false) e.append(k.nodeType ? k : String(k));
return e;
}
export const pad = (n) => String(n).padStart(2, '0');
// Clock times follow display.hour12: false ("14:05", default), true ("2:05 PM") or "auto" (follow the phone). Pass hours 0-23.
export function hour12() {
const pref = cfg.display?.hour12;
if (pref === true || pref === 'true') return true;
if (pref === 'auto') {
try { if (bridge?.is24Hour) return !bridge.is24Hour(); } catch (_) {}
try { return Boolean(new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions().hour12); } catch (_) {}
}
return false;
}
export const fmtClock = (H, M) => (hour12() ? `${H % 12 || 12}:${pad(M)} ${H < 12 ? 'AM' : 'PM'}` : `${pad(H)}:${pad(M)}`);
export const fmtHM = (d) => fmtClock(d.getHours(), d.getMinutes());
export const fmtTime = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return Math.floor(s / 60) + ':' + pad(s % 60); };
export function fmtRate(bps) {
if (bps == null) return ['—', ''];
if (bps >= 1e9) return [(bps / 1e9).toFixed(1), 'GB/s'];
if (bps >= 1e6) return [(bps / 1e6).toFixed(bps >= 1e8 ? 0 : 1), 'MB/s'];
if (bps >= 1e3) return [(bps / 1e3).toFixed(0), 'KB/s'];
return [Math.round(bps).toString(), 'B/s'];
}
export const rateStr = (b) => fmtRate(b).join(' ');
export function fmtDur(ms) {
const m = Math.max(0, Math.round(ms / 60000));
if (m < 60) return `${m} min`;
const hr = Math.floor(m / 60);
if (hr < 24) return `${hr} h ${m % 60} min`;
const d = Math.floor(hr / 24);
return `${d} d ${hr % 24} h`;
}
export function shortDur(ms) {
const m = Math.max(0, Math.floor(ms / 60000));
if (m < 1) return 'now';
if (m < 60) return m + 'm';
const hr = Math.floor(m / 60);
return hr < 24 ? `${hr}h ${m % 60}m` : `${Math.floor(hr / 24)}d`;
}
export const ago = (ts) => { const d = Date.now() - ts; return d < 60_000 ? 'just now' : shortDur(d) + ' ago'; };
export const r1 = (v) => (v == null ? '—' : (Math.round(v * 10) / 10).toString());
export const fmtMem = (mb) => (mb >= 1024 ? (mb / 1024).toFixed(1) + ' GB' : Math.round(mb) + ' MB');
export const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
export const color = (c) => (c && c.startsWith('--') ? cssVar(c) : c);
export let toastTimer = 0;
export function toast(msg, kind = '', title = '', seconds = 2.6) {
const t = $('toast');
t.replaceChildren(...(title ? [h('b', null, title), h('span', null, msg)] : [h('span', null, msg)]));
t.className = 'toast on ' + kind;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (t.className = 'toast ' + kind), Math.max(1, seconds) * 1000);
}
// ---------------------------------------------------------------- charts
export function sizeCanvas(c) {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.round(c.clientWidth * dpr)), hh = Math.max(1, Math.round(c.clientHeight * dpr));
if (c.width !== w || c.height !== hh) { c.width = w; c.height = hh; }
return [c.getContext('2d'), w, hh, dpr];
}
/** Draws series as area charts. series: [{ data, color, max }] */
export function chart(canvas, series, opts = {}) {
if (!canvas.clientWidth) return;
const [g, w, hh, dpr] = sizeCanvas(canvas);
g.clearRect(0, 0, w, hh);
if (opts.grid) {
g.strokeStyle = 'rgba(255,255,255,0.06)';
g.lineWidth = 1;
for (let i = 1; i < 4; i++) { const y = Math.round((hh * i) / 4) + 0.5; g.beginPath(); g.moveTo(0, y); g.lineTo(w, y); g.stroke(); }
}
const top = 4 * dpr;
for (const s of series) {
const d = s.data;
if (d.length < 2) continue;
const max = s.max || 1;
const step = w / (HIST - 1);
const x0 = w - (d.length - 1) * step;
const y = (v) => hh - (Math.min(v, max) / max) * (hh - top);
g.beginPath();
g.moveTo(x0, y(d[0]));
for (let i = 1; i < d.length; i++) g.lineTo(x0 + i * step, y(d[i]));
g.lineWidth = 1.6 * dpr;
g.strokeStyle = s.color;
g.lineJoin = 'round';
g.stroke();
g.lineTo(w, hh);
g.lineTo(x0, hh);
g.closePath();
const grad = g.createLinearGradient(0, 0, 0, hh);
grad.addColorStop(0, s.color + '55');
grad.addColorStop(1, s.color + '00');
g.fillStyle = grad;
g.fill();
}
}
export function ringSvg(pct, elapsed) {
const r = 42, c = 2 * Math.PI * r;
const off = c * (1 - Math.max(0, Math.min(100, pct)) / 100);
let tick = '';
if (elapsed != null) {
const a = elapsed * 2 * Math.PI;
const p = (rad) => [50 + Math.cos(a) * rad, 50 + Math.sin(a) * rad].map((v) => v.toFixed(1));
const [x1, y1] = p(35), [x2, y2] = p(49);
tick = ``;
}
return ``;
}
// ---------------------------------------------------------------- metrics (stat, gauge, chart widgets)
export const shortCpu = (n) => (n || '').replace(/^(AMD|Intel\(R\))\s*/, '').split(' ').slice(0, 4).join(' ');
export const gpuOf = (s) => s?.pc?.gpu;
/** All GPUs, main one first (older agents only send pc.gpu). */
export const gpusOf = (s) => (s?.pc?.gpus?.length ? s.pc.gpus : s?.pc?.gpu ? [s.pc.gpu] : []);
export const shortGpu = (n) => (n || 'GPU').replace(/^(NVIDIA|AMD|Intel\(R\))\s+/i, '').replace(/\((TM|R)\)/gi, '').replace(/\s+/g, ' ').trim();
/**
* The memory a GPU works with. A graphics card: its own VRAM. An integrated GPU has only a small carve-out and lives
* on shared system memory, so dedicated + shared is what counts (Task Manager's "GPU memory").
*/
export function gpuMem(g) {
if (!g) return null;
if (g.integrated && g.sharedTotal) {
return { label: 'GPU memory', used: (g.vramUsed || 0) + (g.sharedUsed || 0), total: (g.vramTotal || 0) + g.sharedTotal };
}
return g.vramTotal ? { label: 'VRAM', used: g.vramUsed || 0, total: g.vramTotal } : null;
}
const memText = (m) => `${r1(m.used)} / ${Math.round(m.total)} GB`;
/** Board power: watts when a sensor gives them, otherwise the share of the power limit Windows reports. */
export const gpuPower = (g) => (g?.power != null ? `${Math.round(g.power)} W` : g?.powerPct != null ? `${Math.round(g.powerPct)}% power` : null);
export const netMax = () => Math.max(1, ...hist.down, ...hist.up) * 1.15;
export const METRICS = {
cpu: { label: 'CPU', name: 'Processor', color: '--cpu', unit: '%', max: () => 100, val: (s) => s.pc?.cpu,
sub: (s) => (s.pc?.cpuTemp != null ? `${Math.round(s.pc.cpuTemp)}°C` : shortCpu(s.pc?.cpuName)), title: (s) => s.pc?.cpuName },
gpu: { label: 'GPU', name: 'Graphics', color: '--gpu', unit: '%', max: () => 100, val: (s) => gpuOf(s)?.util,
sub: (s) => {
const g = gpuOf(s);
if (!g) return 'No GPU data';
const parts = [g.temp != null ? `${Math.round(g.temp)}°C` : null, gpuPower(g)].filter(Boolean);
if (parts.length) return parts.join(' · ');
const mem = gpuMem(g);
if (mem) return `${memText(mem)} memory`; // no sensors (Apple GPU)
return shortGpu(g.name) || 'Active';
}, title: (s) => gpuOf(s)?.name },
ram: { label: 'RAM', name: 'Memory', color: '--ram', unit: 'GB', max: (s) => s.pc?.ramTotalGb || 1, val: (s) => s.pc?.ramUsedGb, fmt: r1,
pct: (s) => (s.pc ? (s.pc.ramUsedGb / s.pc.ramTotalGb) * 100 : null), sub: (s) => (s.pc && s.pc.ramTotalGb ? `${r1(s.pc.ramUsedGb)} / ${Math.round(s.pc.ramTotalGb)} GB` : '') },
vram: { label: 'VRAM', name: 'Video memory', color: '--gpu', unit: '%', max: () => 100,
val: (s) => { const m = gpuMem(gpuOf(s)); return m ? (m.used / m.total) * 100 : null; },
sub: (s) => { const m = gpuMem(gpuOf(s)); return m ? memText(m) : 'No GPU data'; } },
net: { label: 'NETWORK', name: 'Network', color: '--net', rate: true, hist: 'down', max: netMax, val: (s) => s.pc?.netDownBps,
sub: (s) => '↑ ' + rateStr(s.pc?.netUpBps) + (s.ping?.ms != null ? ` · ${Math.round(s.ping.ms)} ms` : '') },
down: { label: 'DOWNLOAD', name: 'Download', color: '--net', rate: true, max: netMax, val: (s) => s.pc?.netDownBps },
up: { label: 'UPLOAD', name: 'Upload', color: '--up', rate: true, max: netMax, val: (s) => s.pc?.netUpBps },
ping: { label: 'PING', name: 'Ping', color: '#38bdf8', unit: 'ms', max: () => Math.max(50, ...hist.ping) * 1.2, val: (s) => s.ping?.ms,
sub: (s) => (s.ping ? `avg ${s.ping.avgMs ?? '—'} ms · loss ${s.ping.lossPct}%` : '') },
gputemp: { label: 'GPU TEMP', name: 'GPU temperature', color: '#f97316', unit: '°C', max: () => 100, hot: [75, 85], val: (s) => gpuOf(s)?.temp,
sub: (s) => {
const g = gpuOf(s);
if (!g) return 'No GPU data';
const detail = [g.hotSpot != null ? `Hot spot ${Math.round(g.hotSpot)}°` : null,
g.fan != null ? `Fan ${Math.round(g.fan)}%` : g.fanRpm != null ? `Fan ${Math.round(g.fanRpm)} rpm` : null,
g.clock != null ? `${Math.round(g.clock)} MHz` : null].filter(Boolean);
return detail.length ? detail.join(' · ') : (g.temp != null ? 'Temperature' : 'No GPU data');
} },
hotspot: { label: 'HOT SPOT', name: 'GPU hot spot', color: '#ef4444', unit: '°C', max: () => 110, hot: [90, 100], val: (s) => gpuOf(s)?.hotSpot,
sub: (s) => {
const g = gpuOf(s);
if (g?.hotSpot == null) return 'needs LibreHardwareMonitor';
return [g.temp != null ? `Edge ${Math.round(g.temp)}°` : null, g.memTemp != null ? `Memory ${Math.round(g.memTemp)}°` : null].filter(Boolean).join(' · ');
} },
gpupower: { label: 'GPU POWER', name: 'GPU power', color: '#facc15', unit: (s) => (gpuOf(s)?.power != null ? 'W' : '%'),
max: (s) => { const g = gpuOf(s); return g?.power != null ? Math.max(g.powerLimit || 0, ...hist.gpupower, 50) : 100; },
val: (s) => { const g = gpuOf(s); return g?.power ?? g?.powerPct; },
sub: (s) => {
const g = gpuOf(s);
if (!g || (g.power == null && g.powerPct == null)) return 'No power data';
if (g.power != null && g.powerLimit) return `of ${Math.round(g.powerLimit)} W limit`;
return g.power != null ? shortGpu(g.name) : 'of the power limit';
} },
gpuclock: { label: 'GPU CLOCK', name: 'GPU clock', color: '#a78bfa', unit: 'MHz', max: () => Math.max(1000, ...hist.gpuclock) * 1.1, val: (s) => gpuOf(s)?.clock,
sub: (s) => { const g = gpuOf(s); return g?.memClock != null ? `Memory ${Math.round(g.memClock)} MHz` : g?.clock == null ? 'No clock data' : ''; } },
cputemp: { label: 'CPU TEMP', name: 'CPU temperature', color: '#fb7185', unit: '°C', max: () => 100, hot: [75, 85], val: (s) => s.pc?.cpuTemp,
sub: (s) => (s.pc?.cpuTemp == null ? 'needs LibreHardwareMonitor' : shortCpu(s.pc?.cpuName)) },
};
export function metricText(m, s) {
const v = m.val(s);
if (m.rate) return fmtRate(v);
const unit = (typeof m.unit === 'function' ? m.unit(s) : m.unit) || '';
if (v == null) return ['—', unit];
return [m.fmt ? m.fmt(v) : String(Math.round(v)), unit];
}
// ---------------------------------------------------------------- weather (Open-Meteo, straight from the phone)
export const WX = {
0: ['Clear', '☀️', '🌙'], 1: ['Mostly clear', '🌤️', '🌙'], 2: ['Partly cloudy', '⛅', '☁️'], 3: ['Overcast', '☁️', '☁️'],
45: ['Fog', '🌫️'], 48: ['Rime fog', '🌫️'],
51: ['Light drizzle', '🌦️'], 53: ['Drizzle', '🌦️'], 55: ['Heavy drizzle', '🌧️'], 56: ['Freezing drizzle', '🌧️'], 57: ['Freezing drizzle', '🌧️'],
61: ['Light rain', '🌦️'], 63: ['Rain', '🌧️'], 65: ['Heavy rain', '🌧️'], 66: ['Freezing rain', '🌧️'], 67: ['Freezing rain', '🌧️'],
71: ['Light snow', '🌨️'], 73: ['Snow', '🌨️'], 75: ['Heavy snow', '❄️'], 77: ['Snow grains', '🌨️'],
80: ['Light showers', '🌦️'], 81: ['Showers', '🌧️'], 82: ['Heavy showers', '⛈️'], 85: ['Snow showers', '🌨️'], 86: ['Heavy snow showers', '❄️'],
95: ['Thunderstorm', '⛈️'], 96: ['Thunderstorm, hail', '⛈️'], 99: ['Severe hail', '⛈️'],
};
export const wxIcon = (code, day = 1) => { const x = WX[code] || ['—', '🌡️']; return day === 0 && x[2] ? x[2] : x[1]; };
export async function loadWeather(force = false) {
const { lat, lon } = cfg.weather || DEFAULT_CFG.weather;
const key = `v3:${lat},${lon}`;
const cached = store.get('wx', null);
if (cached && cached.key === key && !D.weather) { D.weather = cached.data; emit('weather'); }
if (!force && cached && cached.key === key && Date.now() - cached.at < 14 * 60_000) return;
// only while a page shows the weather (PRIVACY.md): no widget, no request with the location
if (!anyNeeds('weather')) return;
try {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
'¤t=temperature_2m,apparent_temperature,weather_code,is_day' +
'&hourly=temperature_2m,weather_code,precipitation_probability,is_day' +
'&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset,daylight_duration,uv_index_max' +
'&timezone=auto&forecast_days=7';
const ctl = new AbortController();
setTimeout(() => ctl.abort(), 8000);
const data = await (await safeFetch(url, { signal: ctl.signal })).json();
store.set('wx', { key, at: Date.now(), data });
D.weather = data;
emit('weather');
} catch (_) { if (!D.weather) emit('weather'); }
}
export function wxLines() {
const w = D.weather;
if (!w || !w.current) return null;
const c = w.current, d = w.daily || {};
const desc = (WX[c.weather_code] || ['—'])[0];
const hi = d.temperature_2m_max ? Math.round(d.temperature_2m_max[0]) : null;
const lo = d.temperature_2m_min ? Math.round(d.temperature_2m_min[0]) : null;
return {
icon: wxIcon(c.weather_code, c.is_day),
temp: Math.round(c.temperature_2m) + '°',
desc: `${desc} · ${cfg.weather?.city || ''}`,
range: (hi != null ? `↑${hi}° ↓${lo}° · ` : '') + `Feels like ${Math.round(c.apparent_temperature)}°`,
};
}
export function wxBlock() {
const e = h('div', 'wx', h('span', 'wx-icon', '·'), h('span', 'wx-temp', '--°'),
h('div', 'wx-meta', h('span', 'wx-desc', 'Loading weather…'), h('span', 'wx-range muted')));
e.update = () => {
const l = wxLines();
if (!l) { e.querySelector('.wx-desc').textContent = D.weather === null ? 'Loading weather…' : 'Weather unavailable'; return; }
e.querySelector('.wx-icon').textContent = l.icon;
e.querySelector('.wx-temp').textContent = l.temp;
e.querySelector('.wx-desc').textContent = l.desc;
e.querySelector('.wx-range').textContent = l.range;
};
return e;
}
// ---------------------------------------------------------------- status line (clock widget)
export function phoneBattery() {
if (!bridge) return '';
try { const b = JSON.parse(bridge.battery()); return `📱 ${b.level}%${b.plugged ? ' ⚡' : ''}`; } catch (_) { return ''; }
}
export function quotaProviders() { return D.quotas?.providers || []; }
export function renderStatus(el) {
const via = transport();
const name = online ? (D.state?.host || 'PC') + (via === 'wifi' ? ' · Wi-Fi' : via === 'usb' ? ' · USB' : '') : online === false ? 'PC not connected' : 'Connecting…';
const kids = [h('span', 'dot ' + (online ? 'on' : online === false ? 'off' : '')), h('span', null, name)];
const bat = phoneBattery();
if (bat) kids.push(h('span', 'sep', '·'), h('span', null, bat));
for (const p of quotaProviders()) {
const l = p.limits?.[0];
if (!l) continue;
const s = h('span', 'qm', h('span', 'sep', '·'), p.name + ' ', h('b', null, Math.round(l.percent) + '%'));
if (l.percent >= 95) s.style.color = '#f87171'; else if (l.percent >= 80) s.style.color = '#fbbf24';
kids.push(s);
}
const waiting = (D.work?.sessions || []).filter((s) => s.status === 'waiting').length;
if (waiting) kids.push(h('span', 'sep', '·'), h('span', 'smini', `${waiting} waiting`));
const u = D.state?.update;
if (u) kids.push(h('span', 'sep', '·'), h('span', 'upd', u.app && via !== 'usb' ? `⬆ ${u.latest}: plug in USB to update` : `⬆ ${u.latest} available`));
el.replaceChildren(...kids);
// Too long for the widget: drop the quota summaries, the most dispensable part.
while (el.scrollWidth > el.clientWidth + 1 && el.querySelector('.qm')) [...el.querySelectorAll('.qm')].pop().remove();
}
// ---------------------------------------------------------------- shortcuts
export async function runShortcut(btn, id) {
haptic();
btn.classList.remove('ok', 'err');
btn.classList.add('busy');
let ok = false;
try { await api('/run/' + encodeURIComponent(id), 'POST', 5000); ok = true; } catch (_) {}
btn.classList.remove('busy');
btn.classList.add(ok ? 'ok' : 'err');
setTimeout(() => btn.classList.remove('ok', 'err'), 900);
}
export function keyButton(s) {
const b = h('button', 'key' + (s.toggle ? ' toggle' : ''), h('span', 'k-icon', s.icon || '•'), h('span', 'k-label', s.label));
b.style.setProperty('--k', s.color || '#6b7280');
b.dataset.id = s.id;
b.addEventListener('click', () => runShortcut(b, s.id));
return b;
}
// ---------------------------------------------------------------- media (shared state, any number of widgets)
export let media = null; // last media state + the time it was received
export let volDragging = false;
export const setVolDragging = (v) => { volDragging = v; };
export const setMediaState = (m) => {
if (D.state) D.state.media = m || null;
media = m ? { ...m, at: performance.now() } : null;
emit('state');
};
// The background glow: the accent colour (Settings › Display), or the album art's colour while music plays.
export let DEFAULT_AMBIENT = '45, 212, 191';
export let artTint = true;
export let currentArtColor = null;
export const setArtColor = (c) => { currentArtColor = c; setAmbient(c); };
export const hexToRgb = (hex) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex || ''); if (!m) return null; const n = parseInt(m[1], 16); return `${n >> 16}, ${(n >> 8) & 255}, ${n & 255}`; };
export function averageColor(img) {
try {
const c = document.createElement('canvas');
c.width = c.height = 16;
const g = c.getContext('2d');
g.drawImage(img, 0, 0, 16, 16);
const d = g.getImageData(0, 0, 16, 16).data;
let r = 0, gg = 0, b = 0, n = 0;
for (let i = 0; i < d.length; i += 4) {
const mx = Math.max(d[i], d[i + 1], d[i + 2]), mn = Math.min(d[i], d[i + 1], d[i + 2]);
if (mx < 30 || mx - mn < 20) continue; // skip very dark / grey pixels
r += d[i]; gg += d[i + 1]; b += d[i + 2]; n++;
}
if (!n) return null;
const [R, G, B] = [r / n, gg / n, b / n];
const k = 220 / (Math.max(R, G, B) || 1); // normalise brightness
return [R, G, B].map((v) => Math.round(Math.min(255, v * k))).join(', ');
} catch (_) { return null; }
}
export const setAmbient = (rgb) => document.documentElement.style.setProperty('--ambient', (artTint && rgb) || DEFAULT_AMBIENT);
export const MEDIA_HTML = `
♪
Media
Nothing playing
Start something on the PC
0:000:00
--
Computer not connectedIs the PhoneBox agent running? Plug in the USB cable, or pair over Wi-Fi: in the PhoneBox settings on the computer choose Wi-Fi & Tailscale › Pair a phone over Wi-Fi and scan the QR code with this phone's camera.
`;
// ---------------------------------------------------------------- quotas
export const Q_STYLE = { antigravity: { color: '#38bdf8', logo: '✦' }, claude: { color: '#d97757', logo: '✳' }, codex: { color: '#e5e7eb', logo: '◎' } };
export function fmtReset(ts) {
if (!ts) return '';
const d = new Date(ts);
const hm = fmtHM(d);
const day = d.toDateString() === new Date().toDateString() ? hm : d.toLocaleDateString(LOCALE, { weekday: 'short' }) + ' ' + hm;
return `${day} · in ${fmtDur(ts - Date.now())}`;
}
/** Compares the share of the quota used with the share of the window elapsed. */
export function paceInfo(l) {
if (!l.resetsAt || !l.windowSec) return null;
const win = l.windowSec * 1000;
const left = l.resetsAt - Date.now();
const elapsed = Math.min(1, Math.max(0, 1 - left / win));
if (l.percent >= 100) return { elapsed, cls: 'over', text: 'Limit reached' };
if (elapsed < 0.04 || l.percent < 1) return { elapsed, cls: 'calm', text: 'Plenty left' };
const rate = l.percent / (elapsed * win); // % per ms
const msTo100 = (100 - l.percent) / rate;
if (msTo100 < left) return { elapsed, cls: 'over', text: `At this pace: full in ${fmtDur(msTo100)}` };
const diff = l.percent - elapsed * 100;
if (diff > 10) return { elapsed, cls: 'fast', text: 'Ahead of pace' };
if (diff < -10) return { elapsed, cls: 'calm', text: 'Plenty left' };
return { elapsed, cls: '', text: 'On pace' };
}
// ---------------------------------------------------------------- sessions & GitHub
export const S_ORDER = { waiting: 0, busy: 1, shell: 2, idle: 3 };
export const prevStatus = new Map();
export let workLoaded = false;
/** Vibrates and shows a toast when a session finishes or starts waiting for input. */
export function notifyTransitions(sessions) {
for (const s of sessions) {
const before = prevStatus.get(s.id);
if (workLoaded && before && before !== s.status) {
if (s.status === 'waiting') {
buzz('0,90,90,90,90,160');
toast(s.waitingFor || 'needs input', 'err', s.name);
} else if (s.status === 'idle' && (before === 'busy' || before === 'shell')) {
buzz('0,70,110,70');
toast(`${s.name} finished`, 'ok');
}
}
prevStatus.set(s.id, s.status);
}
workLoaded = true;
}
export function prRow(p, mine) {
const row = h('div', 'gh-pr' + (p.draft ? ' draft' : ''));
if (mine) {
const ciMap = { SUCCESS: '✓', FAILURE: '✗', ERROR: '✗', PENDING: '●', EXPECTED: '●' };
row.appendChild(h('span', 'ci ' + (p.ci || 'none'), ciMap[p.ci] || '–'));
} else {
row.appendChild(h('span', 'ci none', '◇'));
}
row.append(h('span', 'rp', `${p.repo.split('/')[1]}#${p.number}`), h('span', 'tt', p.title));
if (mine && (p.review === 'APPROVED' || p.review === 'CHANGES_REQUESTED'))
row.appendChild(h('span', 'rv ' + p.review, p.review === 'APPROVED' ? 'approved' : 'changes'));
return row;
}
// ---------------------------------------------------------------- processes: instance sheet
export function monogramColor(name) {
let x = 0;
for (const ch of name) x = (x * 31 + ch.charCodeAt(0)) % 360;
return `hsl(${x} 70% 65%)`;
}
const ICON_CACHE = new Map();
const MAX_ICON_CACHE = 200;
/** Fills an icon box with the app icon, falling back to a coloured monogram. */
export function fillIcon(box, g) {
box.replaceChildren();
const cached = ICON_CACHE.get(g.name);
if (cached) {
box.textContent = '';
box.style.background = 'transparent';
box.appendChild(cached.cloneNode(true));
return;
}
box.style.background = monogramColor(g.name);
box.textContent = (g.title || g.name).charAt(0).toUpperCase();
if (g.hasIcon !== false) {
const img = new Image();
img.onload = () => {
if (ICON_CACHE.size >= MAX_ICON_CACHE) {
const oldest = ICON_CACHE.keys().next().value;
if (oldest !== undefined) ICON_CACHE.delete(oldest);
}
ICON_CACHE.set(g.name, img);
box.textContent = '';
box.style.background = 'transparent';
box.appendChild(img);
};
img.src = `${API}/procs/icon?name=${encodeURIComponent(g.name)}`;
}
}
export let sheet = null; // { group, detail, canKill }
/** Two-tap confirmation: the first tap arms the button, the second one runs the action. */
export function armButton(btn, armedText, action) {
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
if (btn.disabled) return;
if (!btn.classList.contains('armed')) {
haptic();
btn.dataset.text = btn.textContent;
btn.textContent = armedText;
btn.classList.add('armed');
clearTimeout(btn._t);
btn._t = setTimeout(() => { btn.classList.remove('armed'); btn.textContent = btn.dataset.text; }, 3500);
return;
}
clearTimeout(btn._t);
btn.classList.remove('armed');
action();
});
}
export function openSheet(g, canKill) {
sheet = { group: g, detail: null, canKill };
fillIcon($('psIco'), g);
$('psTitle').textContent = g.title;
$('psSub').textContent = 'Loading…';
$('psList').replaceChildren();
$('pSheet').hidden = false;
loadSheet();
}
export function closeSheet() {
sheet = null;
$('pSheet').hidden = true;
closeSessionDetails();
}
/** The session details dialog (Sessions widget) closes like the other overlays: Back, idle return, page change. */
function closeSessionDetails() {
const backdrop = document.querySelector('.session-backdrop');
if (backdrop) backdrop.click(); // a click on the backdrop itself runs the dialog's own dismiss
return !!backdrop;
}
export async function loadSheet() {
if (!sheet) return;
try {
const d = await api('/procs/detail?name=' + encodeURIComponent(sheet.group.name));
if (!sheet || d.name !== sheet.group.name) return;
sheet.detail = d;
renderSheet();
} catch (_) {}
}
export function renderSheet() {
const d = sheet.detail, canKill = sheet.canKill;
const totalCpu = d.instances.reduce((a, i) => a + i.cpu, 0), totalMem = d.instances.reduce((a, i) => a + i.memMb, 0);
$('psSub').textContent = `${d.instances.length} instance${d.instances.length === 1 ? '' : 's'} · CPU ${totalCpu.toFixed(1)}% · ${fmtMem(totalMem)}` +
(d.locked ? ' · system process' : !canKill ? ' · ending disabled in settings' : '');
const all = $('psAll');
if (!all.classList.contains('armed')) all.textContent = d.instances.length > 1 ? `End all ${d.instances.length}` : 'End';
all.disabled = d.locked || !canKill || !d.instances.length;
const list = $('psList');
list.replaceChildren();
if (!d.instances.length) { list.appendChild(h('div', 'empty', 'No running instances')); return; }
for (const i of d.instances) {
const primary = i.session ? '✳ ' + i.session : i.window || i.args || d.title;
const secondary = [i.session ? i.args : i.window ? i.args : null,
i.startedAt ? (Date.now() - i.startedAt < 60_000 ? 'just started' : 'started ' + shortDur(Date.now() - i.startedAt) + ' ago') : null]
.filter(Boolean).join(' · ');
const btn = h('button', 'ps-btn danger', 'End');
btn.disabled = i.locked || !canKill;
armButton(btn, 'Confirm', () => killInstance(i, primary));
list.appendChild(h('div', 'pi', h('span', 'pid', 'PID ' + i.pid),
h('div', 'lbl', h('b', i.session ? 'sess' : '', primary), h('small', null, secondary)),
h('div', 'num', h('b', null, i.cpu.toFixed(1) + '%'), h('br'), fmtMem(i.memMb)), btn));
}
}
export async function postKill(query) {
const r = await fetch(`${API}/procs/kill?${query}`, { method: 'POST', cache: 'no-store', headers: PANEL_HEADERS });
return { ok: r.ok, body: await r.json().catch(() => ({})) };
}
export async function killInstance(i, label) {
try {
const { ok, body } = await postKill('pid=' + i.pid);
ok ? toast(`Ended ${label} (PID ${i.pid})`, 'ok') : toast(body.error || 'Could not end it', 'err');
} catch (_) { toast('Computer unreachable', 'err'); }
setTimeout(() => { loadSheet(); loadProcs(); }, 500);
}
armButton($('psAll'), 'Tap again to confirm', async () => {
const g = sheet?.group;
if (!g) return;
try {
const { ok, body } = await postKill('name=' + encodeURIComponent(g.name));
if (ok) { toast(`Ended ${body.killed} × ${g.title}${body.failed ? ` (${body.failed} failed)` : ''}`, 'ok'); closeSheet(); }
else toast(body.error || 'Could not end them', 'err');
} catch (_) { toast('Computer unreachable', 'err'); }
setTimeout(loadProcs, 500);
});
$('psClose').addEventListener('click', () => { haptic(); closeSheet(); });
// ---------------------------------------------------------------- pushed data (webhook API)
/** Normalises whatever was pushed into { value, title, sub, unit, color, icon, progress, items, level }. */
export function pushView(item) {
const d = item.data;
const v = { at: item.at };
if (Array.isArray(d)) v.items = d;
else if (d !== null && typeof d === 'object') {
v.value = d.value ?? d.state ?? d.status;
v.title = d.title ?? d.label ?? d.name;
v.sub = d.sub ?? d.subtitle ?? d.text ?? d.message;
v.unit = d.unit;
v.color = d.color;
v.icon = d.icon;
v.items = Array.isArray(d.items) ? d.items : undefined;
v.level = d.level;
if (typeof d.progress === 'number') v.progress = d.progress > 1 ? d.progress / 100 : d.progress;
if (typeof d.min === 'number') v.min = d.min;
if (typeof d.max === 'number') v.max = d.max;
if (v.value === undefined && !v.items && v.sub === undefined) v.value = JSON.stringify(d);
} else v.value = d;
return v;
}
export function fmtValue(x) {
if (typeof x === 'number') return Math.abs(x) >= 100 ? String(Math.round(x)) : String(Math.round(x * 10) / 10);
if (typeof x === 'boolean') return x ? 'On' : 'Off';
return x == null ? '—' : String(x);
}
export const LEVEL_COLOR = { ok: '#22c55e', success: '#22c55e', warn: '#f59e0b', warning: '#f59e0b', error: '#ef4444', fail: '#ef4444', failure: '#ef4444', info: '#60a5fa' };
export const levelOf = (v) => v.level || (typeof v.value === 'string' ? (/^(ok|pass(ed)?|success|green|up|online)$/i.test(v.value) ? 'ok'
: /^(fail(ed|ure)?|error|red|down|offline|broken)$/i.test(v.value) ? 'error' : /^(running|pending|building|warn(ing)?|yellow)$/i.test(v.value) ? 'warn' : '') : '');
// ---------------------------------------------------------------- notifications (POST /api/notify)
export function handleNotify(d) {
const level = d.level === 'error' ? 'err' : d.level === 'ok' || d.level === 'success' ? 'ok' : d.level === 'warn' ? 'warn' : '';
if (d.wake && nightOn) { wakeUntil = Date.now() + 30_000; setNight(false); }
undim();
toast(d.text || '', level, d.title || '', d.seconds || 5);
if (d.vibrate !== false) buzz(level === 'err' ? '0,90,90,90,90,160' : '0,70,110,70');
D.notes.unshift({ title: d.title || '', text: d.text || '', level: d.level || 'info', at: Date.now() });
D.notes = D.notes.slice(0, 30);
store.set('notes', D.notes);
emit('notes');
if (d.page != null) requestPage({ page: d.page, name: d.title || null, countdown: d.countdown });
}
// ---------------------------------------------------------------- permission prompts (Claude Code hook → phone)
export let approvals = [];
export let approvalShown = null;
export async function loadApprovals() {
if (PREVIEW) return; // permission prompts are answered on the phone, not in the preview
try { approvals = (await api('/approvals')).items || []; } catch (_) { return; }
renderApproval();
}
export function renderApproval() {
const a = approvals[0];
const box = $('approval');
if (!a) { box.hidden = true; approvalShown = null; return; }
if (approvalShown !== a.id) {
approvalShown = a.id;
lastTouch = Date.now();
undim();
if (nightOn) { wakeUntil = Date.now() + 60_000; setNight(false); }
buzz('0,120,80,120,80,240');
$('apAllow').disabled = $('apDeny').disabled = $('apSkip').disabled = false;
}
// A question from Claude is answered at the PC (the terminal shows it); the phone only tells you it is waiting.
const asking = a.kind === 'question';
$('apAllow').hidden = $('apDeny').hidden = asking;
$('apSkip').hidden = !asking;
$('apSource').textContent = a.source || 'Claude Code';
$('apProject').textContent = a.project || a.session || '';
$('apCount').textContent = approvals.length > 1 ? `1 of ${approvals.length}` : '';
$('apTool').textContent = a.tool;
$('apDesc').textContent = a.description || '';
$('apDetail').textContent = a.detail || '';
$('apDetail').hidden = !a.detail;
box.hidden = false;
tickApproval();
}
export function tickApproval() {
const a = approvals[0];
if (!a || $('approval').hidden) return;
const left = a.expiresAt - Date.now();
$('apTime').textContent = a.kind === 'question' ? 'Answer it in the terminal on your PC'
: left > 0 ? `otherwise asked in the terminal in ${fmtTime(left)}` : 'asking in the terminal…';
if (left < -3000) { approvals.shift(); renderApproval(); }
}
setInterval(tickApproval, 1000);
export async function decide(decision) {
const a = approvals[0];
if (!a) return;
haptic();
$('apAllow').disabled = $('apDeny').disabled = $('apSkip').disabled = true;
try {
await api(`/approvals/${encodeURIComponent(a.id)}?decision=${decision}`, 'POST', 4000);
if (decision === 'skip') toast('The question is waiting in the terminal', 'warn');
else toast(`${decision === 'allow' ? 'Allowed' : 'Denied'}: ${a.tool}`, decision === 'allow' ? 'ok' : 'err');
} catch (_) { toast('Already answered in the terminal', 'warn'); }
approvals = approvals.filter((x) => x.id !== a.id);
renderApproval();
}
$('apAllow').addEventListener('click', () => decide('allow'));
$('apDeny').addEventListener('click', () => decide('deny'));
$('apSkip').addEventListener('click', () => decide('skip'));
export function handleEvent(e) {
if (PREVIEW && (e.kind === 'goto' || e.kind === 'back' || e.kind === 'reload')) return; // the editor picks the page
if (e.kind === 'approval' || e.kind === 'approval-done') loadApprovals();
else if (e.kind === 'notify' && e.data) handleNotify(e.data);
else if (e.kind === 'goto' && e.data) requestPage(e.data);
else if (e.kind === 'back' && e.data) requestBack(e.data.rule);
else if (e.kind === 'refresh') { loadConfig(); loadQuotas(); loadWork(); loadPush(); loadWeather(true); loadCalendar(); }
else if (e.kind === 'reload') location.reload();
}
export const WIDGET_ICONS = { rules: '⚡', chargecare: '🔋', nextup: '⏭️', moon: '🌙', wordclock: '🔤', clock: '🕒', weather: '⛅', forecast: '🌦️', media: '🎵', stat: '📈', gauge: '⏲️', chart: '📊', meters: '💾', gpu: '🎮', quotas: '✳️', sessions: '🤖',
github: '🐙', processes: '⚙️', shortcuts: '🎛️', button: '🔘', worldclock: '🌍', calendar: '📅', pomodoro: '🍅', countdown: '⏳', note: '📝', command: '💻',
push: '📡', inbox: '📬', label: '🔤', analog: '🕰️', progress: '📆', stopwatch: '⏱️', timer: '⏲️', counter: '🔢', breathe: '🫁', quote: '💬',
checklist: '✅', battery: '🔋', topprocs: '🔥', quotaring: '🎯', volume: '🔊', sun: '🌅', air: '🍃', hackernews: '📰', exchange: '💱', crypto: '🪙', image: '🖼️', pcswitch: '🖥️' };
// ================================================================= pages
export const pager = $('pager');
export let pages = []; // enabled pages
export let mounted = []; // { pi, def, inst, ctx, dirty }
export let layoutKey = '';
export let curPage = 0;
let lastHour12 = null;
export const isPortrait = () => window.innerHeight > window.innerWidth * 1.05;
// Portrait: the same widgets packed into a 6-column grid in reading order. Their sizes in cells stay the same
// (portrait cells are about as square as landscape ones); wider widgets shrink to 6 columns and the rows grow to fit.
export function portraitLayout(widgets) {
const cols = 6, occ = [], pos = new Map();
const free = (x, y, w, h) => { for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) if (occ[r]?.[c]) return false; return true; };
const take = (x, y, w, h) => { for (let r = y; r < y + h; r++) { occ[r] ||= []; for (let c = x; c < x + w; c++) occ[r][c] = true; } };
for (const w of [...widgets].sort((a, b) => a.y - b.y || a.x - b.x)) {
const ww = Math.min(cols, w.w), hh = w.h;
search: for (let y = 0; y < 200; y++) for (let x = 0; x + ww <= cols; x++) if (free(x, y, ww, hh)) { take(x, y, ww, hh); pos.set(w, { x, y, w: ww, h: hh }); break search; }
}
return { pos, rows: Math.max(12, occ.length) };
}
export function buildLayout() {
const enabled = (cfg.pages || []).filter((p) => p.enabled !== false && Array.isArray(p.widgets));
const next = enabled.length ? enabled : FALLBACK_PAGES;
const portrait = isPortrait();
const h12 = hour12();
const key = JSON.stringify([next, cfg.shortcuts, cfg.display?.showSeconds, cfg.display?.hour12, h12, cfg.display?.appearance, portrait]);
if (key === layoutKey) return;
layoutKey = key;
lastHour12 = h12;
const prevId = pages[curPage]?.id;
pages = next;
for (const m of mounted) { try { m.inst.destroy?.(); } catch (_) {} }
mounted = [];
closeSheet();
pager.replaceChildren();
pages.forEach((p, pi) => {
const grid = h('div', 'grid');
const pageAppearance = PhoneBoxAppearance.resolve(cfg.display?.appearance, p.appearance);
const pl = portrait ? portraitLayout(p.widgets) : null;
if (pl) { grid.style.gridTemplateColumns = 'repeat(6, minmax(0, 1fr))'; grid.style.gridTemplateRows = `repeat(${pl.rows}, minmax(0, 1fr))`; }
for (const w of p.widgets) {
const def = W[w.type];
if (!def) continue;
const box = h('div', `w w-${w.type}${def.bare ? ' bare' : ' card'}`);
const appearance = PhoneBoxAppearance.resolve(cfg.display?.appearance, p.appearance, w.appearance);
const at = pl?.pos.get(w) || w;
box.style.gridArea = `${at.y + 1} / ${at.x + 1} / span ${at.h} / span ${at.w}`;
grid.appendChild(box);
const ctx = { page: p, w, o: w.options || {}, box, appearance };
let inst;
try { inst = def.create(box, ctx) || {}; } catch (e) { box.replaceChildren(h('div', 'empty', `${w.type}: ${e.message}`)); inst = {}; }
PhoneBoxAppearance.widget(box, appearance);
mounted.push({ pi, def: inst.needs ? { ...def, needs: inst.needs, always: inst.always ?? def.always } : def, inst, ctx, dirty: true });
}
const sec = h('section', 'page', grid);
PhoneBoxAppearance.page(sec, grid, pageAppearance);
sec.dataset.id = p.id;
pager.appendChild(sec);
});
buildDots();
const idx = Math.max(0, pages.findIndex((p) => p.id === prevId));
curPage = -1;
goPage(idx, true);
requestAnimationFrame(() => setActive(true));
}
export function activeSet() { return new Set([curPage - 1, curPage, curPage + 1]); }
export const pageOf = (kind) => mounted.filter((m) => m.pi === curPage && m.def.needs.includes(kind)).length > 0;
export const anyNeeds = (kind) => mounted.some((m) => m.def.needs.includes(kind));
export function runUpdate(m, kind) {
try { m.inst.update?.(kind); } catch (e) { console.warn(m.ctx.w.type, e); }
}
/** Delivers fresh data to the widgets on the visible pages; the others catch up when they come into view. */
export function emit(kind) {
const act = activeSet();
for (const m of mounted) {
if (!m.def.needs.includes(kind)) continue;
// alarms (timer, pomodoro) must go off on whatever page is showing, so those widgets get every tick
if (act.has(m.pi) || (m.def.always && kind === 'tick')) runUpdate(m, kind); else m.dirty = true;
}
}
export function setActive(all = false) {
const act = activeSet();
for (const m of mounted) {
if ((m.dirty || all) && act.has(m.pi)) {
m.dirty = false;
for (const k of m.def.needs) runUpdate(m, k);
}
}
}
// ---------------------------------------------------------------- navigation: swipe, dots, overview, volume keys, agent events
export function buildDots() {
$('dots').replaceChildren(...pages.map(() => h('i')));
$('dots').hidden = pages.length < 2;
}
export function updateDots() {
[...$('dots').children].forEach((d, i) => d.classList.toggle('on', i === curPage));
}
export let snapTimer = 0;
export function goPage(i, instant = false) {
i = Math.max(0, Math.min(pages.length - 1, i));
if (!instant) { pager.scrollTo({ left: i * window.innerWidth, behavior: 'smooth' }); return; }
// scroll-snap-stop: always would stop a programmatic jump at the next page, so snapping is paused for the jump.
pager.style.scrollSnapType = 'none';
pager.scrollLeft = i * window.innerWidth;
onScroll();
clearTimeout(snapTimer);
snapTimer = setTimeout(() => (pager.style.scrollSnapType = ''), 120);
}
export function gotoPageRef(ref) {
const n = resolvePage(ref);
if (n < 0) return -1;
lastTouch = Date.now();
goPage(n, Math.abs(n - curPage) > 1);
return n;
}
let procsSort = 'cpu';
let procsSorter = null;
export function registerProcessesSorter(fn) {
procsSorter = typeof fn === 'function' ? fn : null;
if (procsSorter) procsSorter(procsSort);
}
export function currentProcessesSort() {
return procsSort;
}
export function hasProcessesPage() {
return resolvePage('procs') >= 0 || resolvePage('processes') >= 0
|| pages.some((p) => (p.widgets || []).some((w) => w.type === 'processes'));
}
export function gotoProcesses(sortTarget = 'cpu') {
procsSort = sortTarget === 'mem' ? 'mem' : 'cpu';
let idx = resolvePage('procs');
if (idx < 0) idx = resolvePage('processes');
if (idx < 0) idx = pages.findIndex((p) => (p.widgets || []).some((w) => w.type === 'processes'));
if (idx >= 0) gotoPageRef(idx + 1);
if (procsSorter) procsSorter(procsSort);
}
export const resolvePage = (ref) => {
const n = typeof ref === 'number' ? ref - 1 : /^\d+$/.test(String(ref)) ? +ref - 1 : pages.findIndex((p) => p.id === ref || p.title.toLowerCase() === String(ref).toLowerCase());
return n >= 0 && n < pages.length ? n : -1;
};
export const homeIndex = () => { const id = cfg.display?.homePage; const i = id ? pages.findIndex((p) => p.id === id) : -1; return i >= 0 ? i : 0; };
// ================================================================= calm page switching
// Rules, webhooks and the idle return ask for a page instead of jumping to it. A request waits while you are using the
// panel and while the last automatic switch is recent, then counts down on screen ("Switching to AI in 3…") so it can be
// stopped, and fades over. Requests that come one after another are merged: the highest priority goes first, a rule
// that ends before its turn is dropped, and a page that a rule holds is not taken over by a lower rule while it lasts.
// A rule that holds its page "while active" keeps it (no idle return, up to display.holdMaxMin); when the rule ends the
// panel goes back to the page it came from, unless you moved on yourself in the meantime.
export let ruleHold = null; // { rule, name, page, from, at, priority }
export let navQueue = []; // { kind: 'goto' | 'back' | 'home', rule, name, detail, page, hold, priority, countdown, at }
let navCount = null; // { req, until, total }
let navTimer = 0;
let lastAutoSwitch = 0;
let lastNav = 0; // last automatic switch (for the idle timer)
const dsp = () => cfg.display || {};
const pageLabel = (i) => { const p = pages[i]; return p ? `${p.icon || ''} ${p.title}`.trim() : ''; };
export function requestPage(d) {
if (PREVIEW) return;
const page = resolvePage(d.page);
if (page < 0) return;
const rule = d.rule || null;
if (rule) {
// the rule ended and came back before its "back" ran: just stay
const back = navQueue.find((r) => r.kind === 'back' && r.rule === rule);
navQueue = navQueue.filter((r) => r.rule !== rule);
if (back && ruleHold?.rule === rule && curPage === ruleHold.page) { ruleHold.at = Date.now(); return pump(); }
}
navQueue.push({ kind: 'goto', rule, name: d.name || null, detail: d.detail || '', page, hold: !!(d.hold && rule),
priority: d.priority ?? 5, countdown: d.countdown, at: Date.now() });
soon();
}
// events that arrive together (several rules at once) are looked at together, a moment later
function soon() { if (!navTimer) navTimer = setInterval(pump, 250); }
export function requestBack(rule) {
if (PREVIEW || !rule) return;
// the rule ended before its turn: nothing to undo
const queued = navQueue.findIndex((r) => r.kind === 'goto' && r.rule === rule);
if (queued >= 0) { navQueue.splice(queued, 1); return pump(); }
if (!ruleHold || ruleHold.rule !== rule || navQueue.some((r) => r.kind === 'back' && r.rule === rule)) return;
navQueue.push({ kind: 'back', rule, name: ruleHold.name, page: ruleHold.from, priority: ruleHold.priority, at: Date.now() });
soon();
}
/** Seconds of no touch after which this page goes home (0 = stay). */
const idleLimit = () => pages[curPage]?.idleSec ?? dsp().idleReturnSec ?? 90;
function idleDue() {
const idle = idleLimit();
if (!(idle > 0)) return false;
const maxHold = (dsp().holdMaxMin ?? 30) * 60_000;
const held = ruleHold && curPage === ruleHold.page && (!maxHold || Date.now() - ruleHold.at < maxHold);
return !held && Date.now() - Math.max(lastTouch, lastNav) > idle * 1000
&& (curPage !== homeIndex() || !!sheet || !$('overview').hidden);
}
function softGo(i) {
if (i === curPage) return;
if (Math.abs(i - curPage) === 1) { goPage(i); return; }
pager.classList.add('fade');
setTimeout(() => { goPage(i, true); pager.classList.remove('fade'); }, 320);
}
function perform(req) {
navQueue = navQueue.filter((r) => r !== req);
navCount = null;
if (req.kind === 'goto') {
// a rule taking over another rule's page inherits where that one came from
const from = ruleHold && curPage === ruleHold.page ? ruleHold.from : curPage;
navQueue = navQueue.filter((r) => r.kind !== 'back'); // whatever was going back is replaced by this
if (req.hold) ruleHold = { rule: req.rule, name: req.name, page: req.page, from, at: Date.now(), priority: req.priority };
else if (ruleHold && req.page !== ruleHold.page) ruleHold = null;
softGo(req.page);
} else if (req.kind === 'back') {
const h = ruleHold;
ruleHold = null;
if (h && curPage === h.page && h.from !== h.page) softGo(h.from);
} else {
ruleHold = null;
closeSheet(); closeOverview();
softGo(homeIndex());
}
if (req.kind !== 'home') lastAutoSwitch = Date.now();
lastNav = Date.now();
renderNav();
if (navQueue.length) setTimeout(pump, 0);
}
/** Drops a request (the Stay button). A dropped rule page also gives up its "back". */
function dismiss(req) {
haptic();
navQueue = navQueue.filter((r) => r !== req);
if (req.kind === 'home') lastTouch = Date.now();
navCount = null;
pump();
}
function nextRequest() {
const hold = ruleHold && curPage === ruleHold.page ? ruleHold : null;
navQueue = navQueue.filter((r) => (r.kind === 'back' ? hold && hold.rule === r.rule : r.kind === 'home' ? idleDue() : true));
// already there: a rule just takes the page over
for (const r of navQueue.filter((r) => r.kind === 'goto' && r.page === curPage)) {
if (r.hold && (!hold || r.priority >= hold.priority)) ruleHold = { rule: r.rule, name: r.name, page: r.page, from: hold ? hold.from : curPage, at: Date.now(), priority: r.priority };
navQueue = navQueue.filter((x) => x !== r);
}
const ending = hold && navQueue.some((r) => r.kind === 'back' && r.rule === hold.rule);
const blocked = (r) => r.kind === 'goto' && hold && !ending && r.priority < hold.priority;
const order = { goto: 0, back: 1, home: 2 };
return navQueue.filter((r) => !blocked(r)).sort((a, b) => order[a.kind] - order[b.kind] || b.priority - a.priority || a.at - b.at)[0] || null;
}
/** Why a request has to wait, or '' when it may go. */
function waitReason(req) {
if (!$('approval').hidden) return 'after the permission prompt';
if (req.kind === 'home') return '';
const grace = (dsp().touchGraceSec ?? 20) * 1000;
if (Date.now() - lastUserTouch < grace) return 'waits until you are done';
if (sheet || !$('overview').hidden) return 'waits until you are done';
const dwell = (dsp().minDwellSec ?? 15) * 1000 - (Date.now() - lastAutoSwitch);
if (dwell > 0) return `in ${Math.ceil(dwell / 1000)} s`;
return '';
}
export function pump() {
const req = nextRequest();
if (!req) { navCount = null; renderNav(); clearInterval(navTimer); navTimer = 0; return; }
if (!navTimer) navTimer = setInterval(pump, 250);
const why = waitReason(req);
if (why) { navCount = null; renderNav(req, why); return; }
const seconds = nightOn ? 0 : req.countdown ?? dsp().switchCountdownSec ?? 3;
if (!navCount || navCount.req !== req) {
if (seconds <= 0) return perform(req);
navCount = { req, until: Date.now() + seconds * 1000, total: seconds };
buzz('0,40');
}
if (Date.now() >= navCount.until) return perform(req);
renderNav(req);
}
let navShown = null;
let navWoken = null; // the waiting request or the countdown that last brightened a dimmed panel
function renderNav(req = null, why = '') {
const card = $('navCard'), chip = $('navChip');
if (!req) { card.classList.remove('on'); chip.classList.remove('on'); navShown = null; navWoken = null; return; }
// a new waiting request or countdown must not be hidden under the dark layer; this runs every 250 ms while one
// waits, so only its first appearance counts, or the panel would never dim behind a request that keeps waiting
const wakeKey = why ? req : navCount;
if (wakeKey !== navWoken) { navWoken = wakeKey; undim(); }
const name = req.name || (req.kind === 'home' ? 'Nobody around' : req.kind === 'back' ? 'Done' : 'Page switch');
const target = req.kind === 'home' ? pageLabel(homeIndex()) : pageLabel(req.page);
const more = navQueue.length > 1 ? ` +${navQueue.length - 1}` : '';
if (why) {
card.classList.remove('on');
chip.classList.add('on');
navShown = null;
$('ncText').textContent = `${name} → ${target}${more}`;
$('ncWhy').textContent = why;
$('ncGo').onclick = () => { haptic(); perform(req); };
$('ncX').onclick = () => dismiss(req);
return;
}
chip.classList.remove('on');
if (navShown !== navCount) {
navShown = navCount;
$('nvName').textContent = name;
$('nvLine').textContent = req.kind === 'goto' ? `Switching to ${target}` : `Back to ${target}`;
$('nvDetail').textContent = req.detail && req.detail !== 'test' ? req.detail : more ? `${navQueue.length - 1} more waiting` : '';
const ring = $('nvRing');
ring.style.animation = 'none';
void ring.getBoundingClientRect();
ring.style.animation = `nav-ring ${Math.max(0.1, (navCount.until - Date.now()) / 1000)}s linear forwards`;
$('nvStay').onclick = () => dismiss(req);
$('nvGo').onclick = () => { haptic(); perform(req); };
card.classList.add('on');
}
$('nvNum').textContent = Math.max(1, Math.ceil((navCount.until - Date.now()) / 1000));
}
/** Overview: pause the agent's rules for an hour, or resume them. */
export async function toggleRulesPause() {
haptic();
const paused = rulesPaused && rulesPaused > Date.now();
try {
const r = await api(`/rules/pause?minutes=${paused ? 0 : 60}`, 'POST', 4000);
rulesPaused = r.pausedUntil || null;
if (!rulesPaused) navQueue = navQueue.filter((x) => x.kind === 'home');
toast(rulesPaused ? 'Rules paused for an hour' : 'Rules are back on', 'ok');
} catch (_) { toast('The PC did not answer', 'warn'); }
renderRulesBtn();
}
export function renderRulesBtn() {
const b = $('ovRules');
if (!b) return;
const paused = rulesPaused && rulesPaused > Date.now();
b.hidden = PREVIEW || !online;
b.classList.toggle('on', !!paused);
b.textContent = paused ? `⏸ Rules paused until ${fmtHM(new Date(rulesPaused))} · resume` : '⏸ Pause rules 1 h';
}
export let titleTimer = 0;
export function flashTitle() {
const p = pages[curPage];
if (!p || pages.length < 2) return;
const t = $('ptitle');
t.textContent = `${p.icon || ''} ${p.title}`.trim();
t.classList.add('on');
clearTimeout(titleTimer);
titleTimer = setTimeout(() => t.classList.remove('on'), 900);
}
export function onScroll() {
const p = Math.round(pager.scrollLeft / window.innerWidth);
if (p === curPage) return;
curPage = p;
updateDots();
flashTitle();
setActive();
if (sheet) closeSheet();
if (pageOf('procs')) loadProcs();
}
pager.addEventListener('scroll', onScroll, { passive: true });
export function openOverview() {
haptic();
const ov = $('overview');
const tile = (p, i) => {
const map = h('div', 'ov-map', p.widgets.map((w) => {
const b = h('span', null, WIDGET_ICONS[w.type] || '▫️');
b.style.gridArea = `${w.y + 1} / ${w.x + 1} / span ${w.h} / span ${w.w}`;
return b;
}));
const t = h('button', 'ov-tile' + (i === curPage ? ' on' : ''), map, h('div', 'ov-name', h('b', null, `${i + 1}`), ` ${p.icon || ''} ${p.title}`));
t.addEventListener('click', (e) => { e.stopPropagation(); haptic(); closeOverview(); goPage(i, true); });
return t;
};
$('ovGrid').replaceChildren(...pages.map(tile));
renderRulesBtn();
// several paired computers: switch between them here (a USB cable always wins)
const pcs = pairedPcs();
$('ovPcs').replaceChildren(...(pcs.length > 1 ? pcs.map((pc) => {
const b = h('button', 'ov-pc' + (pc.active ? ' on' : ''), '🖥️ ' + (pc.name || 'Computer'));
b.addEventListener('click', (e) => { e.stopPropagation(); switchPc(pc); });
return b;
}) : []));
$('ovGrid').style.setProperty('--n', Math.min(4, Math.ceil(Math.sqrt(pages.length * 1.6))));
ov.hidden = false;
}
window.openOverview = openOverview;
/** The computers this phone is paired with over Wi-Fi: [{ id, name, active }] (empty outside the app). */
export function pairedPcs() {
try { return bridge?.pcs ? JSON.parse(bridge.pcs()) : []; } catch (_) { return []; }
}
/** Makes another paired computer the one used over Wi-Fi; the app then reloads the panel from it. */
export function switchPc(pc) {
if (!pc || pc.active || !bridge?.selectPc) return;
haptic(); toast(`Switching to ${pc.name || 'the other computer'}…`);
setTimeout(() => bridge.selectPc(pc.id), 300);
}
export function closeOverview() { $('overview').hidden = true; }
$('overview').addEventListener('click', closeOverview);
$('ovRules').addEventListener('click', (e) => { e.stopPropagation(); toggleRulesPause(); });
$('dots').addEventListener('click', openOverview);
// Long press on an empty part of a page opens the overview too.
export let pressTimer = 0;
export let pressAt = null;
pager.addEventListener('pointerdown', (e) => {
if (!e.target.classList.contains('grid') && !e.target.classList.contains('page')) return;
pressAt = [e.clientX, e.clientY];
pressTimer = setTimeout(() => { pressAt = null; openOverview(); }, 550);
});
pager.addEventListener('pointermove', (e) => { if (pressAt && Math.hypot(e.clientX - pressAt[0], e.clientY - pressAt[1]) > 12) { clearTimeout(pressTimer); pressAt = null; } });
['pointerup', 'pointercancel'].forEach((ev) => pager.addEventListener(ev, () => { clearTimeout(pressTimer); pressAt = null; }));
// Edge swipe wrap-around: swiping left on the last page wraps to the first page,
// and swiping right on the first page wraps to the last page.
let swipeStartX = 0;
let swipeStartY = 0;
let swipeStartPage = 0;
let swipeStartTime = 0;
let swipeTracking = false;
pager.addEventListener('touchstart', (e) => {
swipeTracking = false;
if (e.touches.length !== 1 || pages.length < 2) return;
// Dragging a slider, the volume bar, a list or the station carousel must never flip (or wrap) the page.
if (volDragging || e.target.closest?.('button, input, textarea, select, a, [contenteditable], .s-list, .vb, .radio-carousel-track')) return;
swipeStartX = e.touches[0].clientX;
swipeStartY = e.touches[0].clientY;
swipeStartPage = curPage;
swipeStartTime = Date.now();
swipeTracking = true;
}, { passive: true });
pager.addEventListener('touchend', (e) => {
if (!swipeTracking || !e.changedTouches || e.changedTouches.length !== 1 || pages.length < 2) {
swipeTracking = false;
return;
}
swipeTracking = false;
const dx = e.changedTouches[0].clientX - swipeStartX;
const dy = e.changedTouches[0].clientY - swipeStartY;
const dt = Date.now() - swipeStartTime;
if (dt < 700 && Math.abs(dx) >= 40 && Math.abs(dx) > Math.abs(dy) * 1.3) {
if (dx < 0 && swipeStartPage === pages.length - 1 && curPage === pages.length - 1) {
haptic();
goPage(0, true);
} else if (dx > 0 && swipeStartPage === 0 && curPage === 0) {
haptic();
goPage(pages.length - 1, true);
}
}
}, { passive: true });
pager.addEventListener('touchcancel', () => { swipeTracking = false; }, { passive: true });
/** Called by the Android shell (volume keys) and by arrow keys in a desktop browser. */
window.onAndroidKey = (k) => {
lastTouch = lastUserTouch = Date.now();
if (nightOn) { wakeUntil = Date.now() + 30_000; setNight(false); return; }
if (!$('overview').hidden) closeOverview();
if (k === 'next') goPage(curPage + 1 >= pages.length ? 0 : curPage + 1, true);
else if (k === 'prev') goPage(curPage - 1 < 0 ? pages.length - 1 : curPage - 1, true);
};
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') window.onAndroidKey('next');
else if (e.key === 'ArrowLeft') window.onAndroidKey('prev');
else if (e.key === 'Escape' || e.key === 'o') ($('overview').hidden ? openOverview() : closeOverview());
});
window.onAndroidBack = () => {
if (!$('approval').hidden) return; // answer it (or let it time out); Back must not dismiss a permission prompt
if (closeSessionDetails()) return;
if (!$('overview').hidden) closeOverview();
else if (sheet) closeSheet();
else if (curPage !== homeIndex()) goPage(homeIndex(), true);
else openOverview();
};
window.addEventListener('resize', () => { buildLayout(); goPage(curPage, true); });
['pointerdown', 'touchstart'].forEach((ev) => document.addEventListener(ev, (e) => {
if (dimmed) wakeFromDim();
if (e.target?.closest?.('#navCard, #navChip')) return;
lastTouch = lastUserTouch = Date.now();
if (navQueue.length) { navQueue = navQueue.filter((r) => r.kind !== 'home'); pump(); }
if (nightOn) { wakeUntil = Date.now() + 30_000; setNight(false); }
}, { passive: true, capture: true }));
// ---------------------------------------------------------------- clock tick & night mode
export function parseHM(s) { const [hh, mm] = (s || '0:0').split(':').map(Number); return hh * 60 + (mm || 0); }
export function isNight(now) {
const n = cfg.night || {};
if (!n.enabled) return false;
const t = now.getHours() * 60 + now.getMinutes();
const a = parseHM(n.start), b = parseHM(n.end);
return a <= b ? t >= a && t < b : t >= a || t < b;
}
export function setNight(on) {
if (on === nightOn) return;
nightOn = on;
document.body.classList.toggle('night', on);
applyBrightness(true);
}
// ---------------------------------------------------------------- phone sensors
// Room light (lux) and proximity come from the Android shell. Brightness can follow the room, the night screen can
// come on when the room goes dark, and a hand near the phone wakes the night screen.
export let lux = null;
export let darkSince = 0;
export let darkRoom = false;
export let lastBrightness = null;
/** 1 lux → 5 %, 10 → 37 %, 100 → 68 %, 1000+ lux → 100 % (perceived brightness is roughly logarithmic). */
export const luxToBrightness = (l) => Math.max(0.05, Math.min(1, 0.05 + (0.95 * Math.log10(l + 1)) / 3));
export function applyBrightness(force = false) {
if (!bridge) return;
let v = -1; // the phone's own setting
if (nightOn) v = cfg.night?.brightness ?? 0.02;
else if (cfg.display?.autoBrightness && lux != null) v = luxToBrightness(lux);
if (!force && lastBrightness != null && v >= 0 && lastBrightness >= 0 && Math.abs(v - lastBrightness) < 0.04) return;
lastBrightness = v;
try { bridge.setBrightness(v); } catch (_) {}
}
window.onAndroidSensor = (kind, value) => {
if (kind === 'light') {
lux = +value;
// dark room: below 3 lux for 30 s; light again above 15 lux
if (lux < 3) { darkSince ||= Date.now(); } else darkSince = 0;
if (darkRoom && lux > 15) darkRoom = false;
applyBrightness();
} else if (kind === 'proximity' && value === true && cfg.display?.proximityWake !== false && (nightOn || dimmed)) {
undim();
if (!nightOn) return;
wakeUntil = Date.now() + 30_000;
setNight(false);
}
};
// ---------------------------------------------------------------- OLED burn-in protection
// Pixel shift (display.pixelShift): every 2 minutes the panel steps along a small loop, at most 2 px from its place, so
// edges and text don't sit on the same pixels all day; the night clock, the most static thing on screen, drifts further.
// Idle dim (display.idleDimMin): after that many minutes without a touch, page switch, notification or prompt, a dark
// layer covers the panel. Like waking the night screen, the touch that brightens it does nothing else.
const SHIFT = [[0, 0], [1, 0], [2, 1], [1, 2], [0, 2], [-1, 1], [-2, 0], [-1, -1], [0, -2], [1, -1]];
const NIGHT_SHIFT = [[0, 0], [4, -3], [-4, 3], [3, 4], [-4, -2], [2, -4], [-2, 2], [4, 1], [-3, -4], [1, 3]]; // vmin
const SHIFT_MS = 120_000;
let shiftStep = -1;
let lastWake = Date.now();
let dimmed = false;
/** Something happened that the user should see: brighten a dimmed panel and restart its idle time. */
export function undim() {
lastWake = Date.now();
if (!dimmed) return;
dimmed = false;
document.body.classList.remove('dimmed');
}
// The dark layer keeps catching this touch until the finger lifts, so the widget under it is not pressed.
function wakeFromDim() {
const body = document.body.classList;
body.add('waking');
undim();
const done = () => {
clearTimeout(fallback);
['pointerup', 'pointercancel'].forEach((t) => document.removeEventListener(t, done, true));
setTimeout(() => body.remove('waking'), 300);
};
const fallback = setTimeout(done, 3000);
['pointerup', 'pointercancel'].forEach((t) => document.addEventListener(t, done, true));
}
export function applyBurnIn() {
const d = cfg.display || {};
const shift = d.pixelShift === true && !PREVIEW;
document.body.classList.toggle('shifted', shift);
const step = shift ? Math.floor(Date.now() / SHIFT_MS) % SHIFT.length : -1;
if (step !== shiftStep) {
shiftStep = step;
const [x, y] = step < 0 ? [0, 0] : SHIFT[step];
const [nx, ny] = step < 0 ? [0, 0] : NIGHT_SHIFT[step];
const root = document.documentElement.style;
root.setProperty('--shift-x', `${x}px`);
root.setProperty('--shift-y', `${y}px`);
root.setProperty('--night-x', `${nx}vmin`);
root.setProperty('--night-y', `${ny}vmin`);
}
const after = Math.max(0, Math.floor(+d.idleDimMin || 0)) * 60_000;
const dim = after > 0 && !PREVIEW && !nightOn && $('approval').hidden
&& Date.now() - Math.max(lastTouch, lastNav, lastWake) > after;
if (dim !== dimmed) {
dimmed = dim;
document.body.classList.toggle('dimmed', dim);
}
}
document.addEventListener('visibilitychange', () => { if (!document.hidden) undim(); }); // woken on plug-in, back from another app
export function tick() {
if (lastHour12 !== null && hour12() !== lastHour12) { buildLayout(); renderRulesBtn(); }
const now = new Date();
$('nClock').textContent = fmtHM(now);
$('nDate').textContent = now.toLocaleDateString(LOCALE, { weekday: 'long', day: 'numeric', month: 'long' });
if (darkSince && Date.now() - darkSince > 30_000) darkRoom = true;
const dark = darkRoom && cfg.display?.nightWhenDark === true;
setNight((isNight(now) || dark) && Date.now() > wakeUntil);
if (!PREVIEW && idleDue() && !navQueue.some((r) => r.kind === 'home')) {
if (curPage === homeIndex()) { closeSheet(); closeOverview(); lastNav = Date.now(); } // only a sheet to close
else { navQueue.push({ kind: 'home', at: Date.now(), priority: -1 }); pump(); }
}
applyBurnIn();
if (!nightOn) emit('tick');
setTimeout(tick, 1000 - (Date.now() % 1000) + 5);
}
// ---------------------------------------------------------------- data from the agent
export function applyDisplay() {
const d = cfg.display || {};
// theme and accent apply in the preview and the browser too
document.body.dataset.theme = ['amoled', 'midnight', 'warm', 'graphite'].includes(d.theme) ? d.theme : 'default';
const accent = hexToRgb(d.accent);
const before = DEFAULT_AMBIENT;
DEFAULT_AMBIENT = accent || '45, 212, 191';
document.documentElement.style.setProperty('--accent', `rgb(${DEFAULT_AMBIENT})`);
artTint = d.artTint !== false;
if (before !== DEFAULT_AMBIENT || !artTint) setAmbient(artTint ? currentArtColor : null);
renderRulesBtn();
if (PREVIEW || !bridge) return;
try { bridge.setVolumeKeys && bridge.setVolumeKeys(d.volumeKeys !== false); } catch (_) {}
try { bridge.setPinned && bridge.setPinned(d.pin === true); } catch (_) {}
try { bridge.setOrientation && bridge.setOrientation(['portrait', 'auto'].includes(d.orientation) ? d.orientation : 'landscape'); } catch (_) {}
try { bridge.setHomeScreen && bridge.setHomeScreen(d.homeScreen === true); } catch (_) {}
applyBrightness(true);
}
export async function loadConfig() {
if (PREVIEW) return;
try {
const r = await api('/config');
const before = JSON.stringify(cfg);
cfg = { ...DEFAULT_CFG, ...r.config };
cfgVersion = r.version;
if (JSON.stringify(cfg) !== before) {
store.set('cfg', cfg);
buildLayout();
applyDisplay();
loadWeather();
}
} catch (_) {}
}
// All-day events have a date but no time zone: the agent sends the UTC midnight of that date. Show them on that date in
// the phone's own zone (west of UTC they would land a day early, east of UTC run into the next day).
const localDay = (ms) => { const d = new Date(ms); return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()).getTime(); };
export async function loadCalendar() {
try {
const cal = await api('/calendar');
cal.events = (cal.events || []).map((e) => (e.allDay ? { ...e, start: localDay(e.start), end: localDay(e.end) } : e)).sort((a, b) => a.start - b.start);
D.calendar = cal;
emit('calendar');
} catch (_) {}
}
export async function loadQuotas() {
try { D.quotas = await api('/quotas'); emit('quotas'); } catch (_) {}
}
export function applyWork(w) {
D.work = w;
notifyTransitions(w.sessions || []);
emit('work');
}
export async function loadWork() {
try { applyWork(await api('/work')); } catch (_) {}
}
export async function loadProcs() {
try { D.procs = await api('/procs?top=150'); emit('procs'); } catch (_) {}
}
export async function loadPush() {
try {
const r = await api('/push');
D.push = r.items || {};
pushVersion = r.version;
emit('push');
} catch (_) {}
}
export function setOnline(on) {
document.body.classList.toggle('offline', !on);
if (on && online !== true) { loadConfig(); loadQuotas(); loadWork(); loadPush(); loadApprovals(); loadCalendar(); }
const changed = online !== on;
online = on;
if (changed) emit('state');
}
export function applyState(s) {
lastStateAt = Date.now();
D.state = s;
media = s.media ? { ...s.media, at: performance.now() } : null;
setOnline(true);
if (s.cfgVersion !== undefined && cfgVersion !== null && s.cfgVersion !== cfgVersion) loadConfig();
if (s.pushVersion !== undefined && s.pushVersion !== pushVersion) loadPush();
if (evSeq != null && s.evSeq != null && s.evSeq < evSeq) evSeq = s.evSeq; // the agent restarted: it counts from 0 again
if (evSeq != null) for (const e of s.events || []) if (e.seq > evSeq) handleEvent(e);
evSeq = Math.max(evSeq ?? 0, s.evSeq ?? 0);
// the "back" event can be missed while offline; the list of active rules settles it
if (ruleHold && Array.isArray(s.rules) && !s.rules.includes(ruleHold.rule) && Date.now() - ruleHold.at > 8000) requestBack(ruleHold.rule);
rulesPaused = s.rulesPaused || null;
if (!$('overview').hidden) renderRulesBtn();
{
const pc = s.pc;
if (pc) {
pushHist('cpu', pc.cpu);
pushHist('gpu', pc.gpu?.util);
pushHist('ram', pc.ramUsedGb);
const mem = gpuMem(pc.gpu);
pushHist('vram', mem ? (mem.used / mem.total) * 100 : 0);
pushHist('down', pc.netDownBps);
pushHist('up', pc.netUpBps);
pushHist('ping', s.ping?.ms);
pushHist('gputemp', pc.gpu?.temp);
pushHist('cputemp', pc.cpuTemp);
pushHist('hotspot', pc.gpu?.hotSpot);
pushHist('gpupower', pc.gpu?.power ?? pc.gpu?.powerPct);
pushHist('gpuclock', pc.gpu?.clock);
}
}
emit('state');
}
// Live stream from the Android shell (see AgentStream.kt); polling below is the fallback when it is not running.
export let streamOn = false;
export let lastStateAt = 0;
window.onAgentStream = (event, data) => {
if (event === 'open') { streamOn = true; return; }
if (event === 'close') { streamOn = false; return; }
let d;
try { d = JSON.parse(data); } catch (_) { return; }
if (event === 'state') applyState(d);
else if (event === 'work') applyWork(d);
else if (event === 'event' && d.seq > (evSeq ?? 0)) { evSeq = d.seq; handleEvent(d); }
};
export const streaming = () => streamOn && Date.now() - lastStateAt < 3000;
export async function poll() {
if (!streaming()) {
try { applyState(await api('/state' + (evSeq != null ? '?ev=' + evSeq : ''))); }
catch (_) { if (!streaming()) setOnline(false); }
}
setTimeout(poll, 1000);
}
// ---------------------------------------------------------------- start
buildLayout();
applyDisplay();
tick();
loadWeather();
setInterval(loadWeather, 15 * 60_000);
setInterval(() => { if (!nightOn) emit('fast'); }, 250);
setInterval(() => { if (online && anyNeeds('quotas')) loadQuotas(); }, 30_000);
setInterval(() => { if (online && anyNeeds('calendar')) loadCalendar(); }, 120_000);
setInterval(() => { if (online && anyNeeds('work') && !streaming()) loadWork(); }, 3000);
setInterval(() => { if (online && pageOf('procs')) loadProcs(); if (online && sheet) loadSheet(); }, 2000);
setInterval(() => { if (online) loadConfig(); }, 60_000);
document.addEventListener('visibilitychange', () => {
if (!document.hidden && lastHour12 !== null && hour12() !== lastHour12) {
buildLayout();
renderRulesBtn();
$('nClock').textContent = fmtHM(new Date());
}
});
poll();
if (PREVIEW) {
let weatherKey = '';
window.addEventListener('message', (e) => {
if (e.origin !== location.origin || e.data?.type !== 'phonebox-preview') return;
const c = e.data.config || {};
// no night screen and no idle return in the preview
cfg = { ...DEFAULT_CFG, ...c, night: { ...(c.night || {}), enabled: false }, display: { ...(c.display || {}), idleReturnSec: 0 } };
buildLayout();
applyDisplay();
const wk = JSON.stringify(cfg.weather);
if (wk !== weatherKey) { weatherKey = wk; loadWeather(); }
// the editor's page index counts hidden pages too; the panel only has the shown ones
const list = cfg.pages || [], sel = list[e.data.page];
const shown = (p) => p.enabled !== false && Array.isArray(p.widgets);
const i = sel && shown(sel) ? list.slice(0, e.data.page).filter(shown).length : -1;
if (i >= 0 && i !== curPage) goPage(i, true);
});
window.parent.postMessage({ type: 'phonebox-preview-ready' }, location.origin);
}
// ---------------------------------------------------------------- battery report (the agent's charge guard)
function reportBattery() {
if (PREVIEW || !bridge?.battery || !online) return;
try {
const b = JSON.parse(bridge.battery());
// the phone's view of its USB link lets the agent explain a cable that only charges (see UsbDoctor.cs)
const usb = b.usb ? ['connected', 'configured', 'adb'].filter((k) => b.usb[k]).join(',') || 'none' : '';
if (b.level >= 0) api(`/phone/battery?level=${b.level}&plugged=${!!b.plugged}&temp=${b.temp || 0}&source=${b.source || ''}&usb=${usb}`, 'POST', 4000).catch(() => {});
} catch (_) {}
}
setInterval(reportBattery, 30_000);
setTimeout(reportBattery, 5000);