dsh-autorestart 0.8.4 → 0.8.6

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +465 -40
  3. package/assets/icon-9c245e5b.ico +0 -0
  4. package/assets/icon-rescue-1902bb15.ico +0 -0
  5. package/assets/icon-rescue-1902bb15.png +0 -0
  6. package/client.js +3002 -170
  7. package/cordis.patch.yml +12 -0
  8. package/docs/README.zh.md +231 -23
  9. package/docs/platform-matrix.md +171 -55
  10. package/docs/real-machine-verification-0.8.6.md +202 -0
  11. package/index.js +13272 -8513
  12. package/lib/autostart-advice.js +31 -2
  13. package/lib/backup.js +2768 -21
  14. package/lib/checkpoint.js +214 -7
  15. package/lib/cookie-clear.js +260 -0
  16. package/lib/lifecycle.js +23 -40
  17. package/lib/plugin-compare.js +499 -0
  18. package/lib/plugin-failure.js +382 -0
  19. package/lib/plugin-recover.js +1557 -0
  20. package/lib/plugin-watch.js +213 -0
  21. package/lib/rescue-paths.js +982 -56
  22. package/lib/restart-phase.js +5 -0
  23. package/lib/restart-verdict.js +379 -0
  24. package/lib/restart.js +244 -7
  25. package/lib/scheduler-capability.js +19 -3
  26. package/lib/sleep.js +190 -29
  27. package/lib/watchdog-disarm.js +46 -2
  28. package/lib/watchdog-schedule.js +834 -17
  29. package/lib/watchdog-tier.js +8 -1
  30. package/lib/watchdog.js +1222 -47
  31. package/locale/en.json +175 -68
  32. package/locale/zh.json +175 -68
  33. package/package.json +3 -2
  34. package/screenshots.json +6 -6
  35. package/scripts/README.md +6 -3
  36. package/scripts/build-icon.mjs +998 -0
  37. package/scripts/clear-dsh-auth-cookies.mjs +257 -0
  38. package/scripts/emergency-disable.sh +69 -2
  39. package/scripts/emergency-toggle.ps1 +85 -69
  40. package/scripts/rescue-strings.zh.json +36 -18
  41. package/scripts/restore-profile.ps1 +182 -1
  42. package/scripts/watchdog.mjs +432 -79
