@nissyai/desktop 0.1.0
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 +211 -0
- package/package.json +27 -0
- package/src/resolve.mjs +181 -0
package/bin/nissy.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// `nissy` launcher entry point (internal npx dev channel).
|
|
4
|
+
//
|
|
5
|
+
// Picks the payload package for the host platform/arch, finds where npm
|
|
6
|
+
// installed it, and spawns the self-contained app executable inside it,
|
|
7
|
+
// inheriting stdio and propagating the exit code / signal.
|
|
8
|
+
|
|
9
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { createRequire } from "node:module";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
cacheRootSegments,
|
|
26
|
+
needsMacReinstall,
|
|
27
|
+
npxChannelEnv,
|
|
28
|
+
resolveLaunchCommand,
|
|
29
|
+
resolvePayloadSpec,
|
|
30
|
+
UnsupportedPlatformError,
|
|
31
|
+
} from "../src/resolve.mjs";
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
|
|
35
|
+
function fail(message) {
|
|
36
|
+
console.error(`[nissy] ${message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let spec;
|
|
41
|
+
try {
|
|
42
|
+
spec = resolvePayloadSpec(process.platform, process.arch);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err instanceof UnsupportedPlatformError) {
|
|
45
|
+
fail(`unsupported platform: ${err.key}`);
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let payloadPkgJson;
|
|
51
|
+
try {
|
|
52
|
+
payloadPkgJson = require.resolve(`${spec.pkg}/package.json`);
|
|
53
|
+
} catch {
|
|
54
|
+
fail(
|
|
55
|
+
`platform payload "${spec.pkg}" is not installed for ${spec.key}. ` +
|
|
56
|
+
`Reinstall so the matching optional dependency is fetched.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Resolve the executable to launch. Two payload layouts:
|
|
61
|
+
// • app/ — Forge output copied verbatim (Windows). Run in place.
|
|
62
|
+
// • app.tar.gz — symlink-preserving archive (macOS; npm pack would strip the
|
|
63
|
+
// .app's framework symlinks). INSTALL it into ~/Applications
|
|
64
|
+
// — a standard, Spotlight-indexed location — so Nissy shows
|
|
65
|
+
// in Spotlight / Launchpad / Finder and carries its Dock
|
|
66
|
+
// icon, rather than running hidden from a per-user cache.
|
|
67
|
+
// Refreshed in place when the payload version changes. Done
|
|
68
|
+
// here (not a postinstall hook) so package managers that skip
|
|
69
|
+
// lifecycle scripts on cached installs still self-heal.
|
|
70
|
+
const payloadRoot = dirname(payloadPkgJson);
|
|
71
|
+
const archivePath = join(payloadRoot, "app.tar.gz");
|
|
72
|
+
let exePath;
|
|
73
|
+
// The installed macOS `.app` bundle, when this is the macOS archive payload —
|
|
74
|
+
// the anchor we hand to `open` so LaunchServices dedups. null on Windows
|
|
75
|
+
// (run-in-place), where we exec the binary directly.
|
|
76
|
+
let macBundlePath = null;
|
|
77
|
+
if (existsSync(join(payloadRoot, "app"))) {
|
|
78
|
+
exePath = join(payloadRoot, ...spec.exe);
|
|
79
|
+
} else if (existsSync(archivePath)) {
|
|
80
|
+
const { version } = JSON.parse(readFileSync(payloadPkgJson, "utf8"));
|
|
81
|
+
const appsDir = join(homedir(), "Applications");
|
|
82
|
+
const bundleName = spec.exe[1]; // e.g. "Nissy.app" (spec.exe[0] is "app")
|
|
83
|
+
const installedApp = join(appsDir, bundleName);
|
|
84
|
+
macBundlePath = installedApp;
|
|
85
|
+
// The exe inside the installed bundle (drop the payload's leading "app/").
|
|
86
|
+
exePath = join(appsDir, ...spec.exe.slice(1));
|
|
87
|
+
const cacheRoot = join(
|
|
88
|
+
...cacheRootSegments({ platform: process.platform, home: homedir(), env: process.env }),
|
|
89
|
+
);
|
|
90
|
+
const markerPath = join(cacheRoot, "applications-darwin.version");
|
|
91
|
+
const markerVersion = existsSync(markerPath)
|
|
92
|
+
? readFileSync(markerPath, "utf8").trim()
|
|
93
|
+
: null;
|
|
94
|
+
|
|
95
|
+
if (needsMacReinstall({ markerVersion, payloadVersion: version, exeExists: existsSync(exePath) })) {
|
|
96
|
+
const tmp = `${installedApp}.tmp.${process.pid}`;
|
|
97
|
+
try {
|
|
98
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
99
|
+
mkdirSync(tmp, { recursive: true });
|
|
100
|
+
execFileSync("tar", ["-xzf", archivePath, "-C", tmp], { stdio: "inherit" });
|
|
101
|
+
const tmpApp = join(tmp, bundleName);
|
|
102
|
+
if (!existsSync(tmpApp)) {
|
|
103
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
104
|
+
fail(`payload archive ${archivePath} did not contain ${bundleName}`);
|
|
105
|
+
}
|
|
106
|
+
mkdirSync(appsDir, { recursive: true });
|
|
107
|
+
// Swap: move any existing install aside, rename the new one into place,
|
|
108
|
+
// then best-effort remove the old. RENAMING the old bundle (rather than
|
|
109
|
+
// deleting it) keeps a currently-running instance's mapped files valid
|
|
110
|
+
// until it quits; the new version is used on next launch.
|
|
111
|
+
const old = `${installedApp}.old.${process.pid}`;
|
|
112
|
+
rmSync(old, { recursive: true, force: true });
|
|
113
|
+
if (existsSync(installedApp)) renameSync(installedApp, old);
|
|
114
|
+
renameSync(tmpApp, installedApp);
|
|
115
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
116
|
+
rmSync(old, { recursive: true, force: true });
|
|
117
|
+
mkdirSync(dirname(markerPath), { recursive: true });
|
|
118
|
+
writeFileSync(markerPath, version);
|
|
119
|
+
// Best-effort: index the new bundle now so Spotlight/Launchpad pick it up
|
|
120
|
+
// without waiting for the periodic scan. Never block launch.
|
|
121
|
+
try {
|
|
122
|
+
execFileSync("/usr/bin/mdimport", [installedApp], { stdio: "ignore" });
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore — Spotlight indexes ~/Applications on its own schedule too
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
128
|
+
if (!existsSync(exePath)) {
|
|
129
|
+
fail(`failed to install ${installedApp}: ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Best-effort: reclaim space from the OLD version-keyed cache layout earlier
|
|
135
|
+
// launcher versions used (~140 MB each). Never block launch.
|
|
136
|
+
try {
|
|
137
|
+
for (const entry of readdirSync(cacheRoot)) {
|
|
138
|
+
if (entry.startsWith("nissy-app-darwin-")) {
|
|
139
|
+
rmSync(join(cacheRoot, entry), { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// ignore — cleanup is non-essential
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
fail(`payload "${spec.pkg}" contains neither app/ nor app.tar.gz`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!existsSync(exePath)) {
|
|
150
|
+
fail(`payload executable missing at: ${exePath}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// By default we launch the app detached and return immediately — `npx`/`nissy`
|
|
154
|
+
// frees the terminal instead of streaming the app's stdio. The app writes its
|
|
155
|
+
// own persistent log (electron-log → ~/Library/Logs/Nissy/main.log on macOS),
|
|
156
|
+
// so dropping stdio here loses nothing. Pass --foreground to stay attached and
|
|
157
|
+
// mirror the app's stdio + exit code, which is what you want when debugging.
|
|
158
|
+
const forwardedArgs = process.argv.slice(2).filter((a) => a !== "--foreground");
|
|
159
|
+
const foreground = forwardedArgs.length !== process.argv.slice(2).length;
|
|
160
|
+
|
|
161
|
+
// On macOS, launch through `/usr/bin/open <bundle>` so LaunchServices enforces
|
|
162
|
+
// single-instance: a `nissy`/`npx` run while Nissy is already up just activates
|
|
163
|
+
// the running instance instead of spawning a second mascot. Direct-exec of the
|
|
164
|
+
// inner binary (Windows, or `--foreground` debugging on macOS) bypasses that OS
|
|
165
|
+
// dedup and leans solely on Electron's userData lock — which fails open after a
|
|
166
|
+
// stale-lock-leaving crash. See resolveLaunchCommand for the full rationale.
|
|
167
|
+
const { command, args, viaLaunchServices } = resolveLaunchCommand({
|
|
168
|
+
platform: process.platform,
|
|
169
|
+
bundlePath: macBundlePath,
|
|
170
|
+
exePath,
|
|
171
|
+
foreground,
|
|
172
|
+
forwardedArgs,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Stamp the spawned app with this channel + the STABLE launcher command
|
|
176
|
+
// (absolute node + this script). On Windows the app reads the channel marker to
|
|
177
|
+
// pick its autostart/update strategy; on macOS the channel is platform-derived
|
|
178
|
+
// (and `open` launches via LaunchServices, which does NOT inherit this env
|
|
179
|
+
// anyway), so the stamp is a harmless no-op there.
|
|
180
|
+
const childEnv = {
|
|
181
|
+
...process.env,
|
|
182
|
+
...npxChannelEnv(process.execPath, fileURLToPath(import.meta.url)),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (foreground) {
|
|
186
|
+
const child = spawn(command, args, { stdio: "inherit", env: childEnv });
|
|
187
|
+
child.on("error", (err) => {
|
|
188
|
+
fail(`failed to spawn payload: ${err.message}`);
|
|
189
|
+
});
|
|
190
|
+
child.on("exit", (code, signal) => {
|
|
191
|
+
if (signal !== null) {
|
|
192
|
+
// Re-raise the signal so the parent's exit reflects how the child died.
|
|
193
|
+
process.kill(process.pid, signal);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
process.exit(code ?? 0);
|
|
197
|
+
});
|
|
198
|
+
} else {
|
|
199
|
+
// `open` exits as soon as it hands off to LaunchServices; the direct-exec
|
|
200
|
+
// path needs the detach so this launcher can exit while the app keeps
|
|
201
|
+
// running. Either way, don't tie the app's lifetime to this process.
|
|
202
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", env: childEnv });
|
|
203
|
+
child.on("error", (err) => {
|
|
204
|
+
fail(
|
|
205
|
+
viaLaunchServices
|
|
206
|
+
? `failed to launch via 'open': ${err.message}`
|
|
207
|
+
: `failed to spawn payload: ${err.message}`,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
child.unref();
|
|
211
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nissyai/desktop",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Launcher for the Nissy desktop app (internal npx dev channel). 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
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Cleverbit",
|
|
9
|
+
"email": "hello@cleverbit.software"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://nissy.ai",
|
|
12
|
+
"bin": {
|
|
13
|
+
"nissy": "bin/nissy.mjs"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin/",
|
|
20
|
+
"src/"
|
|
21
|
+
],
|
|
22
|
+
"optionalDependencies": {
|
|
23
|
+
"@nissyai/app-win32-x64": "0.1.0",
|
|
24
|
+
"@nissyai/app-darwin-arm64": "0.1.0",
|
|
25
|
+
"@nissyai/app-darwin-x64": "0.1.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/resolve.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Pure platform → payload mapping for the `nissy` launcher.
|
|
2
|
+
//
|
|
3
|
+
// No IO here so it is trivially unit-testable. The bin entry
|
|
4
|
+
// (../bin/nissy.mjs) layers `require.resolve` + `spawn` on top.
|
|
5
|
+
//
|
|
6
|
+
// The Electron Forge `package` output is self-contained (it bundles
|
|
7
|
+
// Electron), so each payload exposes a single executable we spawn
|
|
8
|
+
// directly — there is no dependency on the `electron` npm package.
|
|
9
|
+
|
|
10
|
+
/** Error thrown when the host platform/arch has no payload mapping. */
|
|
11
|
+
export class UnsupportedPlatformError extends Error {
|
|
12
|
+
/** @param {string} key host key, e.g. "linux-x64" */
|
|
13
|
+
constructor(key) {
|
|
14
|
+
super(`unsupported platform: ${key}`);
|
|
15
|
+
this.name = "UnsupportedPlatformError";
|
|
16
|
+
this.key = key;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Host key (`${platform}-${arch}`) → payload package name + the executable
|
|
22
|
+
* within it, expressed as path segments (joined with the OS separator at
|
|
23
|
+
* the call site so this stays platform-neutral and testable).
|
|
24
|
+
*
|
|
25
|
+
* @type {Record<string, { pkg: string, exe: readonly string[] }>}
|
|
26
|
+
*/
|
|
27
|
+
export const PAYLOADS = {
|
|
28
|
+
"win32-x64": {
|
|
29
|
+
pkg: "@nissyai/app-win32-x64",
|
|
30
|
+
exe: ["app", "nissy.exe"],
|
|
31
|
+
},
|
|
32
|
+
"darwin-arm64": {
|
|
33
|
+
pkg: "@nissyai/app-darwin-arm64",
|
|
34
|
+
exe: ["app", "Nissy.app", "Contents", "MacOS", "nissy"],
|
|
35
|
+
},
|
|
36
|
+
"darwin-x64": {
|
|
37
|
+
pkg: "@nissyai/app-darwin-x64",
|
|
38
|
+
exe: ["app", "Nissy.app", "Contents", "MacOS", "nissy"],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the payload spec for a host platform/arch.
|
|
44
|
+
* @param {NodeJS.Platform | string} platform
|
|
45
|
+
* @param {string} arch
|
|
46
|
+
* @returns {{ key: string, pkg: string, exe: readonly string[] }}
|
|
47
|
+
* @throws {UnsupportedPlatformError} when no mapping exists
|
|
48
|
+
*/
|
|
49
|
+
export function resolvePayloadSpec(platform, arch) {
|
|
50
|
+
const key = `${platform}-${arch}`;
|
|
51
|
+
const spec = PAYLOADS[key];
|
|
52
|
+
if (spec === undefined) {
|
|
53
|
+
throw new UnsupportedPlatformError(key);
|
|
54
|
+
}
|
|
55
|
+
return { key, pkg: spec.pkg, exe: spec.exe };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Path segments for the per-user cache root where a payload that ships as an
|
|
60
|
+
* archive (macOS — npm pack strips the .app's framework symlinks) is extracted
|
|
61
|
+
* on first run. Pure (env injected) so it stays unit-testable; the caller joins
|
|
62
|
+
* with the OS separator.
|
|
63
|
+
*
|
|
64
|
+
* @param {{ platform: NodeJS.Platform | string, home: string, env: Record<string, string | undefined> }} host
|
|
65
|
+
* @returns {string[]} path segments of the cache root
|
|
66
|
+
*/
|
|
67
|
+
export function cacheRootSegments({ platform, home, env }) {
|
|
68
|
+
if (platform === "darwin") return [home, "Library", "Caches", "nissy"];
|
|
69
|
+
if (platform === "win32") {
|
|
70
|
+
return [env.LOCALAPPDATA || env.TEMP || home, "nissy", "Cache"];
|
|
71
|
+
}
|
|
72
|
+
return env.XDG_CACHE_HOME ? [env.XDG_CACHE_HOME, "nissy"] : [home, ".cache", "nissy"];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Filesystem-safe, version-pinned cache key for a payload package, e.g.
|
|
77
|
+
* ("@nissyai/app-darwin-x64", "1.2.3") → "nissyai-app-darwin-x64@1.2.3". Version
|
|
78
|
+
* pinning means a new app version extracts to a fresh dir (no stale binaries).
|
|
79
|
+
*
|
|
80
|
+
* @param {string} pkg payload package name
|
|
81
|
+
* @param {string} version
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function payloadCacheKey(pkg, version) {
|
|
85
|
+
const safe = pkg.replace(/^@/, "").replace(/\//g, "-");
|
|
86
|
+
return `${safe}@${version}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Given the entry names in the cache root and the current cache key, return the
|
|
91
|
+
* stale extracted-payload dirs to prune (older versions of the SAME payload).
|
|
92
|
+
* Keeps the current version; ignores unrelated dirs and in-flight `.tmp.<pid>`
|
|
93
|
+
* extraction dirs (a concurrent first-run may still own those). Pure so the
|
|
94
|
+
* keep/prune decision is unit-testable; the caller does the filesystem removal.
|
|
95
|
+
*
|
|
96
|
+
* @param {string[]} entries dir names directly under the cache root
|
|
97
|
+
* @param {string} currentKey the current `payloadCacheKey(pkg, version)`
|
|
98
|
+
* @returns {string[]} entry names safe to remove
|
|
99
|
+
*/
|
|
100
|
+
export function stalePayloadCaches(entries, currentKey) {
|
|
101
|
+
const at = currentKey.lastIndexOf("@");
|
|
102
|
+
if (at <= 0) return [];
|
|
103
|
+
const pkgPrefix = currentKey.slice(0, at + 1); // e.g. "nissyai-app-darwin-x64@"
|
|
104
|
+
return entries.filter(
|
|
105
|
+
(name) =>
|
|
106
|
+
name !== currentKey &&
|
|
107
|
+
name.startsWith(pkgPrefix) &&
|
|
108
|
+
!name.includes(".tmp."),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Environment the launcher stamps on the spawned app so it can tell it was
|
|
114
|
+
* started via this channel (vs the native Velopack install) and knows the
|
|
115
|
+
* STABLE launcher command to register for launch-at-login. A macOS LaunchAgent
|
|
116
|
+
* must point at an absolute node + script, NOT the version-keyed payload cache
|
|
117
|
+
* exe (which is pruned on update). Pure so the mapping is unit-testable.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} nodePath absolute path to the node binary running the launcher (`process.execPath`)
|
|
120
|
+
* @param {string} launcherScript absolute path to this launcher script
|
|
121
|
+
* @returns {{ NISSY_CHANNEL: string, NISSY_LAUNCHER_NODE: string, NISSY_LAUNCHER_SCRIPT: string }}
|
|
122
|
+
*/
|
|
123
|
+
export function npxChannelEnv(nodePath, launcherScript) {
|
|
124
|
+
return {
|
|
125
|
+
NISSY_CHANNEL: "npx",
|
|
126
|
+
NISSY_LAUNCHER_NODE: nodePath,
|
|
127
|
+
NISSY_LAUNCHER_SCRIPT: launcherScript,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decide HOW to launch the resolved payload — the command + argv to spawn.
|
|
133
|
+
*
|
|
134
|
+
* On macOS we go through `/usr/bin/open <bundle>` so that **LaunchServices**
|
|
135
|
+
* enforces its single-instance rule: launching an app that's already running
|
|
136
|
+
* just activates the existing instance instead of spawning a second process.
|
|
137
|
+
* Exec'ing the inner Mach-O binary directly (`…/Contents/MacOS/nissy`, the
|
|
138
|
+
* historical path) bypasses that OS-level dedup entirely — leaving Electron's
|
|
139
|
+
* userData `requestSingleInstanceLock()` as the *only* guard, which fails open
|
|
140
|
+
* when a prior instance crashed and left a stale `SingletonLock` (this is how
|
|
141
|
+
* two mascots ended up running at once). Note: plain `open` — NOT `open -n`,
|
|
142
|
+
* which would FORCE a new instance, the opposite of what we want.
|
|
143
|
+
*
|
|
144
|
+
* `--foreground` (a debugging mode that mirrors the app's stdio + exit code)
|
|
145
|
+
* keeps the direct-exec path, since `open` detaches and can't stream stdio.
|
|
146
|
+
* Windows has no `open` equivalent here and runs the payload in place, so it
|
|
147
|
+
* also keeps the direct-exec path.
|
|
148
|
+
*
|
|
149
|
+
* Pure (no IO) so the decision is unit-testable; the caller does the spawn.
|
|
150
|
+
*
|
|
151
|
+
* @param {{
|
|
152
|
+
* platform: NodeJS.Platform | string,
|
|
153
|
+
* bundlePath: string | null,
|
|
154
|
+
* exePath: string,
|
|
155
|
+
* foreground: boolean,
|
|
156
|
+
* forwardedArgs: readonly string[],
|
|
157
|
+
* }} input bundlePath is the installed `.app` (macOS only; null elsewhere).
|
|
158
|
+
* @returns {{ command: string, args: string[], viaLaunchServices: boolean }}
|
|
159
|
+
*/
|
|
160
|
+
export function resolveLaunchCommand({ platform, bundlePath, exePath, foreground, forwardedArgs }) {
|
|
161
|
+
if (platform === "darwin" && !foreground && bundlePath !== null) {
|
|
162
|
+
const args = [bundlePath];
|
|
163
|
+
// `open` forwards extra argv to the app only after a `--args` separator.
|
|
164
|
+
if (forwardedArgs.length > 0) args.push("--args", ...forwardedArgs);
|
|
165
|
+
return { command: "/usr/bin/open", args, viaLaunchServices: true };
|
|
166
|
+
}
|
|
167
|
+
return { command: exePath, args: [...forwardedArgs], viaLaunchServices: false };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether the macOS ~/Applications install needs (re)writing: when the recorded
|
|
172
|
+
* install-version marker doesn't match the payload version, or the installed
|
|
173
|
+
* bundle's executable is missing. Pure so the decision is unit-testable; the
|
|
174
|
+
* launcher does the filesystem work.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ markerVersion: string | null, payloadVersion: string, exeExists: boolean }} input
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
export function needsMacReinstall({ markerVersion, payloadVersion, exeExists }) {
|
|
180
|
+
return !exeExists || markerVersion !== payloadVersion;
|
|
181
|
+
}
|