sideby 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,81 +1,312 @@
1
- // The sideby mark drawn without dependencies: the Panel header's logo (two faces side by side on a light rounded
2
- // tile, core/brand-mark.ts) rasterised with analytic antialiasing and encoded as PNG, SVG or ICNS.
1
+ // The sideby mark drawn without dependencies: the two buddies of core/brand-mark.ts on a light rounded tile,
2
+ // rasterised in pure Node and encoded as PNG, SVG or ICNS. Curves are flattened to polylines; fills use the nonzero
3
+ // rule with exact horizontal coverage on 16 sub-rows per pixel, outlines are round-capped, round-joined thick
4
+ // polylines (distance to a segment), ellipses use an analytic distance. Each shape is computed only inside its own
5
+ // bounding box and composited back to front, fill before outline, as an SVG renderer would.
3
6
  import { deflateSync } from 'node:zlib';
4
- import { MARK, MARK_COLORS, MARK_TILE_VIEWBOX, markSvgBody } from '../core/brand-mark.js';
7
+ import { BUDDY, highlights, MARK, MARK_COLORS, MARK_VIEWBOX, markSvgBody, } from '../core/brand-mark.js';
5
8
  /** Text stored in every generated file, so `sideby app uninstall` deletes only what it wrote. */
6
9
  export const APP_MARKER = 'sideby-app-install';
7
- // The Panel's logo tile (page-styles.ts: .logo 40×40, radius 10), with the mark in MARK_TILE_VIEWBOX units on it.
10
+ // The Panel's logo tile (page-styles.ts: .logo 40×40, radius 10); MARK_VIEWBOX maps onto the whole tile square.
8
11
  const TILE = { size: 40, radius: 10, color: [0xe5, 0xf2, 0xfb] };
9
12
  // The tile sits on the macOS icon grid: 824 of 1024 units, centred.
10
13
  const GRID = { canvas: 1024, tile: 824 };
14
+ // Sub-rows per pixel row for fills.
15
+ const SUB_ROWS = 16;
11
16
  const rgb = (hex) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
