@nissyai/desktop 0.2.68 → 0.2.71

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
@@ -28,13 +28,17 @@ import {
28
28
  resolvePayloadSpec,
29
29
  UnsupportedPlatformError,
30
30
  } from "../src/resolve.mjs";
31
- import { confirmHealthyStart, prepareStore } from "../src/store.mjs";
31
+ import { confirmHealthyStart, prepareStore, storeLayout } from "../src/store.mjs";
32
32
  import { MAC_STORE_OUTCOME, prepareMacStore, pruneMacStore } from "../src/mac-store.mjs";
33
33
  import {
34
+ clearHandoverMarker,
35
+ HANDOVER_MARKER_FILE,
34
36
  launcherCommand,
35
37
  launcherOptions,
36
38
  runLauncherCommand,
37
39
  stripLauncherFlags,
40
+ waitForProcessExit,
41
+ writeHandoverMarker,
38
42
  } from "../src/cli.mjs";
39
43
  import { DISTRIBUTION } from "../src/distribution.mjs";
40
44
  import { asarBeside, demoPreflight, demoTemplatePath, userDataDir } from "../src/demo-preflight.mjs";
@@ -65,8 +69,40 @@ const LAUNCHER_VERSION = (() => {
65
69
  }
66
70
  })();
67
71
 
72
+ /**
73
+ * What a failed start falls back to: the Velopack build that handed over to this
74
+ * launcher (`--nissy-fallback`), so a handover that fails never leaves no Nissy running.
75
+ * Set below, once the argv is parsed; null for every ordinary launch.
76
+ */
77
+ let fallbackExe = null;
78
+ /** The handover marker this launcher wrote, or null. */
79
+ let handoverMarker = null;
80
+ /** The pid the app handing over asked us to wait for, or null. */
81
+ let waitForPid = null;
82
+
83
+ /** Start the fallback, once the process that handed over has gone (it holds the
84
+ * single-instance lock until then). True when it was started. */
85
+ function startFallback() {
86
+ const exe = fallbackExe;
87
+ if (exe === null) return false;
88
+ // Once: a late spawn error after a fallback already started must not start another.
89
+ fallbackExe = null;
90
+ if (waitForPid !== null) waitForProcessExit(waitForPid);
91
+ try {
92
+ const args = stripLauncherFlags(process.argv.slice(2)).filter((a) => a !== "--foreground");
93
+ spawn(exe, args, { detached: true, stdio: "ignore" }).unref();
94
+ console.error(`[${DISTRIBUTION.npmCommand}] started ${exe} instead`);
95
+ return true;
96
+ } catch (err) {
97
+ console.error(`[${DISTRIBUTION.npmCommand}] could not start ${exe} either: ${err.message}`);
98
+ return false;
99
+ }
100
+ }
101
+
68
102
  function fail(message) {
69
103
  console.error(`[${DISTRIBUTION.npmCommand}] ${message}`);
104
+ if (handoverMarker !== null) clearHandoverMarker(handoverMarker);
105
+ startFallback();
70
106
  process.exit(1);
71
107
  }
72
108
 
@@ -129,7 +165,20 @@ if (launcherOwnCommand !== null) {
129
165
  );
130
166
  }
131
167
 
132
- const { afterPid, applyOnly } = launcherOptions(process.argv.slice(2));
168
+ const { afterPid, applyOnly, fallback } = launcherOptions(process.argv.slice(2));
169
+ fallbackExe = fallback;
170
+ waitForPid = afterPid;
171
+ // A handover from the Velopack bridge (Windows only): say so in the store until the app
172
+ // is started, so a second launch of the Velopack build meanwhile is not taken for a
173
+ // failed handover. See HANDOVER_MARKER_FILE.
174
+ if (fallback !== null && afterPid !== null && process.platform === "win32") {
175
+ handoverMarker = join(
176
+ storeLayout({ platform: process.platform, home: homedir(), env: process.env, cacheName: DISTRIBUTION.cacheName })
177
+ .root,
178
+ HANDOVER_MARKER_FILE,
179
+ );
180
+ writeHandoverMarker(handoverMarker, { launcherPid: process.pid });
181
+ }
133
182
 
134
183
  let spec;
135
184
  try {
@@ -397,8 +446,18 @@ try {
397
446
  storeLog(`demo pre-flight skipped: ${err.message}`);
398
447
  }
399
448
 
449
+ // The app handing over on Windows (the Velopack bridge) starts this launcher detached
450
+ // and exits: wait for it to be gone, or the app started here would find the old one
451
+ // still holding the single-instance lock and quit. Bounded, and a timeout still starts
452
+ // the app: the worst case is the behaviour without the wait. macOS waited above, inside
453
+ // the mac store.
454
+ if (mac === null && afterPid !== null && !waitForProcessExit(afterPid)) {
455
+ storeLog(`pid ${String(afterPid)} was still running; starting anyway`);
456
+ }
457
+
400
458
  if (foreground) {
401
459
  const child = spawn(command, args, { stdio: "inherit", env: childEnv });
460
+ if (handoverMarker !== null) clearHandoverMarker(handoverMarker);
402
461
  child.on("error", (err) => {
403
462
  fail(`failed to spawn payload: ${err.message}`);
404
463
  });
@@ -482,9 +541,20 @@ if (foreground) {
482
541
  //
483
542
  // This is still the weak version of the handshake — see confirmHealthyStart.
484
543
  const onTrial = staged !== null && staged.awaitingConfirmation === true;
485
- if (onTrial) {
544
+ // A handover waits the same few seconds, on trial or not: an app that is gone by
545
+ // then never started, and the Velopack build it replaced is started instead.
546
+ if (onTrial || fallbackExe !== null) {
486
547
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, HEALTH_WAIT_MS);
487
548
  }
549
+ // Cleared after the wait, not at the spawn: by now the app holds the single-instance
550
+ // lock (or has gone), so a Velopack start can no longer be mistaken for either. And
551
+ // before the fallback, as `fail()` does: the Velopack build it starts must not find a
552
+ // live marker and step aside for a launch that has already failed.
553
+ if (handoverMarker !== null) clearHandoverMarker(handoverMarker);
554
+ if (fallbackExe !== null && !stillRunning(child.pid)) {
555
+ console.error(`[${DISTRIBUTION.npmCommand}] the app exited straight after starting`);
556
+ startFallback();
557
+ }
488
558
  confirmHealthyStart({
489
559
  platform: process.platform,
490
560
  home: homedir(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nissyai/desktop",
3
- "version": "0.2.68",
3
+ "version": "0.2.71",
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",
@@ -12,6 +12,9 @@
12
12
  "bin": {
13
13
  "nissy": "bin/nissy.mjs"
14
14
  },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
15
18
  "publishConfig": {
16
19
  "access": "public"
17
20
  },
@@ -20,8 +23,8 @@
20
23
  "src/"
21
24
  ],
22
25
  "optionalDependencies": {
23
- "@nissyai/app-win32-x64": "0.2.68",
24
- "@nissyai/app-darwin-arm64": "0.2.68",
25
- "@nissyai/app-darwin-x64": "0.2.68"
26
+ "@nissyai/app-win32-x64": "0.2.71",
27
+ "@nissyai/app-darwin-arm64": "0.2.71",
28
+ "@nissyai/app-darwin-x64": "0.2.71"
26
29
  }
27
30
  }
package/src/cli.d.mts CHANGED
@@ -6,8 +6,29 @@
6
6
 
7
7
  export declare const AFTER_PID_FLAG: "--nissy-after-pid";
8
8
  export declare const APPLY_ONLY_FLAG: "--nissy-apply-only";
9
+ export declare const FALLBACK_FLAG: "--nissy-fallback";
9
10
  export declare function launcherOptions(argv: readonly string[]): {
10
11
  afterPid: number | null;
11
12
  applyOnly: boolean;
13
+ fallback: string | null;
12
14
  };
13
15
  export declare function stripLauncherFlags(argv: readonly string[]): string[];
16
+ export declare const HANDOVER_MARKER_FILE: "handover-starting.json";
17
+ export declare function writeHandoverMarker(
18
+ file: string,
19
+ options: { launcherPid: number; image?: string; now?: () => number },
20
+ ): void;
21
+ export declare function clearHandoverMarker(file: string): void;
22
+ export declare function readHandoverMarker(
23
+ file: string,
24
+ ): { launcherPid: number; at: number; image: string | null } | null;
25
+ export declare const AFTER_PID_WAIT_MS: number;
26
+ export declare function waitForProcessExit(
27
+ pid: number,
28
+ options?: {
29
+ timeoutMs?: number;
30
+ alive?: (pid: number) => boolean;
31
+ sleep?: (ms: number) => void;
32
+ now?: () => number;
33
+ },
34
+ ): boolean;
package/src/cli.mjs CHANGED
@@ -12,6 +12,9 @@
12
12
  // not belong in the middle of it. Pure apart from the store it is handed, so the
13
13
  // tests do not need a real install.
14
14
 
15
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { basename, dirname } from "node:path";
17
+
15
18
  import {
16
19
  compareVersions,
17
20
  isCompleteVersion,
@@ -45,23 +48,126 @@ export const ROLLBACK_FLAG = "--rollback";
45
48
  */
46
49
  export const AFTER_PID_FLAG = "--nissy-after-pid";
47
50
  export const APPLY_ONLY_FLAG = "--nissy-apply-only";
51
+ /**
52
+ * `--nissy-fallback=<exe>`: what to start if the npm build cannot be started. Set by
53
+ * the Velopack bridge when it hands over, naming the Velopack executable it is about
54
+ * to exit from, so a handover that fails never leaves no Nissy running.
55
+ */
56
+ export const FALLBACK_FLAG = "--nissy-fallback";
48
57
 
49
58
  /** The launcher-only options in an argv. A malformed pid is ignored rather than
50
59
  * trusted: waiting on the wrong process is worse than not waiting. */
51
60
  export function launcherOptions(argv) {
52
61
  let afterPid = null;
62
+ let fallback = null;
53
63
  for (const arg of argv) {
54
64
  if (arg.startsWith(`${AFTER_PID_FLAG}=`)) {
55
65
  const pid = Number(arg.slice(AFTER_PID_FLAG.length + 1));
56
66
  if (Number.isInteger(pid) && pid > 0) afterPid = pid;
67
+ } else if (arg.startsWith(`${FALLBACK_FLAG}=`)) {
68
+ const exe = arg.slice(FALLBACK_FLAG.length + 1);
69
+ if (exe.length > 0) fallback = exe;
57
70
  }
58
71
  }
59
- return { afterPid, applyOnly: argv.includes(APPLY_ONLY_FLAG) };
72
+ return { afterPid, applyOnly: argv.includes(APPLY_ONLY_FLAG), fallback };
60
73
  }
61
74
 
62
75
  /** The argv with the launcher-only flags removed, for forwarding to the app. */
63
76
  export function stripLauncherFlags(argv) {
64
- return argv.filter((arg) => arg !== APPLY_ONLY_FLAG && !arg.startsWith(`${AFTER_PID_FLAG}=`));
77
+ return argv.filter(
78
+ (arg) =>
79
+ arg !== APPLY_ONLY_FLAG && !arg.startsWith(`${AFTER_PID_FLAG}=`) && !arg.startsWith(`${FALLBACK_FLAG}=`),
80
+ );
81
+ }
82
+
83
+ /**
84
+ * The marker a handover's launcher keeps in the store while it starts the npm build.
85
+ *
86
+ * The first start after a handover seeds the store, a copy of the whole app that can
87
+ * take a while, and the user can open Nissy again meanwhile through a shortcut that
88
+ * still points at the Velopack build. That build reads this marker: a launcher that is
89
+ * still alive and recent means "starting", not "the npm build never started", so it
90
+ * steps aside instead of counting a failed handover. Cleared once the app is started.
91
+ * Carries the launcher's executable name as well as its pid, so a pid Windows has since
92
+ * given to another program is not mistaken for this launcher.
93
+ * MUST match `HANDOVER_MARKER_FILE` in the desktop's velopack-migration module.
94
+ */
95
+ export const HANDOVER_MARKER_FILE = "handover-starting.json";
96
+
97
+ /** Record that this launcher is starting the npm build after a handover. Best-effort:
98
+ * without it a second launch costs one counted attempt, which is the old behaviour. */
99
+ export function writeHandoverMarker(file, { launcherPid, image = basename(process.execPath), now = Date.now }) {
100
+ try {
101
+ mkdirSync(dirname(file), { recursive: true });
102
+ writeFileSync(file, JSON.stringify({ launcherPid, at: now(), image }));
103
+ } catch {
104
+ // ignore
105
+ }
106
+ }
107
+
108
+ export function clearHandoverMarker(file) {
109
+ try {
110
+ rmSync(file, { force: true });
111
+ } catch {
112
+ // ignore
113
+ }
114
+ }
115
+
116
+ /** The marker's content, or null when it is absent or unreadable. */
117
+ export function readHandoverMarker(file) {
118
+ try {
119
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
120
+ return Number.isInteger(parsed.launcherPid) && Number.isFinite(parsed.at)
121
+ ? {
122
+ launcherPid: parsed.launcherPid,
123
+ at: parsed.at,
124
+ image: typeof parsed.image === "string" && parsed.image.length > 0 ? parsed.image : null,
125
+ }
126
+ : null;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /** How long the launcher waits for `--nissy-after-pid` to exit before starting the
133
+ * app anyway. Velopack's own updater gives an exiting app the same minute. */
134
+ export const AFTER_PID_WAIT_MS = 60_000;
135
+
136
+ function processAlive(pid) {
137
+ try {
138
+ // Signal 0 checks without delivering; on Windows libuv answers from the process's
139
+ // exit code, so an exited process reads as gone.
140
+ process.kill(pid, 0);
141
+ return true;
142
+ } catch (err) {
143
+ // EPERM: it exists but belongs to someone else, so it is still there.
144
+ return err !== null && typeof err === "object" && err.code === "EPERM";
145
+ }
146
+ }
147
+
148
+ function sleepMs(ms) {
149
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
150
+ }
151
+
152
+ /**
153
+ * Wait, bounded, for `pid` to exit. Returns whether it did.
154
+ *
155
+ * On Windows this is how the app hands over without `app.relaunch`: it starts the
156
+ * launcher detached and hidden with its own pid, and exits. Starting the new app
157
+ * before the old one has gone would lose it the single-instance lock, and the new app
158
+ * would quit on the spot. macOS waits inside the mac store, for the bundle as well.
159
+ * Synchronous like the rest of the launcher.
160
+ */
161
+ export function waitForProcessExit(
162
+ pid,
163
+ { timeoutMs = AFTER_PID_WAIT_MS, alive = processAlive, sleep = sleepMs, now = Date.now } = {},
164
+ ) {
165
+ const deadline = now() + timeoutMs;
166
+ for (;;) {
167
+ if (!alive(pid)) return true;
168
+ if (now() >= deadline) return false;
169
+ sleep(100);
170
+ }
65
171
  }
66
172
 
67
173
  /** Which command, if any, the argv asks for. */