@@ -0,0 +1,998 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build `assets/icon.ico` from `assets/icon-180.png` - DIB entries only.
4
+ *
5
+ * WHY THIS EXISTS AT ALL (user report, 2026-11-02: "the desktop shortcut still
6
+ * shows no icon", while `IconLocation` pointed at this very file and the file was
7
+ * there).
8
+ *
9
+ * MEASURED ON THE REAL MACHINE: the ICO that shipped was built by Pillow with
10
+ * PNG-compressed entries for every size (16/24/32/48/64/128/180 - all `PNG`).
11
+ * The classic Windows icon path - `ExtractIconEx`, `SHGetFileInfo`,
12
+ * `System.Drawing.Icon`, and Explorer's own image list - reads ICO entries as
13
+ * DIBs. Handed a PNG entry it does not fail loudly, it decodes the PNG bytes as
14
+ * raw pixels and paints garbage. Rendering the same 32x32 tile three ways:
15
+ *
16
+ * System.Drawing.Icon(png-entry ico) -> random noise ("+:=. .- #++ =%::*%:")
17
+ * System.Drawing.Icon(dib-entry ico) -> the ring + flame of the plugin's mark
18
+ * shell drawing a .lnk -> DIB ico -> the mark, plus the shortcut arrow
19
+ * shell drawing the real desktop .lnk -> a solid bright block (the bug)
20
+ *
21
+ * The control ("same .lnk format, `shell32.dll,3`") drew correctly, so the probe
22
+ * and the quoting are fine: PNG entries were the whole problem.
23
+ *
24
+ * WHY A SCRIPT AND NOT A ONE-OFF: an icon that can only be rebuilt by whoever
25
+ * happens to have Pillow installed is an icon that will drift. This reads the
26
+ * shipped PNG with `node:zlib` only (no dependencies), so `node scripts/build-icon.mjs`
27
+ * reproduces the artifact byte for byte, and the test suite can assert that.
28
+ *
29
+ * USAGE
30
+ * node scripts/build-icon.mjs # write assets/icon-<hash>.ico
31
+ * node scripts/build-icon.mjs --check # compare with the shipped file, do not write
32
+ *
33
+ * WHY THE FILE NAME CARRIES A HASH (user report, 2026-11-02, the second half of
34
+ * "still no icon"). Explorer caches the icon it drew PER ICON FILE PATH. Once it
35
+ * had cached "...\assets\icon.ico,0" while that file was the broken all-PNG one,
36
+ * fixing the file changed nothing: rewriting the shortcut, even deleting and
37
+ * recreating it, kept showing the cached garbage - measured, not guessed. A new
38
+ * PATH is a new cache entry, so the shipped name embeds the content hash and an
39
+ * update that changes the artwork necessarily changes the path.
40
+ *
41
+ * Sizes are capped at 180 (the artwork's own size): a 256 entry is the one size
42
+ * a DIB cannot carry, and a PNG entry for it would bring the whole bug back.
43
+ */
44
+
45
+ import fs from 'node:fs'
46
+ import path from 'node:path'
47
+ import crypto from 'node:crypto'
48
+ import zlib from 'node:zlib'
49
+ import { fileURLToPath } from 'node:url'
50
+
51
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
52
+ const PLUGIN_DIR = path.dirname(HERE)
53
+ export const SOURCE_PNG = path.join(PLUGIN_DIR, 'assets', 'icon-180.png')
54
+ export const ASSETS_DIR = path.join(PLUGIN_DIR, 'assets')
55
+
56
+ /** The shipped icon's name carries its content: a new build is a new cache key. */
57
+ export function iconFileNameFor(bytes) {
58
+ const digest = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 8)
59
+ return 'icon-' + digest + '.ico'
60
+ }
61
+
62
+ /** Where the icon for this artwork belongs. */
63
+ export function targetIcoPath(bytes) {
64
+ return path.join(ASSETS_DIR, iconFileNameFor(bytes))
65
+ }
66
+
67
+ /** The icon file the plugin currently ships (the newest hashed one). */
68
+ export function shippedIcoPath() {
69
+ let names = []
70
+ try {
71
+ names = fs.readdirSync(ASSETS_DIR)
72
+ } catch {
73
+ return ''
74
+ }
75
+ const hashed = names.filter((name) => /^icon-[0-9a-f]{8}\.ico$/i.test(name)).sort()
76
+ return hashed.length > 0 ? path.join(ASSETS_DIR, hashed[hashed.length - 1]) : ''
77
+ }
78
+
79
+ /** The sizes the shell asks for, small to large. */
80
+ export const ICON_SIZES = Object.freeze([16, 24, 32, 48, 64, 128, 180])
81
+
82
+ /* ------------------------------------------------------------------ *
83
+ * PNG reading (node:zlib only - no dependencies, no native modules)
84
+ * ------------------------------------------------------------------ */
85
+
86
+ /**
87
+ * Decode an 8-bit RGBA (colour type 6) PNG into `{ width, height, data }`.
88
+ *
89
+ * Deliberately narrow: the artwork this project ships is an 8-bit RGBA PNG, and
90
+ * guessing at every PNG variant here would be a second image library. Anything
91
+ * else is refused with a sentence instead of a wrong icon.
92
+ *
93
+ * @param {Buffer} buffer
94
+ * @returns {{ width: number, height: number, data: Buffer }}
95
+ */
96
+ export function decodePng(buffer) {
97
+ if (!Buffer.isBuffer(buffer) || buffer.length < 8) throw new Error('not a PNG: too short')
98
+ if (buffer.readUInt32BE(0) !== 0x89504e47 || buffer.readUInt32BE(4) !== 0x0d0a1a0a) {
99
+ throw new Error('not a PNG: bad signature')
100
+ }
101
+ let offset = 8
102
+ let width = 0
103
+ let height = 0
104
+ let bitDepth = 0
105
+ let colorType = 0
106
+ const idat = []
107
+ while (offset + 8 <= buffer.length) {
108
+ const length = buffer.readUInt32BE(offset)
109
+ const type = buffer.toString('ascii', offset + 4, offset + 8)
110
+ const start = offset + 8
111
+ const end = start + length
112
+ if (end > buffer.length) throw new Error('not a PNG: truncated chunk ' + type)
113
+ if (type === 'IHDR') {
114
+ width = buffer.readUInt32BE(start)
115
+ height = buffer.readUInt32BE(start + 4)
116
+ bitDepth = buffer[start + 8]
117
+ colorType = buffer[start + 9]
118
+ const interlace = buffer[start + 12]
119
+ if (bitDepth !== 8) throw new Error('unsupported PNG: bit depth ' + bitDepth + ' (need 8)')
120
+ if (colorType !== 6) throw new Error('unsupported PNG: colour type ' + colorType + ' (need 6 = RGBA)')
121
+ if (interlace !== 0) throw new Error('unsupported PNG: interlaced')
122
+ } else if (type === 'IDAT') {
123
+ idat.push(buffer.subarray(start, end))
124
+ } else if (type === 'IEND') {
125
+ break
126
+ }
127
+ offset = end + 4 // + CRC
128
+ }
129
+ if (width <= 0 || height <= 0) throw new Error('not a PNG: no IHDR')
130
+ if (idat.length === 0) throw new Error('not a PNG: no IDAT')
131
+ const raw = zlib.inflateSync(Buffer.concat(idat))
132
+ const stride = width * 4
133
+ if (raw.length < (stride + 1) * height) throw new Error('not a PNG: image data is short')
134
+ const out = Buffer.alloc(width * height * 4)
135
+ let previous = Buffer.alloc(stride)
136
+ for (let y = 0; y < height; y += 1) {
137
+ const filter = raw[y * (stride + 1)]
138
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
139
+ const current = Buffer.alloc(stride)
140
+ for (let x = 0; x < stride; x += 1) {
141
+ const rawByte = line[x]
142
+ const left = x >= 4 ? current[x - 4] : 0
143
+ const up = previous[x]
144
+ const upLeft = x >= 4 ? previous[x - 4] : 0
145
+ let value
146
+ switch (filter) {
147
+ case 0: value = rawByte; break
148
+ case 1: value = rawByte + left; break
149
+ case 2: value = rawByte + up; break
150
+ case 3: value = rawByte + ((left + up) >> 1); break
151
+ case 4: {
152
+ const p = left + up - upLeft
153
+ const pa = Math.abs(p - left)
154
+ const pb = Math.abs(p - up)
155
+ const pc = Math.abs(p - upLeft)
156
+ const predictor = pa <= pb && pa <= pc ? left : (pb <= pc ? up : upLeft)
157
+ value = rawByte + predictor
158
+ break
159
+ }
160
+ default: throw new Error('not a PNG: unknown filter ' + filter + ' on row ' + y)
161
+ }
162
+ current[x] = value & 0xff
163
+ }
164
+ current.copy(out, y * stride)
165
+ previous = current
166
+ }
167
+ return { width, height, data: out }
168
+ }
169
+
170
+ /**
171
+ * Box-filter downscale to a square size. Averaging is what makes a 180 px mark
172
+ * legible at 16 px; nearest-neighbour turns the flame into a smear.
173
+ *
174
+ * @param {{ width: number, height: number, data: Buffer }} image
175
+ * @param {number} size
176
+ * @returns {Buffer} size*size RGBA
177
+ */
178
+ export function resizeSquare(image, size) {
179
+ const { width, height, data } = image
180
+ const out = Buffer.alloc(size * size * 4)
181
+ for (let y = 0; y < size; y += 1) {
182
+ const y0 = Math.floor((y * height) / size)
183
+ const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * height) / size))
184
+ for (let x = 0; x < size; x += 1) {
185
+ const x0 = Math.floor((x * width) / size)
186
+ const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * width) / size))
187
+ let r = 0
188
+ let g = 0
189
+ let b = 0
190
+ let a = 0
191
+ let weight = 0
192
+ for (let sy = y0; sy < y1; sy += 1) {
193
+ for (let sx = x0; sx < x1; sx += 1) {
194
+ const at = (sy * width + sx) * 4
195
+ // PREMULTIPLIED WEIGHT: transparent pixels must not darken the edge.
196
+ const alpha = data[at + 3] / 255
197
+ r += data[at] * alpha
198
+ g += data[at + 1] * alpha
199
+ b += data[at + 2] * alpha
200
+ a += alpha
201
+ weight += 1
202
+ }
203
+ }
204
+ const at = (y * size + x) * 4
205
+ if (a > 0) {
206
+ out[at] = Math.round(r / a)
207
+ out[at + 1] = Math.round(g / a)
208
+ out[at + 2] = Math.round(b / a)
209
+ }
210
+ out[at + 3] = Math.round((a / Math.max(1, weight)) * 255)
211
+ }
212
+ }
213
+ return out
214
+ }
215
+
216
+ /* ------------------------------------------------------------------ *
217
+ * ICO writing - DIB (BITMAPINFOHEADER + BGRA) entries only
218
+ * ------------------------------------------------------------------ */
219
+
220
+ /**
221
+ * One DIB entry: BITMAPINFOHEADER + bottom-up BGRA rows.
222
+ *
223
+ * NO AND MASK, ON PURPOSE - and this was MEASURED, not assumed. With a 32-bpp
224
+ * entry the alpha channel carries the transparency; the AND mask is the legacy
225
+ * 1-bit form for the same job, and when both are present this Windows build
226
+ * applies the mask over the alpha and draws a ghost (the mark reduced to its
227
+ * outline). The three builds, each in its own shortcut so the shell had no cache
228
+ * to reuse, rendered:
229
+ *
230
+ * mask omitted -> the ring + flame (the artwork) <- this build
231
+ * mask = alpha<128 -> outline only, everything else blank
232
+ * mask = inverted -> outline only, everything else blank
233
+ *
234
+ * Pillow's ICO writer also omits the mask for 32-bpp entries (its 32 px entry is
235
+ * 40 + 32*32*4 bytes, exactly what this writes), which is why its files always
236
+ * rendered correctly and mine did not.
237
+ *
238
+ * @param {Buffer} rgba size*size RGBA
239
+ * @param {number} size
240
+ * @returns {Buffer}
241
+ */
242
+ export function encodeDibEntry(rgba, size) {
243
+ const header = Buffer.alloc(40)
244
+ header.writeUInt32LE(40, 0) // biSize
245
+ header.writeInt32LE(size, 4) // biWidth
246
+ header.writeInt32LE(size * 2, 8) // biHeight (XOR + AND), the icon convention
247
+ header.writeUInt16LE(1, 12) // biPlanes
248
+ header.writeUInt16LE(32, 14) // biBitCount
249
+ header.writeUInt32LE(0, 16) // BI_RGB
250
+ header.writeUInt32LE(size * size * 4, 20) // biSizeImage
251
+ const pixels = Buffer.alloc(size * size * 4)
252
+ for (let y = 0; y < size; y += 1) {
253
+ // Bottom-up, as every DIB is.
254
+ const sourceY = size - 1 - y
255
+ for (let x = 0; x < size; x += 1) {
256
+ const from = (sourceY * size + x) * 4
257
+ const to = (y * size + x) * 4
258
+ pixels[to] = rgba[from + 2] // B
259
+ pixels[to + 1] = rgba[from + 1] // G
260
+ pixels[to + 2] = rgba[from] // R
261
+ pixels[to + 3] = rgba[from + 3] // A
262
+ }
263
+ }
264
+ return Buffer.concat([header, pixels])
265
+ }
266
+
267
+ /**
268
+ * Build the whole `.ico` from an RGBA image.
269
+ *
270
+ * @param {{ width: number, height: number, data: Buffer }} image
271
+ * @param {number[]} [sizes]
272
+ * @returns {Buffer}
273
+ */
274
+ export function buildIco(image, sizes = ICON_SIZES) {
275
+ const entries = sizes.map((size) => ({ size, payload: encodeDibEntry(resizeSquare(image, size), size) }))
276
+ const header = Buffer.alloc(6)
277
+ header.writeUInt16LE(0, 0) // reserved
278
+ header.writeUInt16LE(1, 2) // 1 = icon
279
+ header.writeUInt16LE(entries.length, 4)
280
+ const directory = Buffer.alloc(entries.length * 16)
281
+ let offset = 6 + entries.length * 16
282
+ entries.forEach((entry, index) => {
283
+ const at = index * 16
284
+ directory[at] = entry.size >= 256 ? 0 : entry.size // width (0 = 256)
285
+ directory[at + 1] = entry.size >= 256 ? 0 : entry.size // height
286
+ directory[at + 2] = 0 // colour count (0 = truecolour)
287
+ directory[at + 3] = 0 // reserved
288
+ directory.writeUInt16LE(1, at + 4) // planes
289
+ directory.writeUInt16LE(32, at + 6) // bits per pixel
290
+ directory.writeUInt32LE(entry.payload.length, at + 8)
291
+ directory.writeUInt32LE(offset, at + 12)
292
+ offset += entry.payload.length
293
+ })
294
+ return Buffer.concat([header, directory, ...entries.map((entry) => entry.payload)])
295
+ }
296
+
297
+ /** Read the artwork and build the icon, from disk. */
298
+ export function buildIconFromDisk() {
299
+ return buildIco(decodePng(fs.readFileSync(SOURCE_PNG)))
300
+ }
301
+
302
+ /* ------------------------------------------------------------------ *
303
+ * A MINIMAL TRUETYPE READER (2026-11-02)
304
+ *
305
+ * WHY THIS EXISTS AT ALL: the approved shortcut icon is the DSH artwork with a
306
+ * round restore badge in the corner, and that badge carries the glyph U+21BB
307
+ * (CLOCKWISE OPEN CIRCLE ARROW) in Segoe UI Symbol. Drawing that glyph needs the
308
+ * font outline, and this generator may not take a dependency (no canvas, no
309
+ * browser, no ImageMagick). So the small part of TrueType that one glyph needs is
310
+ * read here: `cmap` (formats 4 and 12) to find the glyph, `loca` + `glyf` for the
311
+ * outline, and the contours are filled with a nonzero-winding scanline.
312
+ *
313
+ * WHAT IT DOES NOT DO: hinting, variations, colour fonts, kerning, shaping. One
314
+ * glyph, no text layout, no instruction execution.
315
+ * ------------------------------------------------------------------ */
316
+
317
+ /** Read the table directory of a .ttf/.otf and hand back a tag -> {offset} map. */
318
+ export function readTtfDirectory(buffer) {
319
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12) throw new Error('not a font: too short')
320
+ const tag = buffer.toString('ascii', 0, 4)
321
+ if (tag !== '\u0000\u0001\u0000\u0000' && tag !== 'true' && tag !== 'OTTO') {
322
+ throw new Error('not a TrueType/OpenType font: sfnt tag ' + JSON.stringify(tag))
323
+ }
324
+ const numTables = buffer.readUInt16BE(4)
325
+ const tables = new Map()
326
+ for (let index = 0; index < numTables; index += 1) {
327
+ const at = 12 + index * 16
328
+ tables.set(buffer.toString('ascii', at, at + 4), {
329
+ offset: buffer.readUInt32BE(at + 8),
330
+ length: buffer.readUInt32BE(at + 12),
331
+ })
332
+ }
333
+ return tables
334
+ }
335
+
336
+ /** The glyph index for one code point, from `cmap` (formats 4 and 12). */
337
+ export function glyphIndexFor(buffer, tables, codePoint) {
338
+ const cmap = tables.get('cmap')
339
+ if (!cmap) throw new Error('the font has no cmap')
340
+ const count = buffer.readUInt16BE(cmap.offset + 2)
341
+ let best = null
342
+ for (let index = 0; index < count; index += 1) {
343
+ const at = cmap.offset + 4 + index * 8
344
+ const platform = buffer.readUInt16BE(at)
345
+ const encoding = buffer.readUInt16BE(at + 2)
346
+ const subtable = cmap.offset + buffer.readUInt32BE(at + 4)
347
+ const format = buffer.readUInt16BE(subtable)
348
+ // (3,1)/(3,10) are what Windows uses; (0,x) is the Unicode platform.
349
+ const score = (platform === 3 && encoding === 10 && format === 12) ? 4
350
+ : (platform === 3 && encoding === 1 && format === 4) ? 3
351
+ : (platform === 0 && (format === 4 || format === 12)) ? 1
352
+ : 0
353
+ if (score === 0) continue
354
+ if (best === null || score > best.score) best = { score, subtable, format }
355
+ }
356
+ if (best === null) throw new Error('the font has no Unicode cmap subtable')
357
+ if (best.format === 12) {
358
+ const groups = buffer.readUInt32BE(best.subtable + 12)
359
+ for (let index = 0; index < groups; index += 1) {
360
+ const at = best.subtable + 16 + index * 12
361
+ const start = buffer.readUInt32BE(at)
362
+ const end = buffer.readUInt32BE(at + 4)
363
+ if (codePoint >= start && codePoint <= end) return buffer.readUInt32BE(at + 8) + (codePoint - start)
364
+ }
365
+ return 0
366
+ }
367
+ const segCountX2 = buffer.readUInt16BE(best.subtable + 6)
368
+ const endCodes = best.subtable + 14
369
+ const startCodes = endCodes + segCountX2 + 2
370
+ const idDeltas = startCodes + segCountX2
371
+ const idRangeOffsets = idDeltas + segCountX2
372
+ for (let index = 0; index < segCountX2 / 2; index += 1) {
373
+ const end = buffer.readUInt16BE(endCodes + index * 2)
374
+ if (codePoint > end) continue
375
+ const start = buffer.readUInt16BE(startCodes + index * 2)
376
+ if (codePoint < start) return 0
377
+ const delta = buffer.readInt16BE(idDeltas + index * 2)
378
+ const rangeOffset = buffer.readUInt16BE(idRangeOffsets + index * 2)
379
+ if (rangeOffset === 0) return (codePoint + delta) & 0xffff
380
+ const glyphAt = idRangeOffsets + index * 2 + rangeOffset + (codePoint - start) * 2
381
+ if (glyphAt + 1 >= buffer.length) return 0
382
+ const glyph = buffer.readUInt16BE(glyphAt)
383
+ return glyph === 0 ? 0 : (glyph + delta) & 0xffff
384
+ }
385
+ return 0
386
+ }
387
+
388
+ /** Everything one glyph needs, from the font tables. */
389
+ export function readFont(buffer) {
390
+ const tables = readTtfDirectory(buffer)
391
+ const head = tables.get('head')
392
+ const maxp = tables.get('maxp')
393
+ const hhea = tables.get('hhea')
394
+ const hmtx = tables.get('hmtx')
395
+ const loca = tables.get('loca')
396
+ const glyf = tables.get('glyf')
397
+ if (!head || !maxp || !hhea || !hmtx || !loca || !glyf) throw new Error('the font is missing a required table')
398
+ return {
399
+ buffer,
400
+ tables,
401
+ unitsPerEm: buffer.readUInt16BE(head.offset + 18),
402
+ longOffsets: buffer.readInt16BE(head.offset + 50) === 1,
403
+ numGlyphs: buffer.readUInt16BE(maxp.offset + 4),
404
+ numberOfHMetrics: buffer.readUInt16BE(hhea.offset + 34),
405
+ hmtx,
406
+ loca,
407
+ glyf,
408
+ }
409
+ }
410
+
411
+ /** The outline of one glyph as contours of {x, y, onCurve}, in font units. */
412
+ export function readGlyphOutline(font, glyphIndex, depth = 0) {
413
+ if (depth > 8) return []
414
+ if (!Number.isFinite(glyphIndex) || glyphIndex < 0 || glyphIndex >= font.numGlyphs) return []
415
+ const buffer = font.buffer
416
+ const locaAt = font.loca.offset + (font.longOffsets ? glyphIndex * 4 : glyphIndex * 2)
417
+ const start = font.longOffsets ? buffer.readUInt32BE(locaAt) : buffer.readUInt16BE(locaAt) * 2
418
+ const endAt = font.loca.offset + (font.longOffsets ? (glyphIndex + 1) * 4 : (glyphIndex + 1) * 2)
419
+ const end = font.longOffsets ? buffer.readUInt32BE(endAt) : buffer.readUInt16BE(endAt) * 2
420
+ if (end <= start) return []
421
+ const at = font.glyf.offset + start
422
+ const contours = buffer.readInt16BE(at)
423
+ if (contours < 0) {
424
+ // A composite glyph: place the components and let recursion do the rest.
425
+ const out = []
426
+ let cursor = at + 10
427
+ for (let guard = 0; guard < 32; guard += 1) {
428
+ const flags = buffer.readUInt16BE(cursor)
429
+ const componentGlyph = buffer.readUInt16BE(cursor + 2)
430
+ cursor += 4
431
+ const words = (flags & 0x0001) !== 0
432
+ let dx = 0
433
+ let dy = 0
434
+ if (flags & 0x0002) {
435
+ if (words) { dx = buffer.readInt16BE(cursor); dy = buffer.readInt16BE(cursor + 2); cursor += 4 }
436
+ else { dx = buffer.readInt8(cursor); dy = buffer.readInt8(cursor + 1); cursor += 2 }
437
+ } else {
438
+ cursor += words ? 4 : 2 // point matching is not needed for one glyph
439
+ }
440
+ if (flags & 0x0008) cursor += 2
441
+ else if (flags & 0x0040) cursor += 4
442
+ else if (flags & 0x0080) cursor += 8
443
+ for (const contour of readGlyphOutline(font, componentGlyph, depth + 1)) {
444
+ out.push(contour.map((point) => ({ x: point.x + dx, y: point.y + dy, onCurve: point.onCurve })))
445
+ }
446
+ if ((flags & 0x0020) === 0) break
447
+ }
448
+ return out
449
+ }
450
+ if (contours === 0) return []
451
+ let cursor = at + 10
452
+ const endPoints = []
453
+ for (let index = 0; index < contours; index += 1) {
454
+ endPoints.push(buffer.readUInt16BE(cursor))
455
+ cursor += 2
456
+ }
457
+ const instructionLength = buffer.readUInt16BE(cursor)
458
+ cursor += 2 + instructionLength
459
+ const pointCount = endPoints[endPoints.length - 1] + 1
460
+ const flags = []
461
+ while (flags.length < pointCount) {
462
+ const flag = buffer[cursor]
463
+ cursor += 1
464
+ flags.push(flag)
465
+ if (flag & 0x08) {
466
+ const repeat = buffer[cursor]
467
+ cursor += 1
468
+ for (let extra = 0; extra < repeat; extra += 1) flags.push(flag)
469
+ }
470
+ }
471
+ const xs = []
472
+ let x = 0
473
+ for (const flag of flags) {
474
+ if (flag & 0x02) {
475
+ const short = buffer[cursor]
476
+ cursor += 1
477
+ x += (flag & 0x10) ? short : -short
478
+ } else if ((flag & 0x10) === 0) {
479
+ x += buffer.readInt16BE(cursor)
480
+ cursor += 2
481
+ }
482
+ xs.push(x)
483
+ }
484
+ const ys = []
485
+ let y = 0
486
+ for (const flag of flags) {
487
+ if (flag & 0x04) {
488
+ const short = buffer[cursor]
489
+ cursor += 1
490
+ y += (flag & 0x20) ? short : -short
491
+ } else if ((flag & 0x20) === 0) {
492
+ y += buffer.readInt16BE(cursor)
493
+ cursor += 2
494
+ }
495
+ ys.push(y)
496
+ }
497
+ const out = []
498
+ let first = 0
499
+ for (const last of endPoints) {
500
+ const contour = []
501
+ for (let index = first; index <= last; index += 1) {
502
+ contour.push({ x: xs[index], y: ys[index], onCurve: (flags[index] & 0x01) !== 0 })
503
+ }
504
+ if (contour.length > 0) out.push(contour)
505
+ first = last + 1
506
+ }
507
+ return out
508
+ }
509
+
510
+ /**
511
+ * Flatten quadratic contours into polylines (font units).
512
+ *
513
+ * The TrueType rule for a run of off-curve points: each consecutive pair implies an
514
+ * ON-curve midpoint. A contour that starts with an off-curve point starts at the
515
+ * midpoint of the last and first point.
516
+ */
517
+ export function flattenContours(contours) {
518
+ const polylines = []
519
+ for (const contour of contours) {
520
+ if (contour.length === 0) continue
521
+ const points = contour.slice()
522
+ let startPoint
523
+ if (points[0].onCurve) {
524
+ startPoint = points[0]
525
+ points.push(points.shift())
526
+ } else if (points[points.length - 1].onCurve) {
527
+ startPoint = points[points.length - 1]
528
+ } else {
529
+ const last = points[points.length - 1]
530
+ startPoint = { x: (last.x + points[0].x) / 2, y: (last.y + points[0].y) / 2, onCurve: true }
531
+ }
532
+ const line = [{ x: startPoint.x, y: startPoint.y }]
533
+ let current = { x: startPoint.x, y: startPoint.y }
534
+ const queue = points.slice()
535
+ for (let index = 0; index < queue.length; index += 1) {
536
+ const point = queue[index]
537
+ if (point.onCurve) {
538
+ line.push({ x: point.x, y: point.y })
539
+ current = { x: point.x, y: point.y }
540
+ continue
541
+ }
542
+ const next = queue[index + 1] ?? { x: startPoint.x, y: startPoint.y, onCurve: true }
543
+ const end = next.onCurve
544
+ ? { x: next.x, y: next.y }
545
+ : { x: (point.x + next.x) / 2, y: (point.y + next.y) / 2 }
546
+ const steps = 8 // fine enough that the curve reads as a curve
547
+ for (let step = 1; step <= steps; step += 1) {
548
+ const t = step / steps
549
+ const mt = 1 - t
550
+ line.push({
551
+ x: mt * mt * current.x + 2 * mt * t * point.x + t * t * end.x,
552
+ y: mt * mt * current.y + 2 * mt * t * point.y + t * t * end.y,
553
+ })
554
+ }
555
+ current = end
556
+ if (!next.onCurve) index += 1 // the implied midpoint was just consumed
557
+ }
558
+ if (line.length >= 3) polylines.push(line)
559
+ }
560
+ return polylines
561
+ }
562
+
563
+ /** Fill polylines into a coverage buffer (0 or 255 per sample), nonzero winding. */
564
+ export function fillPolylines(polylines, width, height, { coverage = null } = {}) {
565
+ const buffer = coverage ?? new Uint8Array(width * height)
566
+ const edges = []
567
+ for (const line of polylines) {
568
+ for (let index = 0; index < line.length; index += 1) {
569
+ const from = line[index]
570
+ const to = line[(index + 1) % line.length]
571
+ if (from.y === to.y) continue
572
+ edges.push({ x0: from.x, y0: from.y, x1: to.x, y1: to.y })
573
+ }
574
+ }
575
+ for (let y = 0; y < height; y += 1) {
576
+ const sampleY = y + 0.5
577
+ const crossings = []
578
+ for (const edge of edges) {
579
+ const minY = Math.min(edge.y0, edge.y1)
580
+ const maxY = Math.max(edge.y0, edge.y1)
581
+ if (sampleY < minY || sampleY >= maxY) continue
582
+ const t = (sampleY - edge.y0) / (edge.y1 - edge.y0)
583
+ crossings.push({ x: edge.x0 + t * (edge.x1 - edge.x0), winding: edge.y1 > edge.y0 ? 1 : -1 })
584
+ }
585
+ if (crossings.length === 0) continue
586
+ crossings.sort((left, right) => left.x - right.x)
587
+ let winding = 0
588
+ for (let index = 0; index < crossings.length; index += 1) {
589
+ const previous = crossings[index - 1]
590
+ const from = index === 0 ? 0 : Math.ceil(previous.x - 0.5)
591
+ const to = Math.ceil(crossings[index].x - 0.5)
592
+ if (winding !== 0) {
593
+ for (let x = Math.max(0, from); x < Math.min(width, to); x += 1) buffer[y * width + x] = 255
594
+ }
595
+ winding += crossings[index].winding
596
+ }
597
+ }
598
+ return buffer
599
+ }
600
+
601
+ /* ------------------------------------------------------------------ *
602
+ * THE RESCUE ICON: THE DSH ARTWORK WITH A RESTORE BADGE (user decision)
603
+ *
604
+ * The user looked at a 256 px preview of exactly this and said "use that one", so
605
+ * the numbers below are a TRANSCRIPTION of the approved picture, not a new design.
606
+ * Every size is DRAWN at that size (supersampled 4x, then box-downsampled); a 16 px
607
+ * badge scaled down from a 256 px bitmap is a grey smudge, which is why the cache
608
+ * rule in the task says "seven sizes, each drawn directly".
609
+ * ------------------------------------------------------------------ */
610
+
611
+ /** The geometry the user approved, as fractions of the icon edge. */
612
+ export const RESCUE_LAYOUT = Object.freeze({
613
+ discRatio: 0.375, // badge disc diameter
614
+ marginRatio: 0.039, // disc bottom-right to canvas edge
615
+ ringRatio: 0.047, // white ring: disc plus this, on the diameter
616
+ glyphRatio: 0.203, // glyph size
617
+ glyphLiftRatio: 0.012, // the glyph sits this much above the optical centre
618
+ fill: Object.freeze({ r: 0x25, g: 0x63, b: 0xeb }), // #2563EB
619
+ ink: Object.freeze({ r: 0xff, g: 0xff, b: 0xff }),
620
+ })
621
+
622
+ /** How many samples per output pixel the badge is drawn with. */
623
+ export const RESCUE_SUPERSAMPLE = 4
624
+
625
+ /** A filled circle into a coverage buffer, in sample-grid coordinates. */
626
+ function fillCircle(coverage, size, cx, cy, radius) {
627
+ const minX = Math.max(0, Math.floor(cx - radius - 1))
628
+ const maxX = Math.min(size - 1, Math.ceil(cx + radius + 1))
629
+ const minY = Math.max(0, Math.floor(cy - radius - 1))
630
+ const maxY = Math.min(size - 1, Math.ceil(cy + radius + 1))
631
+ const r2 = radius * radius
632
+ for (let y = minY; y <= maxY; y += 1) {
633
+ const dy = y + 0.5 - cy
634
+ for (let x = minX; x <= maxX; x += 1) {
635
+ const dx = x + 0.5 - cx
636
+ if (dx * dx + dy * dy <= r2) coverage[y * size + x] = 255
637
+ }
638
+ }
639
+ return coverage
640
+ }
641
+
642
+ /**
643
+ * Draw the badge onto a transparent RGBA canvas of `size` x `size`.
644
+ *
645
+ * @param {number} size
646
+ * @param {{ glyphPolylines?: Array<Array<{x:number,y:number}>>, fontUnitsPerEm?: number }} [options]
647
+ * @returns {Buffer} size*size RGBA
648
+ */
649
+ export function drawRescueBadge(size, { glyphPolylines = [], fontUnitsPerEm = 2048 } = {}) {
650
+ const samples = size * RESCUE_SUPERSAMPLE
651
+ const disc = RESCUE_LAYOUT.discRatio * size
652
+ const margin = RESCUE_LAYOUT.marginRatio * size
653
+ const ring = disc + RESCUE_LAYOUT.ringRatio * size
654
+ const cx = (size - margin - disc / 2) * RESCUE_SUPERSAMPLE
655
+ const cy = (size - margin - disc / 2) * RESCUE_SUPERSAMPLE
656
+ const ringCoverage = new Uint8Array(samples * samples)
657
+ const discCoverage = new Uint8Array(samples * samples)
658
+ fillCircle(ringCoverage, samples, cx, cy, (ring / 2) * RESCUE_SUPERSAMPLE)
659
+ fillCircle(discCoverage, samples, cx, cy, (disc / 2) * RESCUE_SUPERSAMPLE)
660
+
661
+ // THE GLYPH, scaled into the disc and drawn several times a hair apart: that is
662
+ // the synthetic bold "Segoe UI Symbol Bold" stands for (the family ships no bold
663
+ // face), and it is what the approved preview shows.
664
+ const glyphCoverage = new Uint8Array(samples * samples)
665
+ if (Array.isArray(glyphPolylines) && glyphPolylines.length > 0) {
666
+ const targetPx = RESCUE_LAYOUT.glyphRatio * size
667
+ const scale = (targetPx / fontUnitsPerEm) * RESCUE_SUPERSAMPLE
668
+ let minX = Infinity
669
+ let minY = Infinity
670
+ let maxX = -Infinity
671
+ let maxY = -Infinity
672
+ for (const line of glyphPolylines) {
673
+ for (const point of line) {
674
+ if (point.x < minX) minX = point.x
675
+ if (point.x > maxX) maxX = point.x
676
+ if (point.y < minY) minY = point.y
677
+ if (point.y > maxY) maxY = point.y
678
+ }
679
+ }
680
+ if (Number.isFinite(minX) && maxX > minX) {
681
+ const inkWidth = (maxX - minX) * scale
682
+ const inkHeight = (maxY - minY) * scale
683
+ const left = cx - inkWidth / 2 - minX * scale
684
+ const bottom = cy + inkHeight / 2 + minY * scale // font Y grows up, the grid down
685
+ const lift = RESCUE_LAYOUT.glyphLiftRatio * size * RESCUE_SUPERSAMPLE
686
+ const bold = Math.max(0.5, 0.022 * targetPx * RESCUE_SUPERSAMPLE)
687
+ for (const shift of [-bold, 0, bold]) {
688
+ const placed = glyphPolylines.map((line) => line.map((point) => ({
689
+ x: point.x * scale + left + shift,
690
+ y: bottom - point.y * scale - lift,
691
+ })))
692
+ fillPolylines(placed, samples, samples, { coverage: glyphCoverage })
693
+ }
694
+ }
695
+ }
696
+
697
+ // Composite in sample space: ring (white), disc (blue), glyph (white), then
698
+ // box-downsample to the output size - the same anti-aliasing the base uses.
699
+ const canvas = Buffer.alloc(size * size * 4)
700
+ const per = RESCUE_SUPERSAMPLE * RESCUE_SUPERSAMPLE
701
+ for (let y = 0; y < size; y += 1) {
702
+ for (let x = 0; x < size; x += 1) {
703
+ let r = 0
704
+ let g = 0
705
+ let b = 0
706
+ let a = 0
707
+ for (let sy = 0; sy < RESCUE_SUPERSAMPLE; sy += 1) {
708
+ for (let sx = 0; sx < RESCUE_SUPERSAMPLE; sx += 1) {
709
+ const sample = (y * RESCUE_SUPERSAMPLE + sy) * samples + (x * RESCUE_SUPERSAMPLE + sx)
710
+ let sr = 0
711
+ let sg = 0
712
+ let sb = 0
713
+ let sa = 0
714
+ if (ringCoverage[sample] !== 0) {
715
+ sr = RESCUE_LAYOUT.ink.r; sg = RESCUE_LAYOUT.ink.g; sb = RESCUE_LAYOUT.ink.b; sa = 255
716
+ }
717
+ if (discCoverage[sample] !== 0) {
718
+ sr = RESCUE_LAYOUT.fill.r; sg = RESCUE_LAYOUT.fill.g; sb = RESCUE_LAYOUT.fill.b; sa = 255
719
+ }
720
+ if (glyphCoverage[sample] !== 0) {
721
+ sr = RESCUE_LAYOUT.ink.r; sg = RESCUE_LAYOUT.ink.g; sb = RESCUE_LAYOUT.ink.b; sa = 255
722
+ }
723
+ r += sr; g += sg; b += sb; a += sa
724
+ }
725
+ }
726
+ const at = (y * size + x) * 4
727
+ canvas[at] = Math.round(r / per)
728
+ canvas[at + 1] = Math.round(g / per)
729
+ canvas[at + 2] = Math.round(b / per)
730
+ canvas[at + 3] = Math.round(a / per)
731
+ }
732
+ }
733
+ return canvas
734
+ }
735
+
736
+ /** Alpha-composite `top` (size*size RGBA) over `bottom` (same size). */
737
+ export function compositeOver(bottom, top) {
738
+ const out = Buffer.from(bottom)
739
+ for (let at = 0; at < out.length; at += 4) {
740
+ const topAlpha = top[at + 3] / 255
741
+ if (topAlpha <= 0) continue
742
+ const bottomAlpha = out[at + 3] / 255
743
+ const outAlpha = topAlpha + bottomAlpha * (1 - topAlpha)
744
+ if (outAlpha <= 0) continue
745
+ for (let channel = 0; channel < 3; channel += 1) {
746
+ const value = (top[at + channel] * topAlpha + out[at + channel] * bottomAlpha * (1 - topAlpha)) / outAlpha
747
+ out[at + channel] = Math.round(Math.min(255, Math.max(0, value)))
748
+ }
749
+ out[at + 3] = Math.round(outAlpha * 255)
750
+ }
751
+ return out
752
+ }
753
+
754
+ /**
755
+ * The whole rescue set: the artwork with the badge, one bitmap per size, each drawn
756
+ * at its own size (never scaled from a bigger one).
757
+ *
758
+ * @param {{ width: number, height: number, data: Buffer }} artwork
759
+ * @param {Array<Array<{x:number,y:number}>>} glyphPolylines in font units
760
+ * @param {number} unitsPerEm
761
+ * @param {{ sizes?: number[] }} [options]
762
+ * @returns {Map<number, Buffer>} size -> size*size RGBA
763
+ */
764
+ export function buildRescueBitmaps(artwork, glyphPolylines, unitsPerEm, { sizes = RESCUE_SIZES } = {}) {
765
+ const bitmaps = new Map()
766
+ for (const size of sizes) {
767
+ const base = resizeSquare(artwork, size)
768
+ const badge = drawRescueBadge(size, { glyphPolylines, fontUnitsPerEm: unitsPerEm })
769
+ bitmaps.set(size, compositeOver(base, badge))
770
+ }
771
+ return bitmaps
772
+ }
773
+
774
+ /** Encode an RGBA bitmap as a PNG (node:zlib is enough; no dependencies). */
775
+ export function encodePng(rgba, width, height) {
776
+ const raw = Buffer.alloc((width * 4 + 1) * height)
777
+ for (let y = 0; y < height; y += 1) {
778
+ raw[y * (width * 4 + 1)] = 0 // filter: none
779
+ rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4)
780
+ }
781
+ const crc32 = (buffer) => {
782
+ let crc = 0xffffffff
783
+ for (let index = 0; index < buffer.length; index += 1) {
784
+ crc ^= buffer[index]
785
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
786
+ }
787
+ return (crc ^ 0xffffffff) >>> 0
788
+ }
789
+ const chunk = (type, data) => {
790
+ const length = Buffer.alloc(4)
791
+ length.writeUInt32BE(data.length, 0)
792
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
793
+ const crc = Buffer.alloc(4)
794
+ crc.writeUInt32BE(crc32(body), 0)
795
+ return Buffer.concat([length, body, crc])
796
+ }
797
+ const ihdr = Buffer.alloc(13)
798
+ ihdr.writeUInt32BE(width, 0)
799
+ ihdr.writeUInt32BE(height, 4)
800
+ ihdr[8] = 8 // bit depth
801
+ ihdr[9] = 6 // colour type: RGBA
802
+ return Buffer.concat([
803
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
804
+ chunk('IHDR', ihdr),
805
+ chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
806
+ chunk('IEND', Buffer.alloc(0)),
807
+ ])
808
+ }
809
+
810
+ /* ------------------------------------------------------------------ *
811
+ * THE RESCUE ICON SET, ON DISK
812
+ *
813
+ * NAMING (deliberate): the sha8 is taken from the ICO bytes - the artifact the
814
+ * desktop actually uses - and BOTH files carry it, so `icon-rescue-<sha8>.ico` and
815
+ * `icon-rescue-<sha8>.png` are two renderings of one build. The bytes the settings
816
+ * page shows are the 48 px PNG rendered by the same code, and `iconDataUriHash`
817
+ * states that PNG's own sha8, so "the page and the shortcut are the same drawing"
818
+ * is checkable instead of asserted.
819
+ * ------------------------------------------------------------------ */
820
+
821
+ /** The seven sizes the shell asks for, plus the 256 the page previews. */
822
+ export const RESCUE_ICO_SIZES = Object.freeze([16, 24, 32, 48, 64, 128, 180])
823
+ export const RESCUE_SIZES = Object.freeze([...RESCUE_ICO_SIZES, 256])
824
+ /** The size whose pixels the settings page is given. */
825
+ export const RESCUE_PAGE_SIZE = 48
826
+ /** The size whose pixels ship as the preview PNG. */
827
+ export const RESCUE_PREVIEW_SIZE = 256
828
+
829
+ /** The DSH artwork the user approved as the base (never the exe's 32 px bitmap). */
830
+ export const RESCUE_ARTWORK = 'C:/Users/Administrator/AppData/Local/Programs/DeepSeek Harness/resources/icon.png'
831
+ /** The font the approved preview used for U+21BB. */
832
+ export const RESCUE_FONT = path.join(process.env.WINDIR || 'C:/Windows', 'Fonts', 'seguisym.ttf')
833
+ /** The code point of the glyph on the badge. */
834
+ export const RESCUE_GLYPH = 0x21bb
835
+
836
+ /** Every drawing input the rescue icon needs, read from disk. */
837
+ export function readRescueSources({ artworkPath = RESCUE_ARTWORK, fontPath = RESCUE_FONT } = {}) {
838
+ const artwork = decodePng(fs.readFileSync(artworkPath))
839
+ const font = readFont(fs.readFileSync(fontPath))
840
+ const glyphIndex = glyphIndexFor(font.buffer, font.tables, RESCUE_GLYPH)
841
+ if (glyphIndex === 0) throw new Error('the font has no glyph for U+21BB: ' + fontPath)
842
+ const glyphPolylines = flattenContours(readGlyphOutline(font, glyphIndex))
843
+ if (glyphPolylines.length === 0) throw new Error('the U+21BB glyph in ' + fontPath + ' has an empty outline')
844
+ return { artwork, glyphPolylines, unitsPerEm: font.unitsPerEm }
845
+ }
846
+
847
+ /**
848
+ * Build the whole rescue set from disk.
849
+ *
850
+ * @returns {{ ico: Buffer, png: Buffer, page: Buffer, sizes: number[] }}
851
+ */
852
+ export function buildRescueFromDisk(options = {}) {
853
+ const { artwork, glyphPolylines, unitsPerEm } = readRescueSources(options)
854
+ const bitmaps = buildRescueBitmaps(artwork, glyphPolylines, unitsPerEm)
855
+ const entries = RESCUE_ICO_SIZES.map((size) => ({ size, rgba: bitmaps.get(size) }))
856
+ const ico = buildIcoFromBitmaps(entries)
857
+ return {
858
+ ico,
859
+ png: encodePng(bitmaps.get(RESCUE_PREVIEW_SIZE), RESCUE_PREVIEW_SIZE, RESCUE_PREVIEW_SIZE),
860
+ page: encodePng(bitmaps.get(RESCUE_PAGE_SIZE), RESCUE_PAGE_SIZE, RESCUE_PAGE_SIZE),
861
+ sizes: RESCUE_SIZES.slice(),
862
+ }
863
+ }
864
+
865
+ /** An ICO from ready-made bitmaps - DIB entries, no AND mask (see encodeDibEntry). */
866
+ export function buildIcoFromBitmaps(entries) {
867
+ const payloads = entries.map((entry) => ({ size: entry.size, payload: encodeDibEntry(entry.rgba, entry.size) }))
868
+ const header = Buffer.alloc(6)
869
+ header.writeUInt16LE(0, 0)
870
+ header.writeUInt16LE(1, 2)
871
+ header.writeUInt16LE(payloads.length, 4)
872
+ const directory = Buffer.alloc(payloads.length * 16)
873
+ let offset = 6 + payloads.length * 16
874
+ payloads.forEach((entry, index) => {
875
+ const at = index * 16
876
+ directory[at] = entry.size >= 256 ? 0 : entry.size
877
+ directory[at + 1] = entry.size >= 256 ? 0 : entry.size
878
+ directory[at + 2] = 0
879
+ directory[at + 3] = 0
880
+ directory.writeUInt16LE(1, at + 4)
881
+ directory.writeUInt16LE(32, at + 6)
882
+ directory.writeUInt32LE(entry.payload.length, at + 8)
883
+ directory.writeUInt32LE(offset, at + 12)
884
+ offset += entry.payload.length
885
+ })
886
+ return Buffer.concat([header, directory, ...payloads.map((entry) => entry.payload)])
887
+ }
888
+
889
+ /** The sha8 both rescue files are named after: the ICO's own content. */
890
+ export function rescueHashOf(icoBytes) {
891
+ return crypto.createHash('sha256').update(icoBytes).digest('hex').slice(0, 8)
892
+ }
893
+
894
+ /** Where this build's rescue files belong. */
895
+ export function rescueAssetPaths(icoBytes) {
896
+ const hash = rescueHashOf(icoBytes)
897
+ return {
898
+ hash,
899
+ ico: path.join(ASSETS_DIR, 'icon-rescue-' + hash + '.ico'),
900
+ png: path.join(ASSETS_DIR, 'icon-rescue-' + hash + '.png'),
901
+ }
902
+ }
903
+
904
+ /** The rescue files currently shipped (newest hashed name), or nulls. */
905
+ export function shippedRescuePaths() {
906
+ let names = []
907
+ try {
908
+ names = fs.readdirSync(ASSETS_DIR)
909
+ } catch {
910
+ return { ico: '', png: '' }
911
+ }
912
+ const ico = names.filter((name) => /^icon-rescue-[0-9a-f]{8}\.ico$/i.test(name)).sort()
913
+ const png = names.filter((name) => /^icon-rescue-[0-9a-f]{8}\.png$/i.test(name)).sort()
914
+ return {
915
+ ico: ico.length > 0 ? path.join(ASSETS_DIR, ico[ico.length - 1]) : '',
916
+ png: png.length > 0 ? path.join(ASSETS_DIR, png[png.length - 1]) : '',
917
+ }
918
+ }
919
+
920
+ /* ------------------------------------------------------------------ *
921
+ * CLI
922
+ * ------------------------------------------------------------------ */
923
+
924
+ const invokedDirectly = process.argv[1] !== undefined
925
+ && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
926
+
927
+ if (invokedDirectly) {
928
+ const checkOnly = process.argv.includes('--check')
929
+ // TWO ICONS, TWO MODES. `--rescue` builds the one the user approved (artwork +
930
+ // restore badge); without it the plain artwork is rebuilt, which is what the
931
+ // package used before and what the settings page shows as the plugin's own mark.
932
+ const rescue = process.argv.includes('--rescue')
933
+ const built = rescue ? buildRescueFromDisk() : { ico: buildIconFromDisk(), png: null }
934
+ if (rescue) {
935
+ const target = rescueAssetPaths(built.ico)
936
+ if (checkOnly) {
937
+ const shipped = shippedRescuePaths()
938
+ if (shipped.ico === '') {
939
+ console.error('no assets/icon-rescue-<hash>.ico is shipped; run: node scripts/build-icon.mjs --rescue')
940
+ process.exit(1)
941
+ }
942
+ if (path.basename(shipped.ico) !== path.basename(target.ico)) {
943
+ console.error('the shipped rescue icon is stale: ' + path.basename(shipped.ico)
944
+ + ' (expected ' + path.basename(target.ico) + ')')
945
+ console.error('run: node scripts/build-icon.mjs --rescue')
946
+ process.exit(1)
947
+ }
948
+ const pngOnDisk = fs.existsSync(shipped.png) ? fs.readFileSync(shipped.png) : null
949
+ if (pngOnDisk === null || !pngOnDisk.equals(built.png)) {
950
+ console.error('the shipped rescue preview PNG is missing or stale: ' + path.basename(shipped.png))
951
+ process.exit(1)
952
+ }
953
+ console.log('assets/' + path.basename(shipped.ico) + ' + ' + path.basename(shipped.png)
954
+ + ' match the approved drawing (' + built.ico.length + ' bytes, '
955
+ + RESCUE_ICO_SIZES.length + ' DIB entries)')
956
+ process.exit(0)
957
+ }
958
+ // ONE RESCUE ICON AT A TIME: a stale sibling keeps the old cache key alive.
959
+ for (const name of fs.readdirSync(ASSETS_DIR)) {
960
+ if (!/^icon-rescue-[0-9a-f]{8}\.(ico|png)$/i.test(name)) continue
961
+ if (name === path.basename(target.ico) || name === path.basename(target.png)) continue
962
+ fs.rmSync(path.join(ASSETS_DIR, name), { force: true })
963
+ console.log('removed the superseded rescue icon: ' + name)
964
+ }
965
+ fs.writeFileSync(target.ico, built.ico)
966
+ fs.writeFileSync(target.png, built.png)
967
+ console.log('wrote ' + target.ico + ' (' + built.ico.length + ' bytes, '
968
+ + RESCUE_ICO_SIZES.length + ' DIB entries: ' + RESCUE_ICO_SIZES.join('/') + ')')
969
+ console.log('wrote ' + target.png + ' (' + built.png.length + ' bytes, '
970
+ + RESCUE_PREVIEW_SIZE + ' px preview)')
971
+ process.exit(0)
972
+ }
973
+ const target = targetIcoPath(built.ico)
974
+ if (checkOnly) {
975
+ const shipped = shippedIcoPath()
976
+ if (shipped === '') {
977
+ console.error('no assets/icon-<hash>.ico is shipped; run: node scripts/build-icon.mjs')
978
+ process.exit(1)
979
+ }
980
+ if (path.basename(shipped) !== path.basename(target)) {
981
+ console.error('the shipped icon is stale: ' + path.basename(shipped) + ' (expected ' + path.basename(target) + ')')
982
+ console.error('run: node scripts/build-icon.mjs')
983
+ process.exit(1)
984
+ }
985
+ console.log('assets/' + path.basename(shipped) + ' matches the artwork (' + built.ico.length + ' bytes, all DIB entries)')
986
+ process.exit(0)
987
+ }
988
+ // ONE ICON AT A TIME: a stale sibling would keep the old cache key alive.
989
+ for (const name of fs.readdirSync(ASSETS_DIR)) {
990
+ if (!/^icon(-[0-9a-f]{8})?\.ico$/i.test(name)) continue
991
+ const full = path.join(ASSETS_DIR, name)
992
+ if (path.basename(target) === name) continue
993
+ fs.rmSync(full, { force: true })
994
+ console.log('removed the superseded icon: ' + name)
995
+ }
996
+ fs.writeFileSync(target, built.ico)
997
+ console.log('wrote ' + target + ' (' + built.ico.length + ' bytes, ' + ICON_SIZES.length + ' DIB entries: ' + ICON_SIZES.join('/') + ')')
998
+ }