@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 +73 -3
- package/package.json +7 -4
- package/src/cli.d.mts +21 -0
- package/src/cli.mjs +108 -2
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
|
-
|
|
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.
|
|
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.
|
|
24
|
-
"@nissyai/app-darwin-arm64": "0.2.
|
|
25
|
-
"@nissyai/app-darwin-x64": "0.2.
|
|
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(
|
|
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. */
|