12
- function shapes(size) {
13
- const tilePx = (size * GRID.tile) / GRID.canvas;
14
- const off = (size - tilePx) / 2;
15
- const u = tilePx / MARK_TILE_VIEWBOX.size;
16
- const place = ([x, y, w, h, r]) => ({
17
- x: off + (x - MARK_TILE_VIEWBOX.x) * u,
18
- y: off + (y - MARK_TILE_VIEWBOX.y) * u,
19
- w: w * u,
20
- h: h * u,
21
- r: r * u,
22
- });
17
+ function tileRect(size) {
18
+ const w = (size * GRID.tile) / GRID.canvas;
19
+ const off = (size - w) / 2;
20
+ return { x: off, y: off, w, h: w, r: (TILE.radius * w) / TILE.size };
21
+ }
22
+ /** Pixels per mark unit and the map from a buddy's 64-unit grid to pixels. */
23
+ function placement(size, b) {
24
+ const tile = tileRect(size);
25
+ const u = tile.w / MARK_VIEWBOX.size;
26
+ const k = b.scale * u;
23
27
  return {
24
- tile: { x: off, y: off, w: tilePx, h: tilePx, r: (TILE.radius * tilePx) / TILE.size },
25
- back: place(MARK.back),
26
- front: place(MARK.front),
27
- gap: place(MARK.gap),
28
- backEyes: MARK.backEyes.map(place),
29
- frontEyes: MARK.frontEyes.map(place),
28
+ k,
29
+ map: (gx, gy) => [
30
+ tile.x + (b.x + b.scale * gx - MARK_VIEWBOX.x) * u,
31
+ tile.y + (b.y + b.scale * gy - MARK_VIEWBOX.y) * u,
32
+ ],
33
+ };
34
+ }
35
+ /** Parses absolute M, L, H, Q, C, Z path data and flattens it to pixel polylines. */
36
+ function flatten(d, map) {
37
+ const tokens = d.match(/[MLHQCZ]|-?\d*\.?\d+(?:e-?\d+)?/gi) ?? [];
38
+ const polys = [];
39
+ let cur = null;
40
+ let gx = 0;
41
+ let gy = 0;
42
+ let start = [0, 0];
43
+ let i = 0;
44
+ const num = () => Number(tokens[i++]);
45
+ const isNum = () => i < tokens.length && !/^[A-Za-z]$/.test(tokens[i]);
46
+ const lineTo = (x, y) => {
47
+ gx = x;
48
+ gy = y;
49
+ cur.pts.push(map(x, y));
50
+ };
51
+ const curve = (ctrl, end) => {
52
+ const all = [map(gx, gy), ...ctrl.map(([x, y]) => map(x, y)), map(end[0], end[1])];
53
+ let len = 0;
54
+ for (let j = 1; j < all.length; j++)
55
+ len += Math.hypot(all[j][0] - all[j - 1][0], all[j][1] - all[j - 1][1]);
56
+ const n = Math.max(4, Math.ceil(len / 2));
57
+ for (let s = 1; s <= n; s++) {
58
+ const t = s / n;
59
+ // De Casteljau on the pixel control points.
60
+ let p = all.map((q) => [q[0], q[1]]);
61
+ while (p.length > 1)
62
+ p = p.slice(1).map((q, j) => [p[j][0] + (q[0] - p[j][0]) * t, p[j][1] + (q[1] - p[j][1]) * t]);
63
+ cur.pts.push([p[0][0], p[0][1]]);
64
+ }
65
+ gx = end[0];
66
+ gy = end[1];
30
67
  };
68
+ let cmd = '';
69
+ while (i < tokens.length) {
70
+ if (!isNum())
71
+ cmd = tokens[i++].toUpperCase();
72
+ switch (cmd) {
73
+ case 'M':
74
+ gx = num();
75
+ gy = num();
76
+ start = [gx, gy];
77
+ cur = { pts: [map(gx, gy)], closed: false };
78
+ polys.push(cur);
79
+ cmd = 'L'; // further pairs after M are lines
80
+ break;
81
+ case 'L':
82
+ lineTo(num(), num());
83
+ break;
84
+ case 'H':
85
+ lineTo(num(), gy);
86
+ break;
87
+ case 'Q': {
88
+ const c = [num(), num()];
89
+ curve([c], [num(), num()]);
90
+ break;
91
+ }
92
+ case 'C': {
93
+ const c1 = [num(), num()];
94
+ const c2 = [num(), num()];
95
+ curve([c1, c2], [num(), num()]);
96
+ break;
97
+ }
98
+ case 'Z':
99
+ cur.closed = true;
100
+ gx = start[0];
101
+ gy = start[1];
102
+ break;
103
+ default:
104
+ throw new Error(`icon: unsupported path command ${cmd}`);
105
+ }
106
+ }
107
+ return polys;
108
+ }
109
+ function box(size, x0, y0, x1, y1) {
110
+ const bx0 = Math.max(0, Math.floor(x0));
111
+ const by0 = Math.max(0, Math.floor(y0));
112
+ const bx1 = Math.min(size, Math.ceil(x1));
113
+ const by1 = Math.min(size, Math.ceil(y1));
114
+ if (bx1 <= bx0 || by1 <= by0)
115
+ return null;
116
+ const w = bx1 - bx0;
117
+ const h = by1 - by0;
118
+ return { x0: bx0, y0: by0, w, h, a: new Float32Array(w * h) };
119
+ }
120
+ /** Nonzero fill of the polylines (each closed implicitly), exact in x and sampled on SUB_ROWS rows per pixel. */
121
+ function fillMask(size, polys) {
122
+ const edges = [];
123
+ let x0 = Infinity;
124
+ let y0 = Infinity;
125
+ let x1 = -Infinity;
126
+ let y1 = -Infinity;
127
+ for (const { pts } of polys)
128
+ for (let j = 0; j < pts.length; j++) {
129
+ const p = pts[j];
130
+ const q = pts[(j + 1) % pts.length];
131
+ if (p[1] !== q[1])
132
+ edges.push([p[0], p[1], q[0], q[1]]);
133
+ x0 = Math.min(x0, p[0]);
134
+ y0 = Math.min(y0, p[1]);
135
+ x1 = Math.max(x1, p[0]);
136
+ y1 = Math.max(y1, p[1]);
137
+ }
138
+ const m = box(size, x0, y0, x1, y1);
139
+ if (!m)
140
+ return null;
141
+ const hits = [];
142
+ for (let row = 0; row < m.h; row++)
143
+ for (let s = 0; s < SUB_ROWS; s++) {
144
+ const sy = m.y0 + row + (s + 0.5) / SUB_ROWS;
145
+ hits.length = 0;
146
+ for (const [ax, ay, bx, by] of edges) {
147
+ if (sy < Math.min(ay, by) || sy >= Math.max(ay, by))
148
+ continue;
149
+ hits.push([ax + ((sy - ay) * (bx - ax)) / (by - ay), by > ay ? 1 : -1]);
150
+ }
151
+ if (hits.length < 2)
152
+ continue;
153
+ hits.sort((p, q) => p[0] - q[0]);
154
+ let wind = 0;
155
+ for (let h = 0; h < hits.length - 1; h++) {
156
+ wind += hits[h][1];
157
+ if (wind === 0)
158
+ continue;
159
+ const xa = Math.max(hits[h][0], m.x0);
160
+ const xb = Math.min(hits[h + 1][0], m.x0 + m.w);
161
+ for (let px = Math.floor(xa); px < xb; px++) {
162
+ const cover = Math.min(xb, px + 1) - Math.max(xa, px);
163
+ if (cover > 0)
164
+ m.a[row * m.w + px - m.x0] += cover / SUB_ROWS;
165
+ }
166
+ }
167
+ }
168
+ return m;
169
+ }
170
+ /** Round-capped, round-joined line of width `w` along the polylines: distance to the nearest segment ≤ w/2. */
171
+ function strokeMask(size, polys, w) {
172
+ const r = w / 2;
173
+ const segs = [];
174
+ let x0 = Infinity;
175
+ let y0 = Infinity;
176
+ let x1 = -Infinity;
177
+ let y1 = -Infinity;
178
+ for (const { pts, closed } of polys) {
179
+ const n = closed ? pts.length : pts.length - 1;
180
+ for (let j = 0; j < Math.max(n, 1); j++) {
181
+ const p = pts[j];
182
+ const q = pts[(j + 1) % pts.length] ?? p;
183
+ segs.push([p[0], p[1], q[0], q[1]]);
184
+ x0 = Math.min(x0, p[0], q[0]);
185
+ y0 = Math.min(y0, p[1], q[1]);
186
+ x1 = Math.max(x1, p[0], q[0]);
187
+ y1 = Math.max(y1, p[1], q[1]);
188
+ }
189
+ }
190
+ const m = box(size, x0 - r - 1, y0 - r - 1, x1 + r + 1, y1 + r + 1);
191
+ if (!m)
192
+ return null;
193
+ const dist = new Float32Array(m.w * m.h).fill(Infinity);
194
+ for (const [ax, ay, bx, by] of segs) {
195
+ const dx = bx - ax;
196
+ const dy = by - ay;
197
+ const len2 = dx * dx + dy * dy;
198
+ const sx0 = Math.max(m.x0, Math.floor(Math.min(ax, bx) - r - 1));
199
+ const sx1 = Math.min(m.x0 + m.w, Math.ceil(Math.max(ax, bx) + r + 1));
200
+ const sy0 = Math.max(m.y0, Math.floor(Math.min(ay, by) - r - 1));
201
+ const sy1 = Math.min(m.y0 + m.h, Math.ceil(Math.max(ay, by) + r + 1));
202
+ for (let py = sy0; py < sy1; py++)
203
+ for (let px = sx0; px < sx1; px++) {
204
+ const cx = px + 0.5 - ax;
205
+ const cy = py + 0.5 - ay;
206
+ const t = len2 === 0 ? 0 : Math.min(1, Math.max(0, (cx * dx + cy * dy) / len2));
207
+ const d = Math.hypot(cx - t * dx, cy - t * dy);
208
+ const i = (py - m.y0) * m.w + px - m.x0;
209
+ if (d < dist[i])
210
+ dist[i] = d;
211
+ }
212
+ }
213
+ for (let i = 0; i < dist.length; i++)
214
+ m.a[i] = Math.min(1, Math.max(0, r + 0.5 - dist[i]));
215
+ return m;
216
+ }
217
+ /** Filled ellipse with an approximate signed distance (exact on the axes, good near the edge). */
218
+ function ellipseMask(size, cx, cy, rx, ry) {
219
+ const m = box(size, cx - rx - 1, cy - ry - 1, cx + rx + 1, cy + ry + 1);
220
+ if (!m)
221
+ return null;
222
+ for (let py = 0; py < m.h; py++)
223
+ for (let px = 0; px < m.w; px++) {
224
+ const x = m.x0 + px + 0.5 - cx;
225
+ const y = m.y0 + py + 0.5 - cy;
226
+ const k0 = Math.hypot(x / rx, y / ry);
227
+ const k1 = Math.hypot(x / (rx * rx), y / (ry * ry));
228
+ const d = k1 === 0 ? -Math.min(rx, ry) : (k0 * (k0 - 1)) / k1;
229
+ m.a[py * m.w + px] = Math.min(1, Math.max(0, 0.5 - d));
230
+ }
231
+ return m;
31
232
  }
32
233
  /** Signed distance from (px, py) to a rounded rectangle; negative inside. */
33
- function roundedRectDistance(px, py, s) {
234
+ function roundedRectMask(size, s) {
235
+ const m = box(size, s.x - 1, s.y - 1, s.x + s.w + 1, s.y + s.h + 1);
236
+ if (!m)
237
+ return null;
34
238
  const hx = s.w / 2;
35
239
  const hy = s.h / 2;
36
- const qx = Math.abs(px - (s.x + hx)) - (hx - s.r);
37
- const qy = Math.abs(py - (s.y + hy)) - (hy - s.r);
38
- const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
39
- return outside + Math.min(Math.max(qx, qy), 0) - s.r;
240
+ for (let py = 0; py < m.h; py++)
241
+ for (let px = 0; px < m.w; px++) {
242
+ const qx = Math.abs(m.x0 + px + 0.5 - (s.x + hx)) - (hx - s.r);
243
+ const qy = Math.abs(m.y0 + py + 0.5 - (s.y + hy)) - (hy - s.r);
244
+ const d = Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - s.r;
245
+ m.a[py * m.w + px] = Math.min(1, Math.max(0, 0.5 - d));
246
+ }
247
+ return m;
248
+ }
249
+ /** The shapes of one placed buddy, back to front, each with its colour. */
250
+ function buddyLayers(size, b) {
251
+ const { k, map } = placement(size, b);
252
+ const ink = rgb(MARK_COLORS.ink);
253
+ const fills = {
254
+ body: b.colors.body,
255
+ shade: b.colors.shade,
256
+ };
257
+ const layers = [];
258
+ for (const p of BUDDY.paths) {
259
+ const polys = flatten(p.d, map);
260
+ if (p.fill !== 'none')
261
+ layers.push({ mask: fillMask(size, polys), color: rgb(fills[p.fill]) });
262
+ const stroke = p.stroke === 'chevron' ? rgb(b.colors.chevron) : ink;
263
+ layers.push({ mask: strokeMask(size, polys, (p.width ?? MARK.placedLine) * k), color: stroke });
264
+ }
265
+ const dots = [...BUDDY.eyes, ...highlights(b.look)];
266
+ for (const e of dots) {
267
+ const [cx, cy] = map(e.cx, e.cy);
268
+ layers.push({
269
+ mask: ellipseMask(size, cx, cy, e.rx * k, e.ry * k),
270
+ color: rgb(e.fill === 'ink' ? MARK_COLORS.ink : MARK_COLORS.light),
271
+ });
272
+ }
273
+ return layers;
40
274
  }
41
275
  /** RGBA pixels (straight alpha) of the icon at `size`×`size`. */
42
276
  export function renderIcon(size) {
43
- const sh = shapes(size);
44
- const d = (r) => (x, y) => roundedRectDistance(x, y, r);
45
- // Inside `r` and outside the gap around the front face.
46
- const cut = (r) => (x, y) => Math.max(roundedRectDistance(x, y, r), -roundedRectDistance(x, y, sh.gap));
47
277
  const layers = [
48
- { dist: d(sh.tile), color: TILE.color },
49
- { dist: cut(sh.back), color: rgb(MARK_COLORS.back) },
50
- ...sh.backEyes.map((e) => ({ dist: cut(e), color: rgb(MARK_COLORS.eye) })),
51
- { dist: d(sh.front), color: rgb(MARK_COLORS.front) },
52
- ...sh.frontEyes.map((e) => ({ dist: d(e), color: rgb(MARK_COLORS.eye) })),
278
+ { mask: roundedRectMask(size, tileRect(size)), color: TILE.color },
279
+ ...buddyLayers(size, MARK.back),
280
+ ...buddyLayers(size, { ...MARK.front, colors: { ...MARK.front.colors, body: MARK_COLORS.blue.body } }),
53
281
  ];
54
- const px = new Uint8Array(size * size * 4);
55
- for (let y = 0; y < size; y++)
56
- for (let x = 0; x < size; x++) {
57
- // Premultiplied "over" compositing; coverage from the distance at the pixel centre.
58
- let r = 0;
59
- let g = 0;
60
- let b = 0;
61
- let a = 0;
62
- for (const l of layers) {
63
- const la = Math.min(1, Math.max(0, 0.5 - l.dist(x + 0.5, y + 0.5)));
64
- if (la === 0)
282
+ // Premultiplied "over" compositing, layer by layer inside each mask's box.
283
+ const acc = new Float32Array(size * size * 4);
284
+ for (const { mask: m, color } of layers) {
285
+ if (!m)
286
+ continue;
287
+ const [cr, cg, cb] = color.map((c) => c / 255);
288
+ for (let y = 0; y < m.h; y++)
289
+ for (let x = 0; x < m.w; x++) {
290
+ const la = Math.min(1, m.a[y * m.w + x]);
291
+ if (la <= 0)
65
292
  continue;
66
- r = (l.color[0] / 255) * la + r * (1 - la);
67
- g = (l.color[1] / 255) * la + g * (1 - la);
68
- b = (l.color[2] / 255) * la + b * (1 - la);
69
- a = la + a * (1 - la);
293
+ const i = ((m.y0 + y) * size + m.x0 + x) * 4;
294
+ acc[i] = cr * la + acc[i] * (1 - la);
295
+ acc[i + 1] = cg * la + acc[i + 1] * (1 - la);
296
+ acc[i + 2] = cb * la + acc[i + 2] * (1 - la);
297
+ acc[i + 3] = la + acc[i + 3] * (1 - la);
70
298
  }
71
- const i = (y * size + x) * 4;
72
- if (a > 0) {
73
- px[i] = Math.round((r / a) * 255);
74
- px[i + 1] = Math.round((g / a) * 255);
75
- px[i + 2] = Math.round((b / a) * 255);
76
- px[i + 3] = Math.round(a * 255);
77
- }
78
- }
299
+ }
300
+ const px = new Uint8Array(size * size * 4);
301
+ for (let i = 0; i < px.length; i += 4) {
302
+ const a = acc[i + 3];
303
+ if (a <= 0)
304
+ continue;
305
+ px[i] = Math.round((acc[i] / a) * 255);
306
+ px[i + 1] = Math.round((acc[i + 1] / a) * 255);
307
+ px[i + 2] = Math.round((acc[i + 2] / a) * 255);
308
+ px[i + 3] = Math.round(a * 255);
309
+ }
79
310
  return px;
80
311
  }
81
312
  const CRC_TABLE = (() => {
@@ -160,14 +391,14 @@ export function encodeIcns(pngs) {
160
391
  }
161
392
  /** The same mark as an SVG (1024-unit canvas), for Linux icon themes. */
162
393
  export function iconSvg() {
163
- const { tile } = shapes(GRID.canvas);
394
+ const tile = tileRect(GRID.canvas);
164
395
  const hex = (c) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
165
- const V = MARK_TILE_VIEWBOX;
396
+ const V = MARK_VIEWBOX;
166
397
  return [
167
398
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${GRID.canvas} ${GRID.canvas}" width="${GRID.canvas}" height="${GRID.canvas}">`,
168
399
  ` <!-- ${APP_MARKER} -->`,
169
400
  ` <rect x="${tile.x}" y="${tile.y}" width="${tile.w}" height="${tile.h}" rx="${tile.r}" fill="${hex(TILE.color)}"/>`,
170
- ` <svg x="${tile.x}" y="${tile.y}" width="${tile.w}" height="${tile.h}" viewBox="${V.x} ${V.y} ${V.size} ${V.size}">${markSvgBody('gap', MARK_COLORS.front)}</svg>`,
401
+ ` <svg x="${tile.x}" y="${tile.y}" width="${tile.w}" height="${tile.h}" viewBox="${V.x} ${V.y} ${V.size} ${V.size}">${markSvgBody(MARK_COLORS.blue.body)}</svg>`,
171
402
  '</svg>',
172
403
  '',
173
404
  ].join('\n');
@@ -16,6 +16,8 @@ export declare const logFilePath: (p: Paths) => string;
16
16
  export declare function readPidFile(p: Paths): Promise<PanelPidFile | undefined>;
17
17
  /** When `pid` started, as `ps` prints it; with the pid it names one process, which a reused pid does not match. */
18
18
  export declare const processStartTime: (pid: number) => Promise<string | undefined>;
19
+ /** The port of this state directory's background Panel when it runs and answers as itself, else null. */
20
+ export declare function backgroundPanelPort(p: Paths): Promise<number | null>;
19
21
  /** URL of a running background Panel, or of any sideby Panel on `port`; removes a stale pid file. */
20
22
  export declare function findRunningPanel(p: Paths, port: number): Promise<string | undefined>;
21
23
  export interface StartBackgroundOptions {
@@ -68,6 +68,13 @@ async function checkPanel(rec) {
68
68
  return stale(`the panel on port ${rec.port} is not the one it names`);
69
69
  return { status: 'ours', ...(id.build ? { build: id.build } : {}) };
70
70
  }
71
+ /** The port of this state directory's background Panel when it runs and answers as itself, else null. */
72
+ export async function backgroundPanelPort(p) {
73
+ const rec = await readPidFile(p);
74
+ if (!rec)
75
+ return null;
76
+ return (await checkPanel(rec)).status === 'ours' ? rec.port : null;
77
+ }
71
78
  /** URL of a running background Panel, or of any sideby Panel on `port`; removes a stale pid file. */
72
79
  export async function findRunningPanel(p, port) {
73
80
  const rec = await readPidFile(p);
@@ -3,6 +3,7 @@ import { type AppProfileStatus } from '../core/app-profiles.ts';
3
3
  import { type HandoffSettings } from '../core/config.ts';
4
4
  import { type AccountModel } from '../core/model-choice.ts';
5
5
  import type { RemoteControlGroup } from '../core/remote-control.ts';
6
+ import * as update from '../core/update.ts';
6
7
  import type { AccountStatus, DoctorHistory, FamilyInfo, Runtime } from '../runtime.ts';
7
8
  import type { Account, FamilyLogo, QuotaResult, QuotaSetupPlan, UsageResult } from '../types.ts';
8
9
  import { type PanelTheme } from './theme.ts';
@@ -31,6 +32,12 @@ export interface PanelHandlerOptions {
31
32
  * any other value throws a TypeError. A Portal that changes language creates the handler again.
32
33
  */
33
34
  lang?: 'auto' | 'en' | 'zh';
35
+ /**
36
+ * Whether the Panel offers sideby updates (spec §3.23, ADR-0021): the "new version" label, the Update button and
37
+ * the Auto Update switch. Default true; a Portal that manages sideby's version itself passes false, and the
38
+ * Panel then refuses `/api/update*` with 403.
39
+ */
40
+ updates?: boolean;
34
41
  }
35
42
  export type PanelHandler = ((req: IncomingMessage, res: ServerResponse) => Promise<boolean>) & {
36
43
  /** Per-instance secret the page sends as `x-sideby-token`. */
@@ -64,6 +71,29 @@ export declare function healthByAccount(history: DoctorHistory | undefined): Map
64
71
  export type PanelFamily = FamilyInfo & {
65
72
  logo?: FamilyLogo;
66
73
  };
74
+ /** What the Panel shows about updates (spec §3.23); absent when the handler was created with `updates: false`. */
75
+ export interface PanelUpdate {
76
+ current: string;
77
+ /** The newest version the last Update Check saw; absent before the first one. */
78
+ latest?: string;
79
+ available: boolean;
80
+ checkedAt?: string;
81
+ /** Why Update Checks are off; absent when they are on. */
82
+ checksOff?: string;
83
+ /** Config `update.auto`. */
84
+ auto: boolean;
85
+ method: {
86
+ kind: update.InstallKind;
87
+ canUpdate: boolean;
88
+ reason?: string;
89
+ };
90
+ /** The command an Update runs, or the one to run by hand when `canRun` is false. */
91
+ command?: string;
92
+ canRun: boolean;
93
+ releaseNotes?: string;
94
+ /** The command that updates the sideby skill, which sideby never runs itself. */
95
+ skill: string;
96
+ }
67
97
  export interface PanelState {
68
98
  schemaVersion: 1;
69
99
  version: string;
@@ -100,6 +130,8 @@ export interface PanelState {
100
130
  remoteControl?: RemoteControlView[];
101
131
  /** Grok Bot windows (ADR-0019), data folders shown as `~/…`; absent when there are none to show. */
102
132
  appProfiles?: AppProfileStatus[];
133
+ /** Update Check result and Auto Update setting (spec §3.23); absent with `updates: false`. */
134
+ update?: PanelUpdate;
103
135
  }
104
136
  /** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
105
137
  export declare function handoffByFamily(rt: Runtime, accounts: readonly PanelAccount[], now: Date): Record<string, string>;
@@ -2,7 +2,8 @@
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import { aliasesByAccount } from '../core/accounts.js';
4
4
  import { listAppProfiles, openAppProfile, removeAppProfile, } from '../core/app-profiles.js';
5
- import { aliasArgs, aliasRefs } from '../core/config.js';
5
+ import { FAVICON_SVG, ICON_SVG } from '../core/brand-mark.js';
6
+ import { aliasArgs, aliasRefs, editConfigUpdate } from '../core/config.js';
6
7
  import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
7
8
  import { UserError } from '../core/errors.js';
8
9
  import { planHandoff } from '../core/handoff.js';
@@ -11,7 +12,9 @@ import { handoffOverview } from '../core/handoff-overview.js';
11
12
  import { configureHandoff, handoffStatus } from '../core/handoff-status.js';
12
13
  import { accountModel, setAccountModel } from '../core/model-choice.js';
13
14
  import { tildify } from '../core/paths.js';
15
+ import { SKILL_INSTALL } from '../core/skill-check.js';
14
16
  import { MIN_CALLS } from '../core/tuning.js';
17
+ import * as update from '../core/update.js';
15
18
  import { buildId, packageVersion } from '../core/version.js';
16
19
  import { HOOK_FAMILIES } from '../handoff/hook.js';
17
20
  import { renderPage } from './page.js';
@@ -40,6 +43,36 @@ export function healthByAccount(history) {
40
43
  });
41
44
  return out;
42
45
  }
46
+ // One Update Check at a time per process, started by a state read when one is due (the cache also keeps a failed
47
+ // one from being tried again within the hour, though the page asks every 20 s).
48
+ let checking = null;
49
+ /**
50
+ * The `update` part of the state. A Panel that may write starts an Update Check in the background when one is due;
51
+ * a read-only one only shows what the cache holds.
52
+ */
53
+ async function updateState(rt, readOnly) {
54
+ const current = packageVersion();
55
+ const off = update.checksOff(rt.env, rt.config);
56
+ const cache = await update.readUpdateCache(rt.paths);
57
+ if (!off && !readOnly && !checking && update.checkDue(cache, new Date()))
58
+ checking = update.refreshUpdateCheck(rt.paths, rt.env).finally(() => {
59
+ checking = null;
60
+ });
61
+ const method = await update.installMethod(rt.env);
62
+ const plan = cache.latest ? update.shownPlan(update.planFrom(method, current, cache.latest, rt.env)) : null;
63
+ return {
64
+ current,
65
+ ...(cache.latest ? { latest: cache.latest } : {}),
66
+ available: Boolean(plan?.available),
67
+ ...(cache.checkedAt ? { checkedAt: cache.checkedAt } : {}),
68
+ ...(off ? { checksOff: off } : {}),
69
+ auto: rt.config.update?.auto === true,
70
+ method: update.shownMethod(method),
71
+ ...(plan ? { command: plan.command, releaseNotes: plan.releaseNotes } : {}),
72
+ canRun: Boolean(plan?.canRun),
73
+ skill: SKILL_INSTALL,
74
+ };
75
+ }
43
76
  /** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
44
77
  export function handoffByFamily(rt, accounts, now) {
45
78
  const out = {};
@@ -203,12 +236,13 @@ function rcView(groups, home) {
203
236
  }
204
237
  async function buildState(rt, opts) {
205
238
  const ctx = rt.readContext();
206
- const [families, accounts, history, quotaSetups, remoteControl] = await Promise.all([
239
+ const [families, accounts, history, quotaSetups, remoteControl, upd] = await Promise.all([
207
240
  rt.familyInfo(),
208
241
  rt.accounts(),
209
242
  rt.doctorHistory(),
210
243
  rt.quotaSetups(),
211
244
  rt.remoteControl(),
245
+ opts.updates ? updateState(rt, opts.readOnly).catch(() => undefined) : Promise.resolve(undefined),
212
246
  ]);
213
247
  const health = healthByAccount(history);
214
248
  const aliases = aliasesByAccount(aliasRefs(rt.config.aliases), accounts);
@@ -259,6 +293,7 @@ async function buildState(rt, opts) {
259
293
  .filter((f) => f.handoff?.starts.length && HOOK_FAMILIES.includes(f.id))
260
294
  .map((f) => f.id),
261
295
  },
296
+ ...(upd ? { update: upd } : {}),
262
297
  };
263
298
  }
264
299
  /** The `change` of a /api/handoff/configure body, with every field checked; unknown fields are refused. */
@@ -318,6 +353,7 @@ export function createPanelHandler(opts) {
318
353
  const build = buildId();
319
354
  const theme = resolveTheme(opts.theme);
320
355
  const lang = resolveLang(opts.lang);
356
+ const updates = opts.updates !== false;
321
357
  const token = randomBytes(32).toString('base64url');
322
358
  const serial = writeQueue();
323
359
  const getRuntime = typeof opts.runtime === 'function' ? opts.runtime : async () => opts.runtime;
@@ -543,7 +579,51 @@ export function createPanelHandler(opts) {
543
579
  return { ...r, plan: show(r.plan) };
544
580
  },
545
581
  },
546
- // Undo for the setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
582
+ // Update (spec §3.23, ADR-0021): asks the registry, and runs the plan only with `confirm` and the `version` the
583
+ // person saw. When the registry names another version now, nothing runs and the new plan comes back (`changed`).
584
+ // It writes the cache even without `confirm`, so a read-only Panel refuses it (writes defaults to true).
585
+ '/api/update': {
586
+ async run(body) {
587
+ if (!updates)
588
+ throw new HttpError(403, 'updates are managed by the page that shows this panel');
589
+ const rt = await getRuntime();
590
+ const plan = await update.planUpdate(rt.paths, rt.env);
591
+ const shown = update.shownPlan(plan);
592
+ if (!optBool(body, 'confirm'))
593
+ return { ...shown, updated: false };
594
+ // A confirmation that runs nothing always says why: the registry names another version (or none newer) now.
595
+ if (body.version !== plan.latest || !plan.available)
596
+ return { ...shown, updated: false, changed: true };
597
+ const { backgroundPanelPort } = await import('./background.js');
598
+ const r = await update.applyUpdate(rt.paths, plan, rt.env, {
599
+ panelPort: () => backgroundPanelPort(rt.paths),
600
+ });
601
+ if (!r.ok)
602
+ throw new UserError(r.message);
603
+ return { ...shown, updated: true, message: r.message, restartedPanel: r.restartedPanel };
604
+ },
605
+ },
606
+ // The Auto Update switch: config `update.auto`, shown as a diff first like every config change.
607
+ '/api/update/settings': {
608
+ writes: (body) => body.confirm === true,
609
+ async run(body) {
610
+ if (!updates)
611
+ throw new HttpError(403, 'updates are managed by the page that shows this panel');
612
+ if (typeof body.auto !== 'boolean')
613
+ throw new HttpError(400, '"auto" must be true or false');
614
+ const auto = body.auto;
615
+ const rt = await getRuntime();
616
+ const r = await editConfigUpdate(rt.paths.configFile, (cur) => ({ ...cur, auto }), {
617
+ dryRun: !optBool(body, 'confirm'),
618
+ });
619
+ return {
620
+ ...r,
621
+ applied: optBool(body, 'confirm') && r.changed,
622
+ file: tildify(rt.paths.configFile, rt.paths.home),
623
+ };
624
+ },
625
+ },
626
+ // Undo for the quota setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
547
627
  '/api/quota/teardown': {
548
628
  async run(body) {
549
629
  const family = optString(body, 'family');
@@ -591,7 +671,17 @@ export function createPanelHandler(opts) {
591
671
  });
592
672
  res.end(method === 'HEAD'
593
673
  ? undefined
594
- : renderPage({ basePath, token, nonce, version, build, readOnly, theme, lang }));
674
+ : renderPage({ basePath, token, nonce, version, build, readOnly, theme, lang, updates }));
675
+ return true;
676
+ }
677
+ // The mark, for a Portal's tab and for this page's favicon (ADR-0006 amendment).
678
+ if (sub === '/icon.svg' || sub === '/favicon.svg') {
679
+ res.writeHead(200, {
680
+ 'content-type': 'image/svg+xml; charset=utf-8',
681
+ 'cache-control': 'no-cache',
682
+ 'x-content-type-options': 'nosniff',
683
+ });
684
+ res.end(method === 'HEAD' ? undefined : sub === '/icon.svg' ? ICON_SVG : FAVICON_SVG);
595
685
  return true;
596
686
  }
597
687
  if (sub === '/api/health') {
@@ -607,7 +697,7 @@ export function createPanelHandler(opts) {
607
697
  return true;
608
698
  }
609
699
  if (sub === '/api/state') {
610
- send(res, 200, await buildState(await getRuntime(), { version, build, readOnly }));
700
+ send(res, 200, await buildState(await getRuntime(), { version, build, readOnly, updates }));
611
701
  return true;
612
702
  }
613
703
  if (Object.hasOwn(routes, sub))