@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.
- package/bin/nissy.mjs +144 -142
- package/package.json +4 -4
- package/src/cli.d.mts +13 -0
- package/src/cli.mjs +63 -5
- package/src/demo-preflight.d.mts +35 -0
- package/src/demo-preflight.mjs +129 -0
- package/src/mac-store.d.mts +8 -0
- package/src/mac-store.mjs +664 -0
- package/src/resolve.d.mts +4 -0
- package/src/resolve.mjs +0 -17
|
@@ -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
|
-
}
|