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.
- package/README.md +2 -2
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +7 -0
- package/dist/src/cli/commands.js +94 -2
- package/dist/src/cli/json-schemas.js +25 -0
- package/dist/src/cli/main.js +104 -43
- package/dist/src/core/brand-mark.d.ts +75 -21
- package/dist/src/core/brand-mark.js +80 -34
- package/dist/src/core/config.d.ts +16 -0
- package/dist/src/core/config.js +17 -2
- package/dist/src/core/update.d.ts +209 -0
- package/dist/src/core/update.js +631 -0
- package/dist/src/desktop/icon.js +292 -61
- package/dist/src/panel/background.d.ts +2 -0
- package/dist/src/panel/background.js +7 -0
- package/dist/src/panel/handler.d.ts +32 -0
- package/dist/src/panel/handler.js +95 -5
- package/dist/src/panel/i18n.d.ts +29 -0
- package/dist/src/panel/i18n.js +58 -0
- package/dist/src/panel/page-script.js +133 -1
- package/dist/src/panel/page-styles.js +6 -2
- package/dist/src/panel/page.d.ts +2 -0
- package/dist/src/panel/page.js +4 -2
- package/dist/testing/index.js +2 -0
- package/package.json +2 -2
- package/schemas/config.json +15 -0
- package/schemas/update.json +115 -0
- package/skills/sideby/SKILL.md +5 -1
package/dist/src/desktop/icon.js
CHANGED
|
@@ -1,81 +1,312 @@
|
|
|
1
|
-
// The sideby mark drawn without dependencies: the
|
|
2
|
-
//
|
|
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,
|
|
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)
|
|
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
|
|
13
|
-
const
|
|
14
|
-
const off = (size -
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
{
|
|
49
|
-
|
|
50
|
-
...
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
394
|
+
const tile = tileRect(GRID.canvas);
|
|
164
395
|
const hex = (c) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
165
|
-
const V =
|
|
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(
|
|
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 {
|
|
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
|
-
//
|
|
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))
|