@nissyai/desktop 0.2.35 → 0.2.38

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/src/store.mjs ADDED
@@ -0,0 +1,977 @@
1
+ /**
2
+ * The versioned application store.
3
+ *
4
+ * WHY THIS EXISTS
5
+ *
6
+ * The npm channel used to update itself by running `npm i -g`. That cannot be made
7
+ * safe. npm's global reify renames the installed package to a hidden directory before
8
+ * unpacking the replacement, and rolls that rename back only on a best-effort basis. On
9
+ * Windows the running `nissy.exe` is locked, so the unpack fails, the rollback fails
10
+ * too, and the package is gone from npm's view — an update that ends with no
11
+ * application installed at all. That has happened to a real machine.
12
+ *
13
+ * The replacement never lets anything mutate the copy that is running. Each version
14
+ * lives in its own directory and a pointer says which one is current:
15
+ *
16
+ * <cacheRoot>/store/
17
+ * versions/0.2.35/ one extracted application per version
18
+ * versions/0.2.36/
19
+ * current -> pointer at the version that runs
20
+ * previous -> pointer at the one before, kept for rollback
21
+ * pending.json a staged version waiting for a restart
22
+ * health.json the last version that started successfully
23
+ *
24
+ * Updating is: download beside the running copy, verify it, then move a pointer. The
25
+ * worst outcome of any failure is "still on the old version", because the old version
26
+ * was never touched.
27
+ *
28
+ * WHAT LIVES HERE
29
+ *
30
+ * Path resolution, the small JSON state files, and the DECISIONS — seed, apply, roll
31
+ * back, prune — as pure functions. The filesystem work and the platform-specific
32
+ * pointer mechanics (a directory junction on Windows, the `~/Applications` bundle on
33
+ * macOS) stay in the launcher, so every rule below is unit-testable without a store on
34
+ * disk.
35
+ */
36
+
37
+ import {
38
+ appendFileSync,
39
+ closeSync,
40
+ openSync,
41
+ readSync,
42
+ copyFileSync,
43
+ existsSync,
44
+ mkdirSync,
45
+ readFileSync,
46
+ linkSync,
47
+ readdirSync,
48
+ realpathSync,
49
+ rmSync,
50
+ statSync,
51
+ symlinkSync,
52
+ writeFileSync,
53
+ } from "node:fs";
54
+ import { basename, join } from "node:path";
55
+
56
+ import { cacheRootSegments } from "./resolve.mjs";
57
+
58
+ /** Subdirectory of the per-user cache root that holds the whole store. */
59
+ export const STORE_DIRNAME = "store";
60
+
61
+ /**
62
+ * Written last inside a version directory, and required before that version is
63
+ * considered usable.
64
+ *
65
+ * This replaces an earlier staging-plus-rename. Renaming a freshly populated
66
+ * 450 MB tree fails with EPERM on Windows often enough to matter: the seed hard
67
+ * links from a just-extracted copy, so Defender is still scanning those inodes,
68
+ * and a directory containing an open handle cannot be renamed. Observed on a
69
+ * real machine seeding two published builds back to back. Writing in place and
70
+ * marking completion at the end removes the rename entirely rather than
71
+ * retrying it, and gives the same guarantee: a directory without this file was
72
+ * never finished and is treated as absent.
73
+ */
74
+ export const SEED_COMPLETE_MARKER = ".seed-complete";
75
+
76
+ /**
77
+ * Resolve every path in the store for a host.
78
+ *
79
+ * @param {{ platform: NodeJS.Platform, home: string, env: NodeJS.ProcessEnv, cacheName?: string }} input
80
+ * @returns {{ root: string, versionsDir: string, currentLink: string, previousLink: string, pendingFile: string, healthFile: string }}
81
+ */
82
+ export function storeLayout({ platform, home, env, cacheName }) {
83
+ const root = join(
84
+ ...cacheRootSegments({ platform, home, env, ...(cacheName ? { cacheName } : {}) }),
85
+ STORE_DIRNAME,
86
+ );
87
+ return {
88
+ root,
89
+ versionsDir: join(root, "versions"),
90
+ currentLink: join(root, "current"),
91
+ previousLink: join(root, "previous"),
92
+ pendingFile: join(root, "pending.json"),
93
+ healthFile: join(root, "health.json"),
94
+ logFile: join(root, "store.log"),
95
+ lockDir: join(root, "store.lock"),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Directory for one version. Version strings come from a package.json we published, but
101
+ * this path is joined into the filesystem, so anything that could climb out of the
102
+ * store is refused rather than sanitised — a silently rewritten version number would be
103
+ * worse than a hard failure.
104
+ *
105
+ * @param {{ versionsDir: string }} layout
106
+ * @param {string} version
107
+ * @returns {string}
108
+ */
109
+ export function versionDir(layout, version) {
110
+ if (!isSafeVersion(version)) {
111
+ throw new Error(`unsafe version string: ${JSON.stringify(version)}`);
112
+ }
113
+ return join(layout.versionsDir, version);
114
+ }
115
+
116
+ /** Semver-ish, and free of anything that means something to a path.
117
+ *
118
+ * The leading character may not be a dot. Nothing publishes a version starting
119
+ * with one, and the store's own bookkeeping lives in dotfiles — `.seed-complete`
120
+ * is a name a "version" could otherwise claim. Mirrored in the app's
121
+ * `npm-store.ts`; `npm-store.parity.test.ts` fails if the two drift, which is
122
+ * how this rule got tightened. */
123
+ export function isSafeVersion(version) {
124
+ return (
125
+ typeof version === "string" &&
126
+ /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(version) &&
127
+ !version.includes("..")
128
+ );
129
+ }
130
+
131
+ // ── version comparison ────────────────────────────────────────────────────
132
+ //
133
+ // MIRRORS `compareVersions` in apps/desktop/src/main/features/auto-update/npx-update.ts.
134
+ // The launcher and the app are published together but share no runtime code, so this
135
+ // parallel copy is deliberate — the same reason `cacheRootSegments` is duplicated. The
136
+ // test suite pins the same cases as the app's, so a divergence fails somewhere.
137
+
138
+ /** @returns {{ core: [number, number, number], pre: number | null } | null} */
139
+ function parseVersion(version) {
140
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-alpha\.(\d+))?$/u.exec(String(version).trim());
141
+ if (m === null) return null;
142
+ const core = [Number(m[1]), Number(m[2]), Number(m[3])];
143
+ const pre = m[4] === undefined ? null : Number(m[4]);
144
+ if (!core.every(Number.isSafeInteger)) return null;
145
+ if (pre !== null && !Number.isSafeInteger(pre)) return null;
146
+ return { core, pre };
147
+ }
148
+
149
+ /**
150
+ * -1 / 0 / 1. A release outranks a prerelease of the same core. An unparseable version
151
+ * sorts oldest, so a version we cannot read never wins a comparison and never triggers
152
+ * a seed or an update.
153
+ */
154
+ export function compareVersions(a, b) {
155
+ const pa = parseVersion(a);
156
+ const pb = parseVersion(b);
157
+ if (pa === null && pb === null) return 0;
158
+ if (pa === null) return -1;
159
+ if (pb === null) return 1;
160
+ for (let i = 0; i < 3; i += 1) {
161
+ if (pa.core[i] !== pb.core[i]) return pa.core[i] < pb.core[i] ? -1 : 1;
162
+ }
163
+ if (pa.pre === pb.pre) return 0;
164
+ if (pa.pre === null) return 1; // release > prerelease
165
+ if (pb.pre === null) return -1;
166
+ return pa.pre < pb.pre ? -1 : 1;
167
+ }
168
+
169
+ export function isNewer(candidate, than) {
170
+ return compareVersions(candidate, than) > 0;
171
+ }
172
+
173
+ // ── state files ───────────────────────────────────────────────────────────
174
+
175
+ /** Read and parse a JSON file, or null when absent/unreadable/malformed. */
176
+ function readJson(path) {
177
+ let raw;
178
+ try {
179
+ raw = readFileSync(path, "utf8");
180
+ } catch {
181
+ return null;
182
+ }
183
+ try {
184
+ const parsed = JSON.parse(raw);
185
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ function writeJson(path, value) {
192
+ mkdirSync(join(path, ".."), { recursive: true });
193
+ writeFileSync(path, JSON.stringify(value), "utf8");
194
+ }
195
+
196
+ /**
197
+ * How many launches a newly applied version gets to prove it starts.
198
+ *
199
+ * Three, matching systemd-boot's default boot counting. ChromeOS uses five;
200
+ * Android leaves the count to the bootloader. The shape is the same everywhere:
201
+ * a bounded number of attempts, then an automatic return to the last version
202
+ * known to work.
203
+ */
204
+ export const DEFAULT_TRIES = 3;
205
+
206
+ /** @returns {{ version: string, triesLeft: number } | null} */
207
+ export function readPending(layout) {
208
+ const p = readJson(layout.pendingFile);
209
+ if (!p || !isSafeVersion(p.version)) return null;
210
+ // A pending file written before tries existed is treated as having a full
211
+ // allowance rather than none, so an in-flight update is not rolled back by
212
+ // the upgrade that introduced the counter.
213
+ const triesLeft = Number.isInteger(p.triesLeft) ? p.triesLeft : DEFAULT_TRIES;
214
+ return { version: p.version, triesLeft };
215
+ }
216
+
217
+ export function writePending(layout, version, triesLeft = DEFAULT_TRIES) {
218
+ writeJson(layout.pendingFile, {
219
+ version,
220
+ triesLeft,
221
+ stagedAt: new Date().toISOString(),
222
+ });
223
+ }
224
+
225
+ /** Record one attempt at starting the pending version. */
226
+ export function consumeTry(layout) {
227
+ const pending = readPending(layout);
228
+ if (pending === null) return null;
229
+ const next = { ...pending, triesLeft: pending.triesLeft - 1 };
230
+ writeJson(layout.pendingFile, { ...next, stagedAt: new Date().toISOString() });
231
+ return next;
232
+ }
233
+
234
+ export function clearPending(layout) {
235
+ try {
236
+ rmSync(layout.pendingFile, { force: true });
237
+ } catch {
238
+ /* best-effort: a stale pending is re-evaluated, not fatal */
239
+ }
240
+ }
241
+
242
+ /** @returns {{ version: string } | null} */
243
+ export function readHealth(layout) {
244
+ const h = readJson(layout.healthFile);
245
+ return h && isSafeVersion(h.version) ? { version: h.version } : null;
246
+ }
247
+
248
+ export function writeHealth(layout, version) {
249
+ writeJson(layout.healthFile, { version, confirmedAt: new Date().toISOString() });
250
+ }
251
+
252
+ /** Version directories present, unsorted. */
253
+ export function listVersions(layout) {
254
+ try {
255
+ return readdirSync(layout.versionsDir, { withFileTypes: true })
256
+ .filter(
257
+ (e) =>
258
+ e.isDirectory() &&
259
+ isSafeVersion(e.name) &&
260
+ isCompleteVersion(join(layout.versionsDir, e.name)),
261
+ )
262
+ .map((e) => e.name);
263
+ } catch {
264
+ return [];
265
+ }
266
+ }
267
+
268
+ // ── the decisions ─────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Should the npm payload be copied into the store as a version?
272
+ *
273
+ * The subtle case, and the reason this is not simply "they differ": after a
274
+ * self-update the store is AHEAD of npm. The npm payload is whatever was last
275
+ * installed by `npm i -g` and goes stale the moment the app updates itself. Seeding on
276
+ * inequality would therefore downgrade every self-updated user back to their install
277
+ * version on the next launch.
278
+ *
279
+ * So: seed when there is no current version at all (first run, or a store that lost
280
+ * its pointer), or when npm genuinely carries something NEWER — which is what happens
281
+ * when a user runs `npm i -g` themselves to jump forward or to repair a broken store.
282
+ *
283
+ * @param {{ currentVersion: string | null, payloadVersion: string, versionExists: boolean }} input
284
+ * @returns {boolean}
285
+ */
286
+ export function shouldSeedFromPayload({ currentVersion, payloadVersion, versionExists }) {
287
+ if (!isSafeVersion(payloadVersion)) return false;
288
+ if (currentVersion === null) return true;
289
+ if (!isNewer(payloadVersion, currentVersion)) return false;
290
+ // Newer, but already extracted by an earlier run — point at it, don't re-extract.
291
+ return !versionExists;
292
+ }
293
+
294
+ /** What the launcher should do about the store before it spawns anything. */
295
+ export const STORE_ACTION = {
296
+ /** Nothing to do; launch `current`. */
297
+ launch: "launch",
298
+ /** Copy/extract the npm payload in, then point `current` at it. */
299
+ seed: "seed",
300
+ /** A staged version is ready; point `current` at it before launching. */
301
+ applyPending: "apply-pending",
302
+ /** The last applied version never reported a healthy start; go back. */
303
+ rollback: "rollback",
304
+ };
305
+
306
+ /**
307
+ * The single decision the launcher makes at startup.
308
+ *
309
+ * Order matters and encodes the safety rules:
310
+ *
311
+ * 1. **Rollback wins.** A `current` that was applied from `pending` but never wrote
312
+ * `health.json` did not start. Launching it again would loop a user into a version
313
+ * that cannot run, which is the one failure worse than not updating.
314
+ * 2. **Then seeding**, so a store with no pointer — or one a user has just repaired
315
+ * with `npm i -g` — becomes launchable before anything else is considered.
316
+ * 3. **Then a pending apply.** The user chose "later", or the app staged an update and
317
+ * the process was killed before it restarted itself; either way the next launch
318
+ * picks it up, the way Velopack applies on startup.
319
+ *
320
+ * @param {{
321
+ * currentVersion: string | null,
322
+ * previousVersion: string | null,
323
+ * pending: { version: string } | null,
324
+ * health: { version: string } | null,
325
+ * payloadVersion: string,
326
+ * versionExists: (version: string) => boolean,
327
+ * }} input
328
+ * @returns {{ action: string, version: string | null, reason: string }}
329
+ */
330
+ export function decideStartupAction({
331
+ currentVersion,
332
+ previousVersion,
333
+ pending,
334
+ health,
335
+ payloadVersion,
336
+ versionExists,
337
+ }) {
338
+ // 1. Did the version we last switched to fail to start, repeatedly?
339
+ // `pending` still naming the CURRENT version means the swap happened but the
340
+ // healthy-start handshake never completed. One failure is not enough to act
341
+ // on — a machine can be rebooted mid-launch — so this waits for the attempt
342
+ // allowance to run out, the way systemd-boot, ChromeOS and Android A/B all
343
+ // do. Every one of those requires a POSITIVE signal from userspace and
344
+ // bounds the number of attempts; none treat "the process was created" as
345
+ // proof that anything started.
346
+ if (
347
+ pending !== null &&
348
+ currentVersion !== null &&
349
+ pending.version === currentVersion &&
350
+ (health === null || health.version !== currentVersion) &&
351
+ pending.triesLeft <= 0 &&
352
+ previousVersion !== null &&
353
+ previousVersion !== currentVersion &&
354
+ versionExists(previousVersion)
355
+ ) {
356
+ return {
357
+ action: STORE_ACTION.rollback,
358
+ version: previousVersion,
359
+ reason: `${currentVersion} was applied but never confirmed a start in ${DEFAULT_TRIES} attempts; going back to ${previousVersion}`,
360
+ };
361
+ }
362
+
363
+ // 2. No launchable current, or npm is genuinely ahead.
364
+ if (
365
+ shouldSeedFromPayload({
366
+ currentVersion,
367
+ payloadVersion,
368
+ versionExists: versionExists(payloadVersion),
369
+ })
370
+ ) {
371
+ return {
372
+ action: STORE_ACTION.seed,
373
+ version: payloadVersion,
374
+ reason:
375
+ currentVersion === null
376
+ ? `no current version; seeding ${payloadVersion} from the npm payload`
377
+ : `npm payload ${payloadVersion} is newer than ${currentVersion}`,
378
+ };
379
+ }
380
+
381
+ // A current version that npm is ahead of, but whose directory already exists, just
382
+ // needs the pointer moved.
383
+ if (
384
+ currentVersion !== null &&
385
+ isNewer(payloadVersion, currentVersion) &&
386
+ versionExists(payloadVersion)
387
+ ) {
388
+ return {
389
+ action: STORE_ACTION.applyPending,
390
+ version: payloadVersion,
391
+ reason: `npm payload ${payloadVersion} is already extracted; pointing current at it`,
392
+ };
393
+ }
394
+
395
+ // 3. A staged version waiting for this restart.
396
+ if (
397
+ pending !== null &&
398
+ pending.version !== currentVersion &&
399
+ versionExists(pending.version)
400
+ ) {
401
+ return {
402
+ action: STORE_ACTION.applyPending,
403
+ version: pending.version,
404
+ reason: `applying staged version ${pending.version}`,
405
+ };
406
+ }
407
+
408
+ return { action: STORE_ACTION.launch, version: currentVersion, reason: "current is up to date" };
409
+ }
410
+
411
+ /**
412
+ * Version directories safe to delete: everything that is neither current, nor the
413
+ * retained previous, nor a staged pending. Pruning is only ever done AFTER a healthy
414
+ * start, so a version that has not yet proved itself is never the thing removed.
415
+ *
416
+ * @param {{ versions: readonly string[], currentVersion: string | null, previousVersion: string | null, pending: { version: string } | null }} input
417
+ * @returns {string[]}
418
+ */
419
+ export function prunableVersions({ versions, currentVersion, previousVersion, pending }) {
420
+ const keep = new Set(
421
+ [currentVersion, previousVersion, pending?.version].filter((v) => typeof v === "string"),
422
+ );
423
+ return versions.filter((v) => !keep.has(v)).sort();
424
+ }
425
+
426
+ /**
427
+ * Whether this launch should record a healthy start — i.e. `current` is the version
428
+ * `pending` staged and nothing has confirmed it yet. Recording it clears the rollback
429
+ * arming above.
430
+ */
431
+ export function shouldConfirmHealth({ currentVersion, pending, health }) {
432
+ if (currentVersion === null) return false;
433
+ if (pending === null || pending.version !== currentVersion) return false;
434
+ return health === null || health.version !== currentVersion;
435
+ }
436
+
437
+
438
+ // ── the filesystem operations ─────────────────────────────────────────────
439
+ //
440
+ // Windows only for now. `current` is a directory JUNCTION, which is the one
441
+ // pointer that gives a STABLE path — `<store>/current/app/nissy.exe` — for the
442
+ // autostart Run key and the protocol handler to reference across updates.
443
+ //
444
+ // Verified on Windows 11 before this was built: a junction can be created
445
+ // unelevated, re-pointed WHILE a process launched through it is running, that
446
+ // process survives the swap, and the next launch resolves to the new target.
447
+ // The whole swap design rests on that, so it was measured rather than assumed.
448
+
449
+ /** The version `current` resolves to, or null when there is no usable pointer. */
450
+ export function readCurrentVersion(layout) {
451
+ try {
452
+ const resolved = realpathSync(layout.currentLink);
453
+ const name = basename(resolved);
454
+ return isSafeVersion(name) ? name : null;
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+ /** Same, for the retained rollback target. */
461
+ export function readPreviousVersion(layout) {
462
+ try {
463
+ const name = basename(realpathSync(layout.previousLink));
464
+ return isSafeVersion(name) ? name : null;
465
+ } catch {
466
+ return null;
467
+ }
468
+ }
469
+
470
+ /** Re-point a junction, replacing whatever is there. */
471
+ function repoint(linkPath, targetDir) {
472
+ try {
473
+ rmSync(linkPath, { recursive: true, force: true });
474
+ } catch {
475
+ /* nothing there, or already gone */
476
+ }
477
+ symlinkSync(targetDir, linkPath, "junction");
478
+ }
479
+
480
+ /**
481
+ * Point `current` at `version`, demoting the outgoing one to `previous`.
482
+ *
483
+ * Not atomic — a crash between the unlink and the create leaves no `current` at
484
+ * all. That is survivable by construction: the startup decision treats a missing
485
+ * current as "seed", so the next launch repairs it from the npm payload rather
486
+ * than failing.
487
+ */
488
+ export function pointCurrentAt(layout, version) {
489
+ const target = versionDir(layout, version);
490
+ if (!isCompleteVersion(target)) throw new Error(`version ${version} is not in the store`);
491
+ const outgoing = readCurrentVersion(layout);
492
+ if (outgoing !== null && outgoing !== version) {
493
+ repoint(layout.previousLink, versionDir(layout, outgoing));
494
+ }
495
+ repoint(layout.currentLink, target);
496
+ }
497
+
498
+ /**
499
+ * Populate `dest` from `src` using hard links, falling back to a copy per file.
500
+ *
501
+ * Measured on the real 0.2.35 payload (2,318 files, 450 MB): 2.0s hard-linked
502
+ * against 36.5s copied. A copy would put a 36-second stall in front of the
503
+ * first launch after every `npm i -g`, which is a worse experience than the
504
+ * problem the store is solving.
505
+ *
506
+ * Hard links also cost no extra disk — both names point at the same blocks —
507
+ * and they are the RIGHT semantics here, not just the fast ones. npm replaces
508
+ * files rather than editing them in place, so a later `npm i -g` unlinks its
509
+ * name and writes a new one, leaving the store's copy of the old version
510
+ * intact. That is precisely the independence the store needs.
511
+ *
512
+ * The fallback covers a store on a different volume from the npm prefix
513
+ * (EXDEV) and any filesystem without hard links.
514
+ */
515
+ function linkTree(src, dest) {
516
+ mkdirSync(dest, { recursive: true });
517
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
518
+ const from = join(src, entry.name);
519
+ const to = join(dest, entry.name);
520
+ if (entry.isDirectory()) {
521
+ linkTree(from, to);
522
+ continue;
523
+ }
524
+ try {
525
+ linkSync(from, to);
526
+ } catch {
527
+ copyFileSync(from, to);
528
+ }
529
+ // Do NOT mark these read-only as a defence against writing through the
530
+ // link. On Windows the attribute belongs to the FILE, not the link, so it
531
+ // would also mark npm's copy read-only — and Microsoft documents that the
532
+ // other links then *display* the old value, which is simply untrue. ACLs
533
+ // are worse: a hard link has no security descriptor of its own at all. Nix
534
+ // gets read-only stores by chmod plus a read-only bind mount, neither of
535
+ // which has an NTFS equivalent, and pnpm — the closest analogue to this —
536
+ // does not use permissions either. It documents the hazard and offers
537
+ // copy-on-write instead.
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Copy an extracted application into the store as `version`.
543
+ *
544
+ * Completion is signalled by a marker file written last, NOT by renaming a
545
+ * staged directory into place. The rename is the obvious design and it does not
546
+ * work here: the seed hard links from a tree npm has just extracted, Defender
547
+ * still holds handles on those inodes, and a directory with an open handle
548
+ * cannot be renamed. It failed with EPERM on a real machine. Go hit the same
549
+ * wall and abandoned rename-into-place in 1.16 for a `.partial` marker; this is
550
+ * the same idea with the sense inverted.
551
+ *
552
+ * So an interrupted seed leaves an unmarked directory, which `isCompleteVersion`
553
+ * treats as absent and the next seed clears.
554
+ */
555
+ export function seedVersion(layout, version, sourceDir, exeSegments = null) {
556
+ const target = versionDir(layout, version);
557
+ if (isCompleteVersion(target)) return target;
558
+ // An unmarked directory is the wreckage of an interrupted seed. Clear it
559
+ // rather than linking on top of a half-populated tree.
560
+ rmSync(target, { recursive: true, force: true });
561
+ mkdirSync(layout.versionsDir, { recursive: true });
562
+ try {
563
+ linkTree(sourceDir, target);
564
+ if (exeSegments !== null) {
565
+ const exe = join(target, ...exeSegments);
566
+ if (!looksExecutable(exe)) {
567
+ throw new Error(`seeded version ${version} has no runnable executable at ${exe}`);
568
+ }
569
+ }
570
+ // Written LAST, and only once the executable has been checked: a version
571
+ // that cannot run must never become eligible for `current`.
572
+ writeFileSync(join(target, SEED_COMPLETE_MARKER), new Date().toISOString());
573
+ } catch (err) {
574
+ // Leave nothing usable and nothing large behind.
575
+ rmSync(target, { recursive: true, force: true });
576
+ throw err;
577
+ }
578
+ return target;
579
+ }
580
+
581
+ /**
582
+ * Whether a file is plausibly a Windows executable.
583
+ *
584
+ * Reads the two-byte DOS header rather than trusting the extension. This is not
585
+ * a signature check and is not meant to be: it catches truncation, a failed
586
+ * download written to the right name, and a file replaced by text — the
587
+ * failures that actually happen. Anything subtler is the digest's job, upstream.
588
+ *
589
+ * Cheap on purpose: two bytes and a stat, on the launch path.
590
+ */
591
+ export function looksExecutable(filePath) {
592
+ let fd;
593
+ try {
594
+ if (statSync(filePath).size < 1024) return false; // no real exe is this small
595
+ fd = openSync(filePath, "r");
596
+ const head = Buffer.alloc(2);
597
+ if (readSync(fd, head, 0, 2, 0) !== 2) return false;
598
+ return head[0] === 0x4d && head[1] === 0x5a; // "MZ"
599
+ } catch {
600
+ return false;
601
+ } finally {
602
+ if (fd !== undefined) {
603
+ try {
604
+ closeSync(fd);
605
+ } catch {
606
+ /* nothing to do */
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ /** A version directory that finished seeding. Anything else does not exist as
613
+ * far as the rest of the store is concerned. */
614
+ export function isCompleteVersion(dir) {
615
+ return existsSync(join(dir, SEED_COMPLETE_MARKER));
616
+ }
617
+
618
+ /** Delete version directories that are no longer referenced. Best-effort: a
619
+ * directory still holding a running executable is locked on Windows and is
620
+ * simply left for the next launch. */
621
+ export function pruneVersions(layout, versions) {
622
+ for (const v of versions) {
623
+ try {
624
+ rmSync(versionDir(layout, v), { recursive: true, force: true });
625
+ } catch {
626
+ /* locked or vanished — try again next time */
627
+ }
628
+ }
629
+ }
630
+
631
+ // ── diagnostics ───────────────────────────────────────────────────────────
632
+
633
+ /** Keep the log bounded. It is append-only across every launch, forever. */
634
+ const LOG_MAX_BYTES = 128 * 1024;
635
+
636
+ /**
637
+ * Append a line to the store's own log.
638
+ *
639
+ * ALWAYS written, not gated behind a debug flag. The store degrades silently by
640
+ * design — every failure falls back to running the npm payload in place — which
641
+ * means a store that never works looks exactly like a store that is working
642
+ * fine. Without a durable record, we would ship it, see updates still applying
643
+ * through the old path, and conclude it was doing its job.
644
+ *
645
+ * Never throws: a launch must not fail because a log could not be written.
646
+ */
647
+ export function storeLog(layout, message) {
648
+ try {
649
+ mkdirSync(layout.root, { recursive: true });
650
+ try {
651
+ if (statSync(layout.logFile).size > LOG_MAX_BYTES) {
652
+ writeFileSync(layout.logFile, `--- truncated ${new Date().toISOString()} ---
653
+ `);
654
+ }
655
+ } catch {
656
+ /* no log yet */
657
+ }
658
+ appendFileSync(layout.logFile, `${new Date().toISOString()} ${message}
659
+ `);
660
+ } catch {
661
+ /* diagnostics must never break a launch */
662
+ }
663
+ }
664
+
665
+ // ── the launch lock ───────────────────────────────────────────────────────
666
+
667
+ /** How long a lock may be held before another launcher treats it as abandoned. */
668
+ const LOCK_STALE_MS = 2 * 60 * 1000;
669
+ /** How long to wait for a lock before giving up and running in place. */
670
+ const LOCK_WAIT_MS = 5000;
671
+
672
+ function lockIsStale(lockDir) {
673
+ try {
674
+ if (Date.now() - statSync(lockDir).mtimeMs > LOCK_STALE_MS) return true;
675
+ const pid = Number(readFileSync(join(lockDir, "pid"), "utf8").trim());
676
+ if (!Number.isInteger(pid) || pid <= 0) return true;
677
+ try {
678
+ process.kill(pid, 0);
679
+ return false; // holder is alive
680
+ } catch {
681
+ return true; // holder is gone
682
+ }
683
+ } catch {
684
+ return true; // unreadable lock is not a lock
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Run `fn` while holding an exclusive lock on the store, or return
690
+ * `LOCK_UNAVAILABLE` if another launcher holds it.
691
+ *
692
+ * Two launchers really can run at once: the login item firing while someone
693
+ * types `nissy`, or a double-click. Without this, one process can be mid-seed
694
+ * while the other re-points `current` at a directory that is still being
695
+ * copied into.
696
+ *
697
+ * `mkdir` is the primitive because it is atomic on every filesystem we care
698
+ * about and needs no dependency. A crashed holder leaves the directory behind,
699
+ * so a lock whose PID is dead, or which is simply old, is taken over.
700
+ */
701
+ export const LOCK_UNAVAILABLE = Symbol("store-lock-unavailable");
702
+
703
+ export function withStoreLock(layout, fn, { waitMs = LOCK_WAIT_MS, now = Date.now } = {}) {
704
+ // The lock is a directory inside the store root, so the root has to exist
705
+ // before `mkdir` can distinguish "somebody holds it" (EEXIST) from "there is
706
+ // nowhere to put it" (ENOENT).
707
+ try {
708
+ mkdirSync(layout.root, { recursive: true });
709
+ } catch {
710
+ return LOCK_UNAVAILABLE;
711
+ }
712
+
713
+ const deadline = now() + waitMs;
714
+ for (;;) {
715
+ try {
716
+ mkdirSync(layout.lockDir, { recursive: false });
717
+ break; // acquired
718
+ } catch (err) {
719
+ // ONLY EEXIST means another launcher holds it. Anything else (the store
720
+ // root is a file, the directory is not writable) means the store itself
721
+ // is unusable, and retrying cannot fix it. Treating those as contention
722
+ // is an infinite loop that hangs the launcher and leaves the user unable
723
+ // to start the app at all.
724
+ if (!err || err.code !== "EEXIST") return LOCK_UNAVAILABLE;
725
+ // Checked before the takeover, so no branch of this loop can outlive the
726
+ // deadline.
727
+ if (now() >= deadline) return LOCK_UNAVAILABLE;
728
+ if (lockIsStale(layout.lockDir)) {
729
+ storeLog(layout, "taking over an abandoned store lock");
730
+ try {
731
+ rmSync(layout.lockDir, { recursive: true, force: true });
732
+ } catch {
733
+ return LOCK_UNAVAILABLE; // cannot clear it; do not spin on it
734
+ }
735
+ continue;
736
+ }
737
+ // Busy-wait without a dependency. The launcher is synchronous up to this
738
+ // point and the wait is bounded at a few seconds.
739
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 120);
740
+ }
741
+ }
742
+ try {
743
+ writeFileSync(join(layout.lockDir, "pid"), String(process.pid));
744
+ } catch {
745
+ /* the directory is the lock; the pid is only for staleness */
746
+ }
747
+ try {
748
+ return fn();
749
+ } finally {
750
+ try {
751
+ rmSync(layout.lockDir, { recursive: true, force: true });
752
+ } catch {
753
+ /* a leftover lock is reclaimed as stale */
754
+ }
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Decide what the store needs, do it, and return the executable to launch.
760
+ *
761
+ * Returns null when the store cannot be used for ANY reason — an unwritable
762
+ * cache root, a filesystem without junctions, a half-built store we cannot
763
+ * repair. The launcher then runs the npm payload in place, exactly as it did
764
+ * before the store existed. That fallback is the whole safety story: a bug in
765
+ * here must degrade to the old behaviour, never to a user who cannot start the
766
+ * app.
767
+ *
768
+ * @param {{
769
+ * platform: NodeJS.Platform,
770
+ * home: string,
771
+ * env: NodeJS.ProcessEnv,
772
+ * cacheName?: string,
773
+ * payloadVersion: string,
774
+ * payloadRoot: string,
775
+ * exeSegments: readonly string[],
776
+ * log?: (message: string) => void,
777
+ * }} input
778
+ * @returns {{ exePath: string, version: string, action: string } | null}
779
+ */
780
+ export function prepareStore({
781
+ platform,
782
+ home,
783
+ env,
784
+ cacheName,
785
+ payloadVersion,
786
+ payloadRoot,
787
+ exeSegments,
788
+ log = () => {},
789
+ }) {
790
+ // Windows only. macOS keeps its existing ~/Applications install, which already
791
+ // swaps a whole bundle and does not suffer the locked-executable problem this
792
+ // store exists to solve.
793
+ if (platform !== "win32") return null;
794
+
795
+ let layout = null;
796
+ try {
797
+ layout = storeLayout({ platform, home, env, ...(cacheName ? { cacheName } : {}) });
798
+ const currentVersion = readCurrentVersion(layout);
799
+ const previousVersion = readPreviousVersion(layout);
800
+ const pending = readPending(layout);
801
+ const health = readHealth(layout);
802
+
803
+ const decision = decideStartupAction({
804
+ currentVersion,
805
+ previousVersion,
806
+ pending,
807
+ health,
808
+ payloadVersion,
809
+ versionExists: (v) => isSafeVersion(v) && isCompleteVersion(versionDir(layout, v)),
810
+ });
811
+ log(`store: ${decision.action} - ${decision.reason}`);
812
+ storeLog(layout, `${decision.action}: ${decision.reason}`);
813
+
814
+ // Only the mutating branches need the lock; a plain launch reads and goes.
815
+ const mutating = decision.action !== STORE_ACTION.launch;
816
+ const apply = () => {
817
+ switch (decision.action) {
818
+ case STORE_ACTION.seed:
819
+ seedVersion(layout, decision.version, payloadRoot, exeSegments);
820
+ pointCurrentAt(layout, decision.version);
821
+ // A freshly seeded version is the one we are about to run; nothing is
822
+ // pending against it.
823
+ clearPending(layout);
824
+ writeHealth(layout, decision.version);
825
+ break;
826
+ case STORE_ACTION.applyPending:
827
+ // Re-checked at the point of use, not only at seed time: the files may
828
+ // have been damaged since, and pointing `current` at a broken version
829
+ // is what strands a user outside the app.
830
+ if (!looksExecutable(join(versionDir(layout, decision.version), ...exeSegments))) {
831
+ storeLog(layout, `refusing to apply ${decision.version}: no runnable executable`);
832
+ rmSync(versionDir(layout, decision.version), { recursive: true, force: true });
833
+ clearPending(layout);
834
+ break;
835
+ }
836
+ pointCurrentAt(layout, decision.version);
837
+ // Deliberately NOT confirmed here: health is written only once the
838
+ // version has actually started, which is what arms the rollback below
839
+ // if it never does.
840
+ writePending(layout, decision.version);
841
+ break;
842
+ case STORE_ACTION.rollback:
843
+ pointCurrentAt(layout, decision.version);
844
+ // Clearing `pending` is what disarms the rollback, so it cannot fire
845
+ // again next launch. Health is deliberately NOT written: this version
846
+ // has not started yet in this session, and recording a start we did not
847
+ // observe would put a lie in the state file that later diagnosis reads.
848
+ clearPending(layout);
849
+ break;
850
+ case STORE_ACTION.launch:
851
+ default:
852
+ break;
853
+ }
854
+ };
855
+
856
+ if (mutating) {
857
+ const held = withStoreLock(layout, apply);
858
+ if (held === LOCK_UNAVAILABLE) {
859
+ // Another launcher is seeding or swapping. Doing it concurrently is how
860
+ // a half-copied version gets pointed at, so this one steps back and
861
+ // runs the npm payload in place for this launch only.
862
+ storeLog(layout, "another launcher holds the store lock; running in place");
863
+ log("store: busy; running the npm payload in place");
864
+ return null;
865
+ }
866
+ }
867
+
868
+ const version = readCurrentVersion(layout);
869
+ if (version === null) return null;
870
+ const exePath = join(layout.currentLink, ...exeSegments);
871
+ if (!existsSync(exePath)) {
872
+ storeLog(layout, `no executable at ${exePath}; running the npm payload in place`);
873
+ log(`store: no executable at ${exePath}; running the npm payload in place`);
874
+ return null;
875
+ }
876
+
877
+ // Is this launch an ATTEMPT at an unproven version? It is whenever `pending`
878
+ // still names what we are about to run and no health record agrees. Spend
879
+ // one of its attempts now, before the spawn — a launch that never comes back
880
+ // to confirm must still have cost something, or a version that hard-crashes
881
+ // on startup would retry for ever.
882
+ let awaitingConfirmation = false;
883
+ const afterPending = readPending(layout);
884
+ const afterHealth = readHealth(layout);
885
+ if (
886
+ afterPending !== null &&
887
+ afterPending.version === version &&
888
+ (afterHealth === null || afterHealth.version !== version)
889
+ ) {
890
+ // If another launcher holds the lock, skip the decrement rather than wait:
891
+ // losing an attempt is the safe direction to err in, spending one twice is
892
+ // not.
893
+ const held = withStoreLock(layout, () => consumeTry(layout), { waitMs: 0 });
894
+ if (held !== LOCK_UNAVAILABLE) {
895
+ awaitingConfirmation = true;
896
+ storeLog(layout, `attempt at ${version}; ${held === null ? "?" : held.triesLeft} left`);
897
+ }
898
+ }
899
+ return { exePath, version, action: decision.action, awaitingConfirmation };
900
+ } catch (err) {
901
+ const detail = (err && err.stack) || String(err);
902
+ // Recorded, not just whispered behind a debug flag: a store that always
903
+ // falls back is indistinguishable from one that works.
904
+ if (layout !== null) storeLog(layout, `FAILED, running the npm payload in place
905
+ ${detail}`);
906
+ log(`store unavailable (${err && err.message}); running the npm payload in place`);
907
+ return null;
908
+ }
909
+ }
910
+
911
+ /**
912
+ * Record that the current version started, and prune what is no longer referenced.
913
+ *
914
+ * `confirm: false` does the pruning only. Use it on the ordinary launch, where
915
+ * there is nothing to confirm and no reason to wait around for a signal.
916
+ *
917
+ * WHAT COUNTS AS A START is the whole question, and the honest answer today is
918
+ * "the process was still alive a few seconds later" — see HEALTH_WAIT_MS in the
919
+ * launcher. That is weaker than the systems this design borrows from. ChromeOS
920
+ * waits 45 seconds and then has userspace run `chromeos-setgoodkernel`; Android
921
+ * A/B verifies the whole partition with dm-verity before zygote and only then
922
+ * marks the slot successful; systemd renames the boot-counter file at the end of
923
+ * a successful boot. All three take a positive signal from the thing that
924
+ * booted, not from whatever launched it.
925
+ *
926
+ * A short liveness check does catch the failure that actually happens — an
927
+ * executable that dies immediately on a missing DLL or a truncated download —
928
+ * and the attempt counter bounds everything it misses. The app calling this
929
+ * itself, once it has a window and a working database, is the real fix and is
930
+ * the next piece of work.
931
+ *
932
+ * Pruning is deliberately last and best-effort: the outgoing version's
933
+ * executable may still be locked by a process that has not exited yet.
934
+ */
935
+ export function confirmHealthyStart({
936
+ platform,
937
+ home,
938
+ env,
939
+ cacheName,
940
+ confirm = true,
941
+ log = () => {},
942
+ }) {
943
+ if (platform !== "win32") return;
944
+ try {
945
+ const layout = storeLayout({ platform, home, env, ...(cacheName ? { cacheName } : {}) });
946
+ const currentVersion = readCurrentVersion(layout);
947
+ if (currentVersion === null) return;
948
+ const pending = readPending(layout);
949
+ withStoreLock(layout, () => {
950
+ if (confirm && shouldConfirmHealth({ currentVersion, pending, health: readHealth(layout) })) {
951
+ writeHealth(layout, currentVersion);
952
+ clearPending(layout);
953
+ storeLog(layout, `${currentVersion} started; confirmed`);
954
+ log(`store: ${currentVersion} started; confirmed`);
955
+ }
956
+ const dead = prunableVersions({
957
+ versions: listVersions(layout),
958
+ currentVersion,
959
+ previousVersion: readPreviousVersion(layout),
960
+ pending: readPending(layout),
961
+ });
962
+ if (dead.length > 0) storeLog(layout, `pruning ${dead.join(", ")}`);
963
+ pruneVersions(layout, dead);
964
+ });
965
+ } catch (err) {
966
+ // Never let bookkeeping affect a launch that has already happened - but do
967
+ // leave a trace, so a store that silently stops confirming is findable.
968
+ try {
969
+ storeLog(
970
+ storeLayout({ platform, home, env, ...(cacheName ? { cacheName } : {}) }),
971
+ `confirm/prune failed: ${(err && err.message) || err}`,
972
+ );
973
+ } catch {
974
+ /* nothing further to do */
975
+ }
976
+ }
977
+ }