@nissyai/desktop 0.2.55 → 0.2.59

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.
@@ -0,0 +1,664 @@
1
+ /**
2
+ * The versioned store on macOS.
3
+ *
4
+ * Windows keeps `current` as a directory junction. macOS cannot: the app has to
5
+ * live at a real path in `~/Applications` for Spotlight, Launchpad, the Dock and
6
+ * the LaunchAgent, and LaunchServices treats a symlinked `.app` as a different
7
+ * item. So on macOS `current` IS the installed bundle, and the store holds
8
+ * everything else:
9
+ *
10
+ * ~/Applications/Nissy.app current — its Info.plist says which version
11
+ * <cacheRoot>/store/
12
+ * versions/0.2.57/app/Nissy.app staged by the app, waiting for a restart
13
+ * versions/0.2.56/app/Nissy.app the bundle this one replaced (previous)
14
+ * previous -> versions/0.2.56
15
+ * pending.json, health.json, store.log, store.lock (same as Windows)
16
+ *
17
+ * THE ONE RULE: the bundle is swapped only when nothing runs from it.
18
+ *
19
+ * Electron finds its helper apps (`Nissy Helper (Renderer).app` and friends) by
20
+ * path on disk every time it starts one. Replacing the bundle under a live
21
+ * process lets the old main process start the NEW version's helpers — "Unable to
22
+ * find helper app", or two framework versions talking to each other. Every Mac
23
+ * updater worth copying swaps after the app has gone: Squirrel.Mac's ShipIt
24
+ * installs on termination, Sparkle's Autoupdate waits for the host to exit,
25
+ * Velopack's UpdateMac closes the app first. This launcher is our ShipIt: the app
26
+ * relaunches it with its own pid and quits, and the launcher waits for the pid and
27
+ * for every process under the bundle before it touches anything.
28
+ *
29
+ * The decisions — seed, apply, roll back — are `store.mjs`'s `decideStartupAction`,
30
+ * fed the installed bundle's version. Only the pointer mechanics differ.
31
+ */
32
+
33
+ import { execFileSync } from "node:child_process";
34
+ import {
35
+ closeSync,
36
+ existsSync,
37
+ lstatSync,
38
+ mkdirSync,
39
+ openSync,
40
+ readdirSync,
41
+ readFileSync,
42
+ readSync,
43
+ realpathSync,
44
+ renameSync,
45
+ rmSync,
46
+ statSync,
47
+ symlinkSync,
48
+ writeFileSync,
49
+ } from "node:fs";
50
+ import { dirname, isAbsolute, join, relative } from "node:path";
51
+
52
+ import { unsafeArchiveEntries } from "./resolve.mjs";
53
+ import {
54
+ consumeTry,
55
+ decideStartupAction,
56
+ isCompleteVersion,
57
+ isNewer,
58
+ isSafeVersion,
59
+ listVersions,
60
+ LOCK_UNAVAILABLE,
61
+ prunableVersions,
62
+ pruneVersions,
63
+ readHealth,
64
+ readPending,
65
+ readPreviousVersion,
66
+ SEED_COMPLETE_MARKER,
67
+ STORE_ACTION,
68
+ clearPending,
69
+ storeLayout,
70
+ storeLog,
71
+ versionDir,
72
+ withStoreLock,
73
+ writeHealth,
74
+ writePending,
75
+ } from "./store.mjs";
76
+
77
+ /** How long the launcher waits for the app it was handed to exit. Generous: a
78
+ * quit flushes the database and stops hosted services, and a swap that gives
79
+ * up early only costs a later launch the same work. */
80
+ export const EXIT_WAIT_MS = 30_000;
81
+
82
+ /** What `prepareMacStore` did. `deferred` means a swap was needed and NOT done
83
+ * because the bundle was in use; the caller tells the user. */
84
+ export const MAC_STORE_OUTCOME = {
85
+ launch: "launch",
86
+ applied: "applied",
87
+ seeded: "seeded",
88
+ rolledBack: "rolled-back",
89
+ deferred: "deferred",
90
+ /** The npm payload was newer but the app was running: it was staged into the
91
+ * store and requested, so the app's quit (or the next launcher run) applies it. */
92
+ stagedForLater: "staged-for-later",
93
+ };
94
+
95
+ // ── reading what is installed ─────────────────────────────────────────────
96
+
97
+ /**
98
+ * The version of an installed `.app`, from its Info.plist, or null.
99
+ *
100
+ * The bundle itself is the authority. The `applications-darwin.version` marker
101
+ * is only what the last launcher wrote, and a user can replace the app by hand.
102
+ * Forge writes an XML plist, read with a regex; a binary plist (someone ran it
103
+ * through a tool) is handed to `plutil`, which ships with every macOS.
104
+ */
105
+ export function readBundleVersion(bundlePath, { exec = execFileSync } = {}) {
106
+ const plist = join(bundlePath, "Contents", "Info.plist");
107
+ let raw;
108
+ try {
109
+ raw = readFileSync(plist);
110
+ } catch {
111
+ return null;
112
+ }
113
+ let version = null;
114
+ if (raw.subarray(0, 6).toString("latin1") === "bplist") {
115
+ try {
116
+ version = String(
117
+ exec("/usr/bin/plutil", ["-extract", "CFBundleShortVersionString", "raw", "-o", "-", plist], {
118
+ encoding: "utf8",
119
+ stdio: ["ignore", "pipe", "ignore"],
120
+ }),
121
+ ).trim();
122
+ } catch {
123
+ return null;
124
+ }
125
+ } else {
126
+ const m = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/u.exec(
127
+ raw.toString("utf8"),
128
+ );
129
+ version = m === null ? null : m[1].trim();
130
+ }
131
+ return version !== null && isSafeVersion(version) ? version : null;
132
+ }
133
+
134
+ /**
135
+ * The installed version: the bundle's own, falling back to the marker only when
136
+ * the bundle has an executable but an unreadable plist. No executable, no
137
+ * version — the store then seeds, which is the repair.
138
+ */
139
+ export function installedMacVersion({ installedApp, exePath, markerPath }) {
140
+ if (!existsSync(exePath)) return null;
141
+ const fromBundle = readBundleVersion(installedApp);
142
+ if (fromBundle !== null) return fromBundle;
143
+ try {
144
+ const marker = readFileSync(markerPath, "utf8").trim();
145
+ return isSafeVersion(marker) ? marker : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Whether a file is plausibly a Mach-O executable: thin 64-bit, thin 32-bit, or
153
+ * universal. The macOS counterpart of `looksExecutable`'s "MZ" check, and for
154
+ * the same failures — truncation, a text file under the right name.
155
+ */
156
+ export function looksMachO(filePath) {
157
+ let fd;
158
+ try {
159
+ if (statSync(filePath).size < 1024) return false;
160
+ fd = openSync(filePath, "r");
161
+ const head = Buffer.alloc(4);
162
+ if (readSync(fd, head, 0, 4, 0) !== 4) return false;
163
+ const magic = head.toString("hex");
164
+ return (
165
+ magic === "cffaedfe" || // MH_MAGIC_64, little-endian
166
+ magic === "cefaedfe" || // MH_MAGIC, little-endian
167
+ magic === "cafebabe" || // FAT_MAGIC (universal)
168
+ magic === "cafebabf" // FAT_MAGIC_64
169
+ );
170
+ } catch {
171
+ return false;
172
+ } finally {
173
+ if (fd !== undefined) {
174
+ try {
175
+ closeSync(fd);
176
+ } catch {
177
+ /* nothing to do */
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ /** `versions/<ver>/app/<Bundle>.app` — the same shape the Windows store and
184
+ * the app's `payloadExeSegments` use, so both sides find one executable. */
185
+ export function stagedBundlePath(layout, version, bundleName) {
186
+ return join(versionDir(layout, version), "app", bundleName);
187
+ }
188
+
189
+ /** A version directory that finished staging AND holds a runnable bundle. */
190
+ export function isStagedMacVersion(layout, version, exeSegments) {
191
+ if (!isSafeVersion(version)) return false;
192
+ const dir = versionDir(layout, version);
193
+ return isCompleteVersion(dir) && looksMachO(join(dir, ...exeSegments));
194
+ }
195
+
196
+ // ── is anything running from the bundle? ─────────────────────────────────
197
+
198
+ /** Every running executable's path. `comm` is the full path on macOS. */
199
+ export function listProcessPaths({ exec = execFileSync } = {}) {
200
+ try {
201
+ return String(exec("/bin/ps", ["-axo", "comm="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }))
202
+ .split("\n")
203
+ .map((line) => line.trim())
204
+ .filter((line) => line.length > 0);
205
+ } catch {
206
+ // If we cannot tell, we must assume the worst: something is running.
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Processes running from the installed bundle — the main binary, its Helper
213
+ * apps, the crash handler. Null when `ps` could not be read, which callers treat
214
+ * as busy: swapping blind is the one thing this module exists not to do.
215
+ */
216
+ export function bundleProcesses(installedApp, list = listProcessPaths) {
217
+ const paths = list();
218
+ if (paths === null) return null;
219
+ const prefix = `${installedApp}/`;
220
+ return paths.filter((p) => p.startsWith(prefix));
221
+ }
222
+
223
+ function isAlive(pid) {
224
+ try {
225
+ process.kill(pid, 0);
226
+ return true;
227
+ } catch (err) {
228
+ // EPERM means it exists but is not ours — still alive.
229
+ return err && err.code === "EPERM";
230
+ }
231
+ }
232
+
233
+ function sleepMs(ms) {
234
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
235
+ }
236
+
237
+ /**
238
+ * Wait until `pid` (if given) has exited and nothing runs from the bundle.
239
+ * Returns whether the bundle is idle. Bounded, and synchronous like the rest of
240
+ * the launcher.
241
+ */
242
+ export function waitForBundleIdle({
243
+ pid = null,
244
+ installedApp,
245
+ timeoutMs = EXIT_WAIT_MS,
246
+ list = listProcessPaths,
247
+ alive = isAlive,
248
+ sleep = sleepMs,
249
+ now = Date.now,
250
+ }) {
251
+ const deadline = now() + timeoutMs;
252
+ for (;;) {
253
+ const pidGone = pid === null || !alive(pid);
254
+ const running = bundleProcesses(installedApp, list);
255
+ if (pidGone && running !== null && running.length === 0) return true;
256
+ if (now() >= deadline) return false;
257
+ sleep(250);
258
+ }
259
+ }
260
+
261
+ // ── moving bundles ────────────────────────────────────────────────────────
262
+
263
+ /**
264
+ * Move a bundle. A rename when both paths share a device — always, in practice,
265
+ * since the store and ~/Applications both live under the home directory — and
266
+ * otherwise `ditto` (which preserves the framework symlinks and extended
267
+ * attributes a bundle depends on) to a sibling path, then a same-directory
268
+ * rename, so the destination only ever appears complete.
269
+ */
270
+ export function moveBundle(src, dest, { rename = renameSync, exec = execFileSync } = {}) {
271
+ mkdirSync(dirname(dest), { recursive: true });
272
+ try {
273
+ rename(src, dest);
274
+ return;
275
+ } catch (err) {
276
+ if (!err || err.code !== "EXDEV") throw err;
277
+ }
278
+ const sibling = `${dest}.moving.${process.pid}`;
279
+ rmSync(sibling, { recursive: true, force: true });
280
+ exec("/usr/bin/ditto", [src, sibling], { stdio: "ignore" });
281
+ rename(sibling, dest);
282
+ rmSync(src, { recursive: true, force: true });
283
+ }
284
+
285
+ function repointSymlink(linkPath, targetDir) {
286
+ try {
287
+ rmSync(linkPath, { recursive: false, force: true });
288
+ } catch {
289
+ /* nothing there */
290
+ }
291
+ symlinkSync(targetDir, linkPath, "dir");
292
+ }
293
+
294
+ /**
295
+ * Make `version` (already staged under `versions/<version>/app/`) the installed
296
+ * bundle, keeping whatever was installed as `previous`.
297
+ *
298
+ * Two renames. The second can fail — a full disk, a permissions surprise — and
299
+ * a failure between them is the moment an updater can lose a user's app: the
300
+ * Tauri updater did exactly that, with its backup in a temporary directory that
301
+ * deleted itself. So the backup lives in the store, and a failed second rename
302
+ * puts it straight back before the error propagates. Sparkle closes the gap
303
+ * entirely with `renamex_np(RENAME_SWAP)`, which Node cannot reach; the window
304
+ * here is two syscalls wide.
305
+ *
306
+ * The CALLER guarantees nothing runs from the bundle.
307
+ */
308
+ export function swapInBundle({
309
+ layout,
310
+ version,
311
+ bundleName,
312
+ installedApp,
313
+ markerPath,
314
+ fsOps = {},
315
+ log = () => {},
316
+ }) {
317
+ const incomingDir = versionDir(layout, version);
318
+ const incoming = join(incomingDir, "app", bundleName);
319
+ if (!existsSync(incoming)) throw new Error(`version ${version} has no ${bundleName} in the store`);
320
+
321
+ const outgoingVersion = existsSync(installedApp) ? readBundleVersion(installedApp) : null;
322
+ let aside = null;
323
+ let retainedDir = null;
324
+ if (existsSync(installedApp)) {
325
+ if (outgoingVersion !== null && outgoingVersion !== version) {
326
+ retainedDir = versionDir(layout, outgoingVersion);
327
+ try {
328
+ rmSync(retainedDir, { recursive: true, force: true });
329
+ aside = join(retainedDir, "app", bundleName);
330
+ } catch (err) {
331
+ // A leftover copy we cannot delete — macOS App Management can refuse to
332
+ // modify a bundle that has been launched. Losing the rollback target is
333
+ // survivable; failing the update over it is not.
334
+ log(`could not clear ${retainedDir} (${err.message}); not keeping ${outgoingVersion} as previous`);
335
+ retainedDir = null;
336
+ }
337
+ }
338
+ if (aside === null) {
339
+ // Unreadable, or the same version being reinstalled: keep it only for the
340
+ // length of the swap, as a way back if the second rename fails.
341
+ // A fresh name every time, so nothing has to be deleted before the move.
342
+ aside = join(layout.root, "displaced", `${String(process.pid)}-${String(Date.now())}`, bundleName);
343
+ }
344
+ moveBundle(installedApp, aside, fsOps);
345
+ }
346
+
347
+ try {
348
+ moveBundle(incoming, installedApp, fsOps);
349
+ } catch (err) {
350
+ if (aside !== null) {
351
+ try {
352
+ moveBundle(aside, installedApp, fsOps);
353
+ log(`swap to ${version} failed; restored the installed bundle`);
354
+ } catch (restoreErr) {
355
+ log(`swap to ${version} failed AND the restore failed: ${restoreErr.message}; the bundle is at ${aside}`);
356
+ }
357
+ }
358
+ throw err;
359
+ }
360
+
361
+ // Past the point of no return: the new bundle is installed. Everything below
362
+ // is bookkeeping, and a failure in it must not be reported as a failed swap.
363
+ try {
364
+ mkdirSync(dirname(markerPath), { recursive: true });
365
+ writeFileSync(markerPath, version);
366
+ } catch {
367
+ /* the Info.plist is the authority; the marker is for older launchers */
368
+ }
369
+ if (retainedDir !== null) {
370
+ try {
371
+ writeFileSync(join(retainedDir, SEED_COMPLETE_MARKER), new Date().toISOString());
372
+ repointSymlink(layout.previousLink, retainedDir);
373
+ } catch (err) {
374
+ log(`could not record ${outgoingVersion} as previous: ${err.message}`);
375
+ }
376
+ } else if (aside !== null) {
377
+ rmSync(dirname(aside), { recursive: true, force: true });
378
+ }
379
+ // The staged version's directory is now an empty shell; its bundle is installed.
380
+ rmSync(incomingDir, { recursive: true, force: true });
381
+ try {
382
+ execFileSync("/usr/bin/mdimport", [installedApp], { stdio: "ignore" });
383
+ } catch {
384
+ /* Spotlight indexes ~/Applications on its own schedule too */
385
+ }
386
+ return { previousVersion: retainedDir === null ? null : outgoingVersion };
387
+ }
388
+
389
+ // ── unpacking the npm payload into the store ─────────────────────────────
390
+
391
+ function isPathInside(root, candidate) {
392
+ const escaped = relative(root, candidate);
393
+ return escaped !== "" && !escaped.startsWith("..") && !isAbsolute(escaped);
394
+ }
395
+
396
+ /**
397
+ * Whether an extracted bundle is a real directory inside `root` whose every
398
+ * symlink resolves inside `root` too. The name check in `unsafeArchiveEntries`
399
+ * cannot see where a symlink POINTS; this walks the tree without following
400
+ * directories and asks. MIRRORED in the app's `mac-bundle.ts`, with a parity test.
401
+ */
402
+ export function isExtractedBundleInside(root, bundlePath) {
403
+ try {
404
+ const rootReal = realpathSync(root);
405
+ const bundleStat = lstatSync(bundlePath);
406
+ if (bundleStat.isSymbolicLink() || !bundleStat.isDirectory()) return false;
407
+ if (!isPathInside(rootReal, realpathSync(bundlePath))) return false;
408
+ const pending = [bundlePath];
409
+ while (pending.length > 0) {
410
+ const current = pending.pop();
411
+ if (current === undefined) continue;
412
+ const stat = lstatSync(current);
413
+ if (stat.isSymbolicLink()) {
414
+ if (!isPathInside(rootReal, realpathSync(current))) return false;
415
+ continue;
416
+ }
417
+ if (!stat.isDirectory()) continue;
418
+ for (const entry of readdirSync(current)) pending.push(join(current, entry));
419
+ }
420
+ return true;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ /** Refuse an archive with any entry that would escape the extraction directory. */
427
+ export function validateArchive(archivePath, { exec = execFileSync } = {}) {
428
+ const listing = String(
429
+ exec("tar", ["-tzf", archivePath], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }),
430
+ );
431
+ const entries = listing
432
+ .split(/\r?\n/u)
433
+ .map((entry) => entry.trim())
434
+ .filter((entry) => entry.length > 0);
435
+ const unsafe = unsafeArchiveEntries(entries);
436
+ if (unsafe.length > 0) {
437
+ throw new Error(`payload archive contains unsafe entries: ${unsafe.slice(0, 5).join(", ")}`);
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Extract the npm payload's `app.tar.gz` into the store as `version`, marked
443
+ * complete only once the bundle is verified. An interrupted run leaves an
444
+ * unmarked directory, which is treated as absent and cleared next time.
445
+ */
446
+ export function seedMacVersion({ layout, version, archivePath, bundleName, exeSegments }) {
447
+ const dir = versionDir(layout, version);
448
+ rmSync(dir, { recursive: true, force: true });
449
+ const appDir = join(dir, "app");
450
+ mkdirSync(appDir, { recursive: true });
451
+ try {
452
+ validateArchive(archivePath);
453
+ execFileSync("tar", ["-xzf", archivePath, "-C", appDir], { stdio: "inherit" });
454
+ const bundle = join(appDir, bundleName);
455
+ if (!isExtractedBundleInside(dir, bundle)) {
456
+ throw new Error(`payload archive did not contain a safe ${bundleName}`);
457
+ }
458
+ if (!looksMachO(join(dir, ...exeSegments))) {
459
+ throw new Error(`payload ${version} has no runnable executable`);
460
+ }
461
+ writeFileSync(join(dir, SEED_COMPLETE_MARKER), new Date().toISOString());
462
+ } catch (err) {
463
+ rmSync(dir, { recursive: true, force: true });
464
+ throw err;
465
+ }
466
+ }
467
+
468
+ // ── the startup decision, macOS edition ──────────────────────────────────
469
+
470
+ /**
471
+ * Decide what the macOS store needs, do it if nothing is running from the
472
+ * bundle, and report what happened.
473
+ *
474
+ * Never throws: a failure is logged to the store log and reported as `launch`,
475
+ * because the installed bundle — whatever version it is — is still there and
476
+ * still the right thing to open. The one exception is a bundle that does not
477
+ * exist and cannot be seeded; the caller fails with a clear message then.
478
+ *
479
+ * @param {{
480
+ * home: string, env: NodeJS.ProcessEnv, cacheName?: string,
481
+ * installedApp: string, exePath: string, bundleName: string,
482
+ * exeSegments: readonly string[], markerPath: string,
483
+ * payloadVersion: string, archivePath: string | null,
484
+ * afterPid?: number | null,
485
+ * willLaunch?: boolean,
486
+ * list?: () => string[] | null,
487
+ * log?: (message: string) => void,
488
+ * }} input
489
+ */
490
+ export function prepareMacStore({
491
+ home,
492
+ env,
493
+ cacheName,
494
+ installedApp,
495
+ exePath,
496
+ bundleName,
497
+ exeSegments,
498
+ markerPath,
499
+ payloadVersion,
500
+ archivePath,
501
+ afterPid = null,
502
+ // Whether the caller will open the app after this. An attempt at an unproven
503
+ // version is spent only when it is actually launched: apply-only runs on
504
+ // every quit, and spending an attempt there rolled good updates back after
505
+ // three quits without the app ever getting a chance to confirm itself.
506
+ willLaunch = true,
507
+ list = listProcessPaths,
508
+ waitMs = EXIT_WAIT_MS,
509
+ log = () => {},
510
+ }) {
511
+ let layout = null;
512
+ try {
513
+ layout = storeLayout({ platform: "darwin", home, env, ...(cacheName ? { cacheName } : {}) });
514
+ const L = layout;
515
+ const note = (message) => {
516
+ log(`store: ${message}`);
517
+ storeLog(L, message);
518
+ };
519
+
520
+ // Handed a pid by the app that is quitting: wait for it, and for its
521
+ // helpers, before looking at anything. Outside the lock, because the wait
522
+ // can be long and other launchers must not time out behind it.
523
+ const idleAfterWait =
524
+ afterPid === null
525
+ ? null
526
+ : waitForBundleIdle({ pid: afterPid, installedApp, timeoutMs: waitMs, list });
527
+ if (idleAfterWait === false) note(`pid ${String(afterPid)} or the bundle was still running after ${String(waitMs)}ms`);
528
+
529
+ const canSeed = archivePath !== null && existsSync(archivePath);
530
+ const decide = () =>
531
+ decideStartupAction({
532
+ currentVersion: installedMacVersion({ installedApp, exePath, markerPath }),
533
+ previousVersion: readPreviousVersion(L),
534
+ pending: readPending(L),
535
+ health: readHealth(L),
536
+ // An empty string never seeds (it is not a safe version), which is how a
537
+ // missing npm payload says "nothing to seed from".
538
+ payloadVersion: canSeed ? payloadVersion : "",
539
+ versionExists: (v) => isStagedMacVersion(L, v, exeSegments),
540
+ });
541
+
542
+ let decision = decide();
543
+ note(`${decision.action}: ${decision.reason}`);
544
+ if (decision.action === STORE_ACTION.launch) {
545
+ return { outcome: MAC_STORE_OUTCOME.launch, version: installedMacVersion({ installedApp, exePath, markerPath }), awaitingConfirmation: willLaunch && spendAttempt(L, installedApp, exePath, markerPath) };
546
+ }
547
+
548
+ // Every other action swaps the bundle. Not while anything runs from it.
549
+ const running = bundleProcesses(installedApp, list);
550
+ const busy = running === null || running.length > 0;
551
+ if (busy) {
552
+ if (decision.action === STORE_ACTION.seed && canSeed && decision.version !== null) {
553
+ // npm has something newer and the app is up. Stage it instead, so the
554
+ // app's quit — or the next launcher run — applies it without anyone
555
+ // having to remember to come back.
556
+ const staged = withStoreLock(L, () => {
557
+ if (!isStagedMacVersion(L, decision.version, exeSegments)) {
558
+ seedMacVersion({ layout: L, version: decision.version, archivePath, bundleName, exeSegments });
559
+ }
560
+ const pending = readPending(L);
561
+ if (pending === null || isNewer(decision.version, pending.version)) writePending(L, decision.version);
562
+ return true;
563
+ });
564
+ if (staged !== LOCK_UNAVAILABLE) {
565
+ note(`Nissy is running; staged ${decision.version} to apply when it quits`);
566
+ return { outcome: MAC_STORE_OUTCOME.stagedForLater, version: decision.version, awaitingConfirmation: false };
567
+ }
568
+ }
569
+ note(`${decision.action} ${decision.version ?? ""} deferred: Nissy is running`);
570
+ return { outcome: MAC_STORE_OUTCOME.deferred, version: decision.version, awaitingConfirmation: false };
571
+ }
572
+
573
+ let outcome = MAC_STORE_OUTCOME.launch;
574
+ const held = withStoreLock(L, () => {
575
+ // Re-decided under the lock: another launcher may have acted while this
576
+ // one was waiting for it.
577
+ decision = decide();
578
+ switch (decision.action) {
579
+ case STORE_ACTION.seed:
580
+ if (!isStagedMacVersion(L, decision.version, exeSegments)) {
581
+ seedMacVersion({ layout: L, version: decision.version, archivePath, bundleName, exeSegments });
582
+ }
583
+ swapInBundle({ layout: L, version: decision.version, bundleName, installedApp, markerPath, log: note });
584
+ // A freshly seeded version came from the user's own `npm i -g`; nothing
585
+ // is pending against it.
586
+ clearPending(L);
587
+ writeHealth(L, decision.version);
588
+ outcome = MAC_STORE_OUTCOME.seeded;
589
+ break;
590
+ case STORE_ACTION.applyPending:
591
+ swapInBundle({ layout: L, version: decision.version, bundleName, installedApp, markerPath, log: note });
592
+ // Not confirmed here: the app writes health once it has actually
593
+ // started, and until then the version is on trial.
594
+ writePending(L, decision.version);
595
+ outcome = MAC_STORE_OUTCOME.applied;
596
+ break;
597
+ case STORE_ACTION.rollback:
598
+ swapInBundle({ layout: L, version: decision.version, bundleName, installedApp, markerPath, log: note });
599
+ clearPending(L);
600
+ outcome = MAC_STORE_OUTCOME.rolledBack;
601
+ break;
602
+ default:
603
+ break;
604
+ }
605
+ });
606
+ if (held === LOCK_UNAVAILABLE) {
607
+ note("another launcher holds the store lock; opening the installed bundle as it is");
608
+ return { outcome: MAC_STORE_OUTCOME.deferred, version: decision.version, awaitingConfirmation: false };
609
+ }
610
+ note(`${outcome} ${decision.version ?? ""}`);
611
+ return {
612
+ outcome,
613
+ version: installedMacVersion({ installedApp, exePath, markerPath }),
614
+ awaitingConfirmation: willLaunch && spendAttempt(L, installedApp, exePath, markerPath),
615
+ };
616
+ } catch (err) {
617
+ const detail = (err && err.stack) || String(err);
618
+ if (layout !== null) storeLog(layout, `FAILED, opening the installed bundle as it is\n${detail}`);
619
+ log(`store unavailable (${err && err.message}); opening the installed bundle as it is`);
620
+ return { outcome: MAC_STORE_OUTCOME.launch, version: null, awaitingConfirmation: false, error: err };
621
+ }
622
+ }
623
+
624
+ /** Spend one attempt when the installed version is on trial — exactly the
625
+ * Windows rule, so a version that never confirms is rolled back after three
626
+ * launcher-started launches. */
627
+ function spendAttempt(layout, installedApp, exePath, markerPath) {
628
+ const current = installedMacVersion({ installedApp, exePath, markerPath });
629
+ const pending = readPending(layout);
630
+ const health = readHealth(layout);
631
+ if (current === null || pending === null || pending.version !== current) return false;
632
+ if (health !== null && health.version === current) return false;
633
+ const held = withStoreLock(layout, () => consumeTry(layout), { waitMs: 0 });
634
+ return held !== LOCK_UNAVAILABLE;
635
+ }
636
+
637
+ /**
638
+ * Prune versions nothing refers to. On macOS the APP confirms its own start
639
+ * (the launcher hands off to LaunchServices and cannot watch the process), so
640
+ * this only tidies — current is the installed bundle, which is never in
641
+ * `versions/`, so what is kept is `previous` and anything pending.
642
+ */
643
+ export function pruneMacStore({ home, env, cacheName }) {
644
+ try {
645
+ const layout = storeLayout({ platform: "darwin", home, env, ...(cacheName ? { cacheName } : {}) });
646
+ withStoreLock(
647
+ layout,
648
+ () => {
649
+ const dead = prunableVersions({
650
+ versions: listVersions(layout),
651
+ currentVersion: null,
652
+ previousVersion: readPreviousVersion(layout),
653
+ pending: readPending(layout),
654
+ });
655
+ if (dead.length > 0) storeLog(layout, `pruning ${dead.join(", ")}`);
656
+ pruneVersions(layout, dead);
657
+ rmSync(join(layout.root, "displaced"), { recursive: true, force: true });
658
+ },
659
+ { waitMs: 0 },
660
+ );
661
+ } catch {
662
+ /* tidying must never affect a launch */
663
+ }
664
+ }
package/src/resolve.d.mts CHANGED
@@ -12,3 +12,7 @@ export declare const PAYLOADS: Record<
12
12
  string,
13
13
  { pkg: string; exe: readonly string[] }
14
14
  >;
15
+
16
+ /** Archive entries that would escape the extraction directory. Mirrored by the
17
+ * app's `mac-bundle.ts`; enforced by `mac-bundle.test.ts`. */
18
+ export declare function unsafeArchiveEntries(entries: readonly string[]): string[];
package/src/resolve.mjs CHANGED
@@ -247,20 +247,3 @@ export function resolveLaunchCommand({
247
247
  viaLaunchServices: false,
248
248
  };
249
249
  }
250
-
251
- /**
252
- * Whether the macOS ~/Applications install needs (re)writing: when the recorded
253
- * install-version marker doesn't match the payload version, or the installed
254
- * bundle's executable is missing. Pure so the decision is unit-testable; the
255
- * launcher does the filesystem work.
256
- *
257
- * @param {{ markerVersion: string | null, payloadVersion: string, exeExists: boolean }} input
258
- * @returns {boolean}
259
- */
260
- export function needsMacReinstall({
261
- markerVersion,
262
- payloadVersion,
263
- exeExists,
264
- }) {
265
- return !exeExists || markerVersion !== payloadVersion;
266
- }