@nissyai/desktop 0.2.56 → 0.2.63

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 CHANGED
@@ -6,36 +6,38 @@
6
6
  // installed it, and spawns the self-contained app executable inside it,
7
7
  // inheriting stdio and propagating the exit code / signal.
8
8
 
9
- import { execFileSync, spawn } from "node:child_process";
9
+ import { spawn } from "node:child_process";
10
10
  import {
11
11
  existsSync,
12
- lstatSync,
13
12
  mkdirSync,
14
13
  readdirSync,
15
14
  readFileSync,
16
- realpathSync,
17
- renameSync,
18
15
  rmSync,
19
16
  writeFileSync,
20
17
  } from "node:fs";
21
18
  import { createRequire } from "node:module";
22
19
  import { homedir } from "node:os";
23
- import { dirname, isAbsolute, join, relative } from "node:path";
20
+ import { dirname, join } from "node:path";
24
21
  import { fileURLToPath } from "node:url";
25
22
 
26
23
  import {
27
24
  cacheRootSegments,
28
25
  LAUNCHER_TOOLCHAIN_FILE,
29
- needsMacReinstall,
30
26
  npxChannelEnv,
31
27
  resolveLaunchCommand,
32
28
  resolvePayloadSpec,
33
- unsafeArchiveEntries,
34
29
  UnsupportedPlatformError,
35
30
  } from "../src/resolve.mjs";
36
31
  import { confirmHealthyStart, prepareStore } from "../src/store.mjs";
37
- import { launcherCommand, runLauncherCommand } from "../src/cli.mjs";
32
+ import { MAC_STORE_OUTCOME, prepareMacStore, pruneMacStore } from "../src/mac-store.mjs";
33
+ import {
34
+ launcherCommand,
35
+ launcherOptions,
36
+ runLauncherCommand,
37
+ stripLauncherFlags,
38
+ } from "../src/cli.mjs";
38
39
  import { DISTRIBUTION } from "../src/distribution.mjs";
40
+ import { asarBeside, demoPreflight, demoTemplatePath, userDataDir } from "../src/demo-preflight.mjs";
39
41
 
40
42
  const require = createRequire(import.meta.url);
41
43
 
@@ -68,58 +70,31 @@ function fail(message) {
68
70
  process.exit(1);
69
71
  }
70
72
 
71
- function validateArchive(archivePath) {
72
- const listing = execFileSync("tar", ["-tzf", archivePath], {
73
- encoding: "utf8",
74
- stdio: ["ignore", "pipe", "inherit"],
75
- });
76
- const entries = listing
77
- .split(/\r?\n/u)
78
- .map((entry) => entry.trim())
79
- .filter((entry) => entry.length > 0);
80
- const unsafe = unsafeArchiveEntries(entries);
81
- if (unsafe.length > 0) {
82
- throw new Error(
83
- `payload archive contains unsafe entries: ${unsafe.slice(0, 5).join(", ")}`,
84
- );
85
- }
86
- }
87
-
88
- function isPathInside(root, candidate) {
89
- const escaped = relative(root, candidate);
90
- return escaped !== "" && !escaped.startsWith("..") && !isAbsolute(escaped);
91
- }
92
-
93
- function isExtractedBundleInside(tempDir, bundlePath) {
73
+ /** Where the macOS bundle lives and how to read it, for a supported Mac host;
74
+ * null anywhere else. Computed without the payload, because the installed
75
+ * bundle is what runs and it must be describable when npm's copy is gone. */
76
+ function macContext() {
77
+ if (process.platform !== "darwin") return null;
78
+ let macSpec;
94
79
  try {
95
- const tempRoot = realpathSync(tempDir);
96
- const bundleStat = lstatSync(bundlePath);
97
- if (bundleStat.isSymbolicLink() || !bundleStat.isDirectory()) return false;
98
- const bundleRoot = realpathSync(bundlePath);
99
- if (!isPathInside(tempRoot, bundleRoot)) return false;
100
-
101
- // The top-level bundle can be inside the temp directory while an inner
102
- // symlink still points outside it. Walk without following directories and
103
- // reject every such link before the bundle is moved into Applications.
104
- const pending = [bundlePath];
105
- while (pending.length > 0) {
106
- const current = pending.pop();
107
- if (current === undefined) continue;
108
- const stat = lstatSync(current);
109
- if (stat.isSymbolicLink()) {
110
- if (!isPathInside(tempRoot, realpathSync(current))) return false;
111
- continue;
112
- }
113
- if (!stat.isDirectory()) continue;
114
- for (const entry of readdirSync(current)) {
115
- pending.push(join(current, entry));
116
- }
117
- }
118
- return true;
80
+ macSpec = resolvePayloadSpec(process.platform, process.arch);
119
81
  } catch {
120
- return false;
82
+ return null;
121
83
  }
84
+ const bundleName = macSpec.exe[1]; // e.g. "Nissy.app" (exe[0] is "app")
85
+ const appsDir = join(homedir(), "Applications");
86
+ return {
87
+ bundleName,
88
+ exeSegments: macSpec.exe,
89
+ installedApp: join(appsDir, bundleName),
90
+ exePath: join(appsDir, ...macSpec.exe.slice(1)),
91
+ markerPath: join(
92
+ ...cacheRootSegments({ platform: process.platform, home: homedir(), env: process.env }),
93
+ "applications-darwin.version",
94
+ ),
95
+ };
122
96
  }
97
+ const mac = macContext();
123
98
 
124
99
  // `--version` / `--rollback` answer and exit FIRST — before the payload is
125
100
  // resolved, before the store is prepared, before anything is spawned.
@@ -146,6 +121,7 @@ if (launcherOwnCommand !== null) {
146
121
  // alongside it, not something they depend on.
147
122
  payloadVersion: readPayloadVersion(),
148
123
  launcherVersion: LAUNCHER_VERSION,
124
+ mac,
149
125
  log: (message) => {
150
126
  console.log(message);
151
127
  },
@@ -153,6 +129,8 @@ if (launcherOwnCommand !== null) {
153
129
  );
154
130
  }
155
131
 
132
+ const { afterPid, applyOnly } = launcherOptions(process.argv.slice(2));
133
+
156
134
  let spec;
157
135
  try {
158
136
  spec = resolvePayloadSpec(process.platform, process.arch);
@@ -163,32 +141,32 @@ try {
163
141
  throw err;
164
142
  }
165
143
 
166
- let payloadPkgJson;
144
+ const missingPayloadMessage =
145
+ `platform payload "${spec.pkg}" is not installed for ${spec.key}. ` +
146
+ `Reinstall so the matching optional dependency is fetched.`;
147
+ let payloadPkgJson = null;
167
148
  try {
168
149
  payloadPkgJson = require.resolve(`${spec.pkg}/package.json`);
169
150
  } catch {
170
- fail(
171
- `platform payload "${spec.pkg}" is not installed for ${spec.key}. ` +
172
- `Reinstall so the matching optional dependency is fetched.`,
173
- );
151
+ // On macOS an installed bundle still runs without npm's copy — npm drops an
152
+ // optional dependency whose download failed and exits 0, which is exactly how
153
+ // a Mac ended up with a launcher and no payload. Everywhere else, and on a Mac
154
+ // with nothing installed, there is nothing to run.
155
+ if (mac === null || !existsSync(mac.exePath)) fail(missingPayloadMessage);
174
156
  }
175
157
 
176
158
  // Resolve the executable to launch. Two payload layouts:
177
- // • app/ — Forge output copied verbatim (Windows). Run in place.
178
- // • app.tar.gz — symlink-preserving archive (macOS; npm pack would strip the
179
- // .app's framework symlinks). INSTALL it into ~/Applications
180
- // — a standard, Spotlight-indexed location — so Nissy shows
181
- // in Spotlight / Launchpad / Finder and carries its Dock
182
- // icon, rather than running hidden from a per-user cache.
183
- // Refreshed in place when the payload version changes. Done
184
- // here (not a postinstall hook) so package managers that skip
185
- // lifecycle scripts on cached installs still self-heal.
186
- const payloadRoot = dirname(payloadPkgJson);
187
- const archivePath = join(payloadRoot, "app.tar.gz");
159
+ // • app/ — Forge output copied verbatim (Windows). Seeds the store.
160
+ // • app.tar.gz — symlink-preserving archive (macOS). Installed into
161
+ // ~/Applications by the macOS store below, and only when
162
+ // npm's copy is genuinely newer than what is installed.
163
+ const payloadRoot = payloadPkgJson === null ? null : dirname(payloadPkgJson);
164
+ const archivePath = payloadRoot === null ? null : join(payloadRoot, "app.tar.gz");
188
165
  // The version npm currently has on disk. On Windows this is what the store
189
166
  // seeds from; it goes STALE the moment the app updates itself, which is why the
190
167
  // store only ever seeds from it when it is genuinely newer than `current`.
191
168
  const payloadVersionForStore = (() => {
169
+ if (payloadPkgJson === null) return "";
192
170
  try {
193
171
  return JSON.parse(readFileSync(payloadPkgJson, "utf8")).version;
194
172
  } catch {
@@ -201,79 +179,70 @@ let exePath;
201
179
  // the anchor we hand to `open` so LaunchServices dedups. null on Windows
202
180
  // (run-in-place), where we exec the binary directly.
203
181
  let macBundlePath = null;
204
- if (existsSync(join(payloadRoot, "app"))) {
205
- exePath = join(payloadRoot, ...spec.exe);
206
- } else if (existsSync(archivePath)) {
207
- const { version } = JSON.parse(readFileSync(payloadPkgJson, "utf8"));
208
- const appsDir = join(homedir(), "Applications");
209
- const bundleName = spec.exe[1]; // e.g. "Nissy.app" (spec.exe[0] is "app")
210
- const installedApp = join(appsDir, bundleName);
211
- macBundlePath = installedApp;
212
- // The exe inside the installed bundle (drop the payload's leading "app/").
213
- exePath = join(appsDir, ...spec.exe.slice(1));
214
- const cacheRoot = join(
215
- ...cacheRootSegments({ platform: process.platform, home: homedir(), env: process.env }),
216
- );
217
- const markerPath = join(cacheRoot, "applications-darwin.version");
218
- const markerVersion = existsSync(markerPath)
219
- ? readFileSync(markerPath, "utf8").trim()
220
- : null;
221
-
222
- if (needsMacReinstall({ markerVersion, payloadVersion: version, exeExists: existsSync(exePath) })) {
223
- const tmp = `${installedApp}.tmp.${process.pid}`;
224
- try {
225
- rmSync(tmp, { recursive: true, force: true });
226
- mkdirSync(tmp, { recursive: true });
227
- validateArchive(archivePath);
228
- execFileSync("tar", ["-xzf", archivePath, "-C", tmp], { stdio: "inherit" });
229
- const tmpApp = join(tmp, bundleName);
230
- if (!existsSync(tmpApp) || !isExtractedBundleInside(tmp, tmpApp)) {
231
- rmSync(tmp, { recursive: true, force: true });
232
- fail(`payload archive ${archivePath} did not contain a safe ${bundleName}`);
233
- }
234
- mkdirSync(appsDir, { recursive: true });
235
- // Swap: move any existing install aside, rename the new one into place,
236
- // then best-effort remove the old. RENAMING the old bundle (rather than
237
- // deleting it) keeps a currently-running instance's mapped files valid
238
- // until it quits; the new version is used on next launch.
239
- const old = `${installedApp}.old.${process.pid}`;
240
- rmSync(old, { recursive: true, force: true });
241
- if (existsSync(installedApp)) renameSync(installedApp, old);
242
- renameSync(tmpApp, installedApp);
243
- rmSync(tmp, { recursive: true, force: true });
244
- rmSync(old, { recursive: true, force: true });
245
- mkdirSync(dirname(markerPath), { recursive: true });
246
- writeFileSync(markerPath, version);
247
- // Best-effort: index the new bundle now so Spotlight/Launchpad pick it up
248
- // without waiting for the periodic scan. Never block launch.
249
- try {
250
- execFileSync("/usr/bin/mdimport", [installedApp], { stdio: "ignore" });
251
- } catch {
252
- // ignore — Spotlight indexes ~/Applications on its own schedule too
253
- }
254
- } catch (err) {
255
- rmSync(tmp, { recursive: true, force: true });
256
- if (!existsSync(exePath)) {
257
- fail(`failed to install ${installedApp}: ${err.message}`);
258
- }
259
- // The previous install is still there, so we CAN carry on — and carrying
260
- // on is right: a user with a working app should not be stopped from
261
- // opening it because the new version would not unpack.
262
- //
263
- // But say so. Silence here is what made the validation bug above cost
264
- // real users their updates rather than a bug report: the app relaunched
265
- // the old version, found the same update waiting, and looped — with
266
- // nothing written anywhere to say an install had been attempted at all.
267
- console.error(
268
- `[nissy] could not install ${version}: ${err.message}\n` +
269
- `[nissy] continuing on the installed version at ${installedApp}`,
270
- );
271
- }
182
+ if (mac !== null) {
183
+ // macOS: `current` is the bundle in ~/Applications — a standard,
184
+ // Spotlight-indexed location, so Nissy shows in Spotlight / Launchpad / Finder
185
+ // and carries its Dock icon. The npm payload ships it as `app.tar.gz`
186
+ // (symlink-preserving; npm pack would strip the framework symlinks), and the
187
+ // store decides whether to install it, apply an update the app staged, or roll
188
+ // back — and never does any of that while Nissy is running. See
189
+ // src/mac-store.mjs. Done here, not in a postinstall hook, so package managers
190
+ // that skip lifecycle scripts on cached installs still self-heal.
191
+ macBundlePath = mac.installedApp;
192
+ exePath = mac.exePath;
193
+ const prepared = prepareMacStore({
194
+ home: homedir(),
195
+ env: process.env,
196
+ cacheName: DISTRIBUTION.cacheName,
197
+ installedApp: mac.installedApp,
198
+ exePath: mac.exePath,
199
+ bundleName: mac.bundleName,
200
+ exeSegments: mac.exeSegments,
201
+ markerPath: mac.markerPath,
202
+ payloadVersion: payloadVersionForStore,
203
+ archivePath,
204
+ afterPid,
205
+ willLaunch: !applyOnly,
206
+ log: (message) => {
207
+ if (process.env.NISSY_STORE_DEBUG === "1") console.error(`[${DISTRIBUTION.npmCommand}] ${message}`);
208
+ },
209
+ });
210
+ if (prepared.error !== undefined) {
211
+ // Said out loud, not only logged: a store that silently never updates looks
212
+ // exactly like one that is working. The installed bundle still opens.
213
+ console.error(`[${DISTRIBUTION.npmCommand}] could not update: ${prepared.error.message}`);
214
+ }
215
+ if (prepared.outcome === MAC_STORE_OUTCOME.stagedForLater) {
216
+ console.error(
217
+ `[${DISTRIBUTION.npmCommand}] Nissy is running, so ${prepared.version} is ready and will be ` +
218
+ `installed when you quit it.`,
219
+ );
220
+ } else if (prepared.outcome === MAC_STORE_OUTCOME.deferred && prepared.version !== null && !applyOnly) {
221
+ console.error(
222
+ `[${DISTRIBUTION.npmCommand}] Nissy is running, so ${prepared.version} was not installed yet. ` +
223
+ `Quit Nissy and run ${DISTRIBUTION.npmCommand} again to finish.`,
224
+ );
225
+ }
226
+ if (!existsSync(exePath)) {
227
+ fail(
228
+ payloadPkgJson === null
229
+ ? missingPayloadMessage
230
+ : `failed to install ${mac.installedApp}; see ${join(
231
+ ...cacheRootSegments({ platform: process.platform, home: homedir(), env: process.env }),
232
+ "store",
233
+ "store.log",
234
+ )}`,
235
+ );
272
236
  }
237
+ pruneMacStore({ home: homedir(), env: process.env, cacheName: DISTRIBUTION.cacheName });
238
+ if (applyOnly) process.exit(0);
273
239
 
274
240
  // Best-effort: reclaim space from the OLD version-keyed cache layout earlier
275
241
  // launcher versions used (~140 MB each). Never block launch.
276
242
  try {
243
+ const cacheRoot = join(
244
+ ...cacheRootSegments({ platform: process.platform, home: homedir(), env: process.env }),
245
+ );
277
246
  for (const entry of readdirSync(cacheRoot)) {
278
247
  if (entry.startsWith(`${DISTRIBUTION.cacheName}-app-darwin-`)) {
279
248
  rmSync(join(cacheRoot, entry), { recursive: true, force: true });
@@ -282,6 +251,8 @@ if (existsSync(join(payloadRoot, "app"))) {
282
251
  } catch {
283
252
  // ignore — cleanup is non-essential
284
253
  }
254
+ } else if (existsSync(join(payloadRoot, "app"))) {
255
+ exePath = join(payloadRoot, ...spec.exe);
285
256
  } else {
286
257
  fail(`payload "${spec.pkg}" contains neither app/ nor app.tar.gz`);
287
258
  }
@@ -350,8 +321,9 @@ if (staged !== null) {
350
321
  // own persistent log (electron-log → ~/Library/Logs/Nissy/main.log on macOS),
351
322
  // so dropping stdio here loses nothing. Pass --foreground to stay attached and
352
323
  // mirror the app's stdio + exit code, which is what you want when debugging.
353
- const forwardedArgs = process.argv.slice(2).filter((a) => a !== "--foreground");
354
- const foreground = forwardedArgs.length !== process.argv.slice(2).length;
324
+ const userArgs = stripLauncherFlags(process.argv.slice(2));
325
+ const forwardedArgs = userArgs.filter((a) => a !== "--foreground");
326
+ const foreground = forwardedArgs.length !== userArgs.length;
355
327
 
356
328
  // On macOS, launch through `/usr/bin/open <bundle>` so LaunchServices enforces
357
329
  // single-instance: a `nissy`/`npx` run while Nissy is already up just activates
@@ -395,6 +367,36 @@ try {
395
367
  // Ignore: self-update falls back to the env/persisted value or the manual command.
396
368
  }
397
369
 
370
+ // `--demo` against a missing or stale template: say so here, in the terminal
371
+ // the user typed the command into. The app would refuse it too, but from a
372
+ // detached process whose output this launcher has already let go of, so the
373
+ // only trace would be a dialog. Advisory by design: every path the check cannot
374
+ // decide lets the launch proceed and leaves the verdict to the app, and a fault
375
+ // in the check itself must never be what stops Nissy starting.
376
+ try {
377
+ const userData = userDataDir({
378
+ platform: process.platform,
379
+ home: homedir(),
380
+ env: process.env,
381
+ userDataName: DISTRIBUTION.userDataName,
382
+ });
383
+ if (userData !== null) {
384
+ const launchedVersion = staged !== null ? staged.version : payloadVersionForStore;
385
+ const verdict = demoPreflight({
386
+ args: forwardedArgs,
387
+ templatePath: demoTemplatePath({ userData, flavor: DISTRIBUTION.flavor, env: process.env }),
388
+ asarPath: asarBeside(exePath, existsSync),
389
+ appLabel: launchedVersion === "" ? DISTRIBUTION.appName : `${DISTRIBUTION.appName} ${launchedVersion}`,
390
+ command: DISTRIBUTION.npmCommand,
391
+ exists: existsSync,
392
+ readText: (path, encoding) => readFileSync(path, encoding),
393
+ });
394
+ if (!verdict.ok) fail(verdict.message);
395
+ }
396
+ } catch (err) {
397
+ storeLog(`demo pre-flight skipped: ${err.message}`);
398
+ }
399
+
398
400
  if (foreground) {
399
401
  const child = spawn(command, args, { stdio: "inherit", env: childEnv });
400
402
  child.on("error", (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nissyai/desktop",
3
- "version": "0.2.56",
3
+ "version": "0.2.63",
4
4
  "type": "module",
5
5
  "description": "Launcher for the Nissy desktop app. Resolves the host's app payload and spawns it. Run via `npx @nissyai/desktop`; a global install (`npm i -g @nissyai/desktop`) exposes the `nissy` command.",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -20,8 +20,8 @@
20
20
  "src/"
21
21
  ],
22
22
  "optionalDependencies": {
23
- "@nissyai/app-win32-x64": "0.2.56",
24
- "@nissyai/app-darwin-arm64": "0.2.56",
25
- "@nissyai/app-darwin-x64": "0.2.56"
23
+ "@nissyai/app-win32-x64": "0.2.63",
24
+ "@nissyai/app-darwin-arm64": "0.2.63",
25
+ "@nissyai/app-darwin-x64": "0.2.63"
26
26
  }
27
27
  }
package/src/cli.d.mts ADDED
@@ -0,0 +1,13 @@
1
+ // Types for the launcher-only flags the DESKTOP hands the launcher.
2
+ //
3
+ // The app builds these arguments and the launcher parses them; they share no
4
+ // code, so `launcher-handoff.test.ts` imports both sides through this
5
+ // declaration and fails if the names drift.
6
+
7
+ export declare const AFTER_PID_FLAG: "--nissy-after-pid";
8
+ export declare const APPLY_ONLY_FLAG: "--nissy-apply-only";
9
+ export declare function launcherOptions(argv: readonly string[]): {
10
+ afterPid: number | null;
11
+ applyOnly: boolean;
12
+ };
13
+ export declare function stripLauncherFlags(argv: readonly string[]): string[];
package/src/cli.mjs CHANGED
@@ -23,10 +23,47 @@ import {
23
23
  versionDir,
24
24
  clearPending,
25
25
  } from "./store.mjs";
26
+ import {
27
+ bundleProcesses,
28
+ installedMacVersion,
29
+ isStagedMacVersion,
30
+ swapInBundle,
31
+ } from "./mac-store.mjs";
26
32
 
27
33
  export const VERSION_FLAG = "--version";
28
34
  export const ROLLBACK_FLAG = "--rollback";
29
35
 
36
+ /**
37
+ * Flags that are for the launcher alone and are never forwarded to the app.
38
+ *
39
+ * Both are set by the APP, not by people. `--nissy-after-pid=<pid>` is how the
40
+ * app hands over when it restarts or quits with an update staged: the launcher
41
+ * waits for that process (and everything running from its bundle) to be gone
42
+ * before it swaps anything, which is the Squirrel.Mac / Sparkle contract.
43
+ * `--nissy-apply-only` asks for the swap and nothing else — the quit path, where
44
+ * the user asked to quit, not to see the app again.
45
+ */
46
+ export const AFTER_PID_FLAG = "--nissy-after-pid";
47
+ export const APPLY_ONLY_FLAG = "--nissy-apply-only";
48
+
49
+ /** The launcher-only options in an argv. A malformed pid is ignored rather than
50
+ * trusted: waiting on the wrong process is worse than not waiting. */
51
+ export function launcherOptions(argv) {
52
+ let afterPid = null;
53
+ for (const arg of argv) {
54
+ if (arg.startsWith(`${AFTER_PID_FLAG}=`)) {
55
+ const pid = Number(arg.slice(AFTER_PID_FLAG.length + 1));
56
+ if (Number.isInteger(pid) && pid > 0) afterPid = pid;
57
+ }
58
+ }
59
+ return { afterPid, applyOnly: argv.includes(APPLY_ONLY_FLAG) };
60
+ }
61
+
62
+ /** The argv with the launcher-only flags removed, for forwarding to the app. */
63
+ export function stripLauncherFlags(argv) {
64
+ return argv.filter((arg) => arg !== APPLY_ONLY_FLAG && !arg.startsWith(`${AFTER_PID_FLAG}=`));
65
+ }
66
+
30
67
  /** Which command, if any, the argv asks for. */
31
68
  export function launcherCommand(argv) {
32
69
  if (argv.includes(ROLLBACK_FLAG)) return "rollback";
@@ -129,14 +166,17 @@ export function planRollback({ currentVersion, previousVersion, versionExists })
129
166
  */
130
167
  export function runLauncherCommand(
131
168
  command,
132
- { platform, home, env, cacheName, payloadVersion, launcherVersion, log },
169
+ { platform, home, env, cacheName, payloadVersion, launcherVersion, log, mac = null },
133
170
  ) {
134
171
  const layout = storeLayout({ platform, home, env, ...(cacheName ? { cacheName } : {}) });
172
+ // On macOS `current` is the installed bundle, not a junction.
173
+ const readCurrent = () =>
174
+ mac === null ? readCurrentVersion(layout) : installedMacVersion(mac);
135
175
 
136
176
  if (command === "version") {
137
177
  log(
138
178
  describeVersions({
139
- storeVersion: readCurrentVersion(layout),
179
+ storeVersion: readCurrent(),
140
180
  previousVersion: readPreviousVersion(layout),
141
181
  payloadVersion,
142
182
  launcherVersion,
@@ -146,17 +186,35 @@ export function runLauncherCommand(
146
186
  }
147
187
 
148
188
  const plan = planRollback({
149
- currentVersion: readCurrentVersion(layout),
189
+ currentVersion: readCurrent(),
150
190
  previousVersion: readPreviousVersion(layout),
151
191
  versionExists: (version) =>
152
- listVersions(layout).includes(version) && isCompleteVersion(versionDir(layout, version)),
192
+ mac === null
193
+ ? listVersions(layout).includes(version) && isCompleteVersion(versionDir(layout, version))
194
+ : isStagedMacVersion(layout, version, mac.exeSegments),
153
195
  });
154
196
  if (!plan.ok) {
155
197
  log(`cannot roll back: ${plan.reason}`);
156
198
  return 1;
157
199
  }
158
200
  try {
159
- pointCurrentAt(layout, plan.to);
201
+ if (mac === null) {
202
+ pointCurrentAt(layout, plan.to);
203
+ } else {
204
+ // The same rule as every other macOS swap: never under a running app.
205
+ const running = bundleProcesses(mac.installedApp, mac.list);
206
+ if (running === null || running.length > 0) {
207
+ log("cannot roll back while Nissy is running. Quit it, then run this again.");
208
+ return 1;
209
+ }
210
+ swapInBundle({
211
+ layout,
212
+ version: plan.to,
213
+ bundleName: mac.bundleName,
214
+ installedApp: mac.installedApp,
215
+ markerPath: mac.markerPath,
216
+ });
217
+ }
160
218
  // Whatever was pending was asking for the version we have just moved away
161
219
  // from. Leaving it would let the next launch apply it again and undo this.
162
220
  clearPending(layout);
@@ -0,0 +1,35 @@
1
+ // Types for the launcher's Demo Mode pre-flight.
2
+ //
3
+ // The launcher mirrors two decisions the desktop owns: where the demo template
4
+ // lives, and how the installed app's migrations are named. They share no code,
5
+ // so `demo-preflight-parity.test.ts` in the desktop imports both sides through
6
+ // this declaration and fails if they drift.
7
+
8
+ export declare const DEMO_FLAG: "--demo";
9
+
10
+ export declare function userDataDir(input: {
11
+ platform: NodeJS.Platform;
12
+ home: string;
13
+ env: NodeJS.ProcessEnv;
14
+ userDataName: string;
15
+ }): string | null;
16
+
17
+ export declare function demoTemplatePath(input: {
18
+ userData: string;
19
+ flavor: string;
20
+ env: NodeJS.ProcessEnv;
21
+ }): string;
22
+
23
+ export declare function asarBeside(exePath: string, exists: (path: string) => boolean): string | null;
24
+
25
+ export declare function embeddedMigrations(asarText: string): string[];
26
+
27
+ export declare function demoPreflight(input: {
28
+ args: readonly string[];
29
+ templatePath: string;
30
+ asarPath: string | null;
31
+ appLabel: string;
32
+ command: string;
33
+ exists: (path: string) => boolean;
34
+ readText: (path: string, encoding: BufferEncoding) => string;
35
+ }): { ok: true } | { ok: false; message: string };