yapin.js-client 0.1.0 → 0.1.4

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.
@@ -55,20 +55,44 @@ export const defaults = Object.freeze({
55
55
  pages: {
56
56
  section: null,
57
57
  position: 'end',
58
- plugins: { title: 'Plugins', order: 10 },
59
- themes: { title: 'Themes', order: 20 },
60
- settings: { title: 'Settings', order: 5 },
58
+ plugins: { title: 'Plugins', order: 10, icon: 'puzzle' },
59
+ themes: { title: 'Themes', order: 20, icon: 'palette' },
60
+ settings: { title: 'Settings', order: 5, icon: 'sliders' },
61
61
  },
62
62
 
63
63
  about: {
64
64
  title: 'About',
65
65
  order: 0,
66
+ icon: 'info',
67
+ logo: null,
66
68
  intro: null,
67
69
  sections: {},
68
70
  fields: {},
69
71
  actions: {},
70
72
  },
71
73
 
74
+ welcome: {
75
+ enabled: true,
76
+ icon: 'sparkles',
77
+ title: 'Welcome to {name}',
78
+ subtitle: null,
79
+ steps: [],
80
+ actionLabel: 'Get started',
81
+ dismissible: true,
82
+ dismissLabel: 'Skip',
83
+ },
84
+
85
+ whatsNew: {
86
+ enabled: true,
87
+ icon: 'rocket',
88
+ title: "What's new in {name} {version}",
89
+ subtitle: null,
90
+ entries: [],
91
+ actionLabel: 'Got it',
92
+ dismissible: true,
93
+ dismissLabel: null,
94
+ },
95
+
72
96
  badges: {
73
97
  enabled: true,
74
98
  sources: [],
@@ -114,6 +138,9 @@ export const defaults = Object.freeze({
114
138
  icon: null,
115
139
  company: null,
116
140
  sign: null,
141
+ nsis: true,
142
+ sidebar: null,
143
+ header: null,
117
144
  },
118
145
  },
119
146
  });
@@ -8,7 +8,7 @@ import { idPattern } from '../shared/constants.js';
8
8
  export const CONFIG_FILES = ['yapin-client.config.js', 'yapin-client.config.mjs', 'yapin-client.config.json'];
9
9
 
10
10
  /** Sections that reach the running client app, so they must be JSON. */
11
- const RUNTIME_SECTIONS = ['settings', 'runtime', 'plugins', 'themes', 'pages', 'about', 'badges', 'api', 'host', 'desktop', 'origins', 'links'];
11
+ const RUNTIME_SECTIONS = ['settings', 'runtime', 'plugins', 'themes', 'pages', 'about', 'welcome', 'whatsNew', 'badges', 'api', 'host', 'desktop', 'origins', 'links'];
12
12
 
13
13
  export class ConfigError extends Error {
14
14
  constructor(problems, file) {
@@ -154,6 +154,14 @@ export function validateConfig(input, { root = process.cwd(), file = null } = {}
154
154
  }
155
155
  if (config.icon) config.icon = resolve(root, config.icon);
156
156
  if (config.installer.windows?.icon) config.installer.windows.icon = resolve(root, config.installer.windows.icon);
157
+ if (typeof config.installer.windows?.nsis === 'string') config.installer.windows.nsis = resolve(root, config.installer.windows.nsis);
158
+ for (const [key, size] of [['sidebar', '164×314'], ['header', '150×57']]) {
159
+ const file = config.installer.windows?.[key];
160
+ if (!file) continue;
161
+ config.installer.windows[key] = resolve(root, file);
162
+ if (!/\.bmp$/i.test(file)) problems.push(`installer.windows.${key} must be a .bmp (${size} pixels)`);
163
+ else if (!existsSync(config.installer.windows[key])) problems.push(`installer.windows.${key} ${config.installer.windows[key]} doesn't exist`);
164
+ }
157
165
  if (config.installer.windows?.sign && (typeof config.installer.windows.sign !== 'string' || !config.installer.windows.sign.includes('{file}'))) problems.push('installer.windows.sign must be a command containing {file}, like: signtool sign /fd sha256 /a {file}');
158
166
  if (!['leave', 'relax'].includes(config.desktop.csp)) problems.push('desktop.csp must be "leave" or "relax"');
159
167
  if (!['default', 'minimal', 'showcase'].includes(config.installer.preset)) problems.push('installer.preset must be "default", "minimal" or "showcase"');
@@ -64,6 +64,8 @@ const PREFIX = 'yapin-client:';
64
64
  },
65
65
  });
66
66
 
67
- const code = `(${boot.runtime})().default(${JSON.stringify(boot.args)});\n//# sourceURL=yapin-client://${encodeURIComponent(boot.args.manifest.id)}/runtime.js`;
67
+ // `void 0` keeps the script's result undefined: executeJavaScript converts the result back to this world,
68
+ // and converting the Client object (which reaches window and the whole DOM) hangs the page before it loads.
69
+ const code = `(${boot.runtime})().default(${JSON.stringify(boot.args)});\nvoid 0;\n//# sourceURL=yapin-client://${encodeURIComponent(boot.args.manifest.id)}/runtime.js`;
68
70
  webFrame.executeJavaScript(code).catch((error) => console.error('[yapin-client] the client app failed to start', error));
69
71
  })();
@@ -110,7 +110,8 @@ module.exports = function setup({ payloadDir }) {
110
110
  os: `${os.type()} ${os.release()} (${process.arch})`,
111
111
  platform: process.platform,
112
112
  };
113
- log('info', `starting ${manifest.name} ${manifest.version}${safeMode ? ' in safe mode' : ''}`);
113
+ log('info', `starting ${manifest.name} ${manifest.version}${safeMode ? ' in safe mode' : ''} (pid ${process.pid}, args ${JSON.stringify(process.argv.slice(1))})`);
114
+ app.on('will-quit', () => log('info', `quitting (pid ${process.pid})`));
114
115
 
115
116
  /** Only the main frame of Yapin’s own pages may talk to us. */
116
117
  const trusted = (event) => {
@@ -179,16 +180,30 @@ module.exports = function setup({ payloadDir }) {
179
180
  return true;
180
181
  };
181
182
 
182
- handleSync('boot', () => ({
183
- runtime,
184
- args: {
185
- manifest,
186
- nativeKey,
187
- safeMode,
188
- environment,
189
- snapshot: { storage: readStorage(), userPlugins: readAllUser('plugins'), userThemes: readAllUser('themes') },
190
- },
191
- }));
183
+ ipcMain.on(`${PREFIX}boot`, (event) => {
184
+ const url = event.senderFrame ? event.senderFrame.url : '(no frame)';
185
+ if (!trusted(event)) {
186
+ event.returnValue = null;
187
+ return;
188
+ }
189
+ const started = Date.now();
190
+ try {
191
+ event.returnValue = {
192
+ runtime,
193
+ args: {
194
+ manifest,
195
+ nativeKey,
196
+ safeMode,
197
+ environment,
198
+ snapshot: { storage: readStorage(), userPlugins: readAllUser('plugins'), userThemes: readAllUser('themes') },
199
+ },
200
+ };
201
+ log('info', `boot served to ${url} in ${Date.now() - started} ms`);
202
+ } catch (error) {
203
+ log('error', 'boot', error);
204
+ event.returnValue = null;
205
+ }
206
+ });
192
207
  handleSync('storage-write-sync', (_event, ns, json) => writeStorage(ns, json));
193
208
  handle('storage-write', (_event, ns, json) => writeStorage(ns, json));
194
209
  handle('list', (_event, kind) => (KINDS.has(kind) ? listFiles(kind) : []));
@@ -390,6 +405,39 @@ module.exports = function setup({ payloadDir }) {
390
405
  /** Crash-loop protection: two renderer crashes soon after loading mean the next start is in safe mode. */
391
406
  app.on('web-contents-created', (_event, contents) => {
392
407
  let loadedAt = 0;
408
+ let navigatedAt = 0;
409
+ const ours = (url) => {
410
+ try {
411
+ return origins.has(new URL(url).origin);
412
+ } catch {
413
+ return false;
414
+ }
415
+ };
416
+ // Enough to tell from main.log alone why a page never finished loading.
417
+ contents.on('did-start-navigation', (details) => {
418
+ if (!details.isMainFrame || details.isSameDocument || !ours(details.url)) return;
419
+ navigatedAt = Date.now();
420
+ log('info', `loading ${details.url}`);
421
+ });
422
+ contents.on('dom-ready', () => {
423
+ if (navigatedAt && ours(contents.getURL())) log('info', `dom-ready after ${Date.now() - navigatedAt} ms`);
424
+ });
425
+ contents.on('did-fail-load', (_e, code, description, url, isMainFrame) => {
426
+ if (isMainFrame && code !== -3 && ours(url)) log('warn', `load failed: ${description} (${code}) ${url}`);
427
+ });
428
+ contents.on('preload-error', (_e, file, error) => log('error', `preload ${file} failed`, error));
429
+ let consoleErrors = 0;
430
+ contents.on('console-message', (event, level, message, line, sourceId) => {
431
+ const text = event && event.message !== undefined ? event.message : message;
432
+ const severity = event && event.level !== undefined ? event.level : level;
433
+ if ((severity !== 'error' && severity !== 3) || consoleErrors >= 30 || !ours(contents.getURL())) return;
434
+ consoleErrors++;
435
+ const source = (event && event.sourceId) || sourceId || '';
436
+ log('warn', `page error: ${text}${source ? ` (${source}:${(event && event.lineNumber) || line || 0})` : ''}`);
437
+ });
438
+ contents.on('unresponsive', () => {
439
+ if (ours(contents.getURL())) log('warn', 'page unresponsive');
440
+ });
393
441
  contents.on('did-finish-load', () => {
394
442
  loadedAt = Date.now();
395
443
  setTimeout(() => {
@@ -400,6 +448,7 @@ module.exports = function setup({ payloadDir }) {
400
448
  }, 60_000);
401
449
  });
402
450
  contents.on('render-process-gone', (_e, details) => {
451
+ if (ours(contents.getURL())) log('warn', `page process gone: ${details.reason} (exit code ${details.exitCode})`);
403
452
  if (details.reason === 'clean-exit' || details.reason === 'killed') return;
404
453
  if (Date.now() - loadedAt < 60_000) {
405
454
  state.crashes = (state.crashes || 0) + 1;
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { runCli, style } from './cli.js';
6
+ import { runNsis } from './nsis.js';
6
7
  import { createOperations } from './operations.js';
7
8
  import { openBrowser, startServer } from './server.js';
8
9
 
@@ -52,6 +53,7 @@ async function main() {
52
53
  path: { type: 'string', multiple: true },
53
54
  index: { type: 'string' },
54
55
  help: { type: 'boolean', short: 'h' },
56
+ nsis: { type: 'string' },
55
57
  },
56
58
  });
57
59
  if (values.help) {
@@ -63,6 +65,9 @@ async function main() {
63
65
  const command = positionals[0];
64
66
  const options = { yes: values.yes, close: values.close, purge: values.purge, index: values.index, launch: values['no-launch'] ? false : undefined };
65
67
 
68
+ // The Windows setup wizard (NSIS) asks with --nsis <file> and reads the answer from that file.
69
+ if (values.nsis) return runNsis({ operations, command, options, file: values.nsis });
70
+
66
71
  // The Windows setup .exe passes --gui: it has no terminal, but the page doesn't need one.
67
72
  if (values.cli || command || installer.gui === false || (!process.stdout.isTTY && !values.gui)) {
68
73
  return runCli({ installer, operations, command, options });
@@ -0,0 +1,75 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ /**
5
+ * The installer's side of the Windows setup wizard (an NSIS installer, see src/build/nsis.js). NSIS runs
6
+ * installer.mjs with `--nsis <file>` and reads the answer back from that file with ReadINIStr:
7
+ *
8
+ * status --nsis status.ini what's installed where
9
+ * install --nsis result.ini --index 1 ... also repair and uninstall; prints each step for the log
10
+ *
11
+ * The INI is UTF-16 with a byte order mark, the form Windows' INI functions read as Unicode. Lines for
12
+ * the log are plain ASCII, because nsExec only recognises UTF-16 output.
13
+ */
14
+
15
+ /** { section: { key: value } } as an INI file. Values are kept on one line. */
16
+ export function iniFile(sections) {
17
+ const lines = [];
18
+ for (const [name, values] of Object.entries(sections)) {
19
+ lines.push(`[${name}]`);
20
+ for (const [key, value] of Object.entries(values)) {
21
+ const text = value === true ? '1' : value === false ? '0' : String(value ?? '');
22
+ lines.push(`${key}=${text.replace(/[\r\n]+/g, ' ').trim()}`);
23
+ }
24
+ lines.push('');
25
+ }
26
+ return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(lines.join('\r\n'), 'utf16le')]);
27
+ }
28
+
29
+ /** The closest ASCII to `text`, for nsExec's log. */
30
+ export function asciiLine(text) {
31
+ return String(text)
32
+ .replace(/[‘’]/g, "'")
33
+ .replace(/[“”]/g, '"')
34
+ .replace(/…/g, '...')
35
+ .replace(/[–—]/g, '-')
36
+ .normalize('NFKD')
37
+ .replace(/[̀-ͯ]/g, '')
38
+ .replace(/[^\x20-\x7e]/g, '?');
39
+ }
40
+
41
+ export function statusSections(status) {
42
+ const sections = {
43
+ status: { count: status.installs.length, running: status.running === null ? '' : status.running, hasData: status.hasData },
44
+ };
45
+ status.installs.forEach((inst, i) => {
46
+ sections[`install${i}`] = {
47
+ location: inst.location,
48
+ appVersion: inst.appVersion ?? '',
49
+ state: inst.state,
50
+ ours: Boolean(inst.installed?.ours),
51
+ installedName: inst.installed?.name ?? '',
52
+ installedVersion: inst.installed?.version ?? '',
53
+ canInstall: inst.canInstall,
54
+ blocked: inst.blocked ?? '',
55
+ executable: inst.executable ?? '',
56
+ folder: inst.executable ? dirname(inst.executable) : '',
57
+ };
58
+ });
59
+ return sections;
60
+ }
61
+
62
+ export async function runNsis({ operations, command, options = {}, file, log = (line) => console.log(line) }) {
63
+ if (command === 'status') {
64
+ writeFileSync(file, iniFile(statusSections(await operations.status())));
65
+ return 0;
66
+ }
67
+ if (!['install', 'repair', 'uninstall'].includes(command)) {
68
+ writeFileSync(file, iniFile({ result: { ok: false, error: `Unknown command "${command}"` } }));
69
+ return 1;
70
+ }
71
+ const index = options.index === undefined ? 0 : Number(options.index) - 1;
72
+ const result = await operations[command]({ index, close: options.close === true, purge: options.purge === true, launch: false, onStep: (step) => log(asciiLine(step)) });
73
+ writeFileSync(file, iniFile({ result: { ok: result.ok, error: result.error ?? '', hint: result.hint ?? '', previous: result.result?.previous ?? '' } }));
74
+ return result.ok ? 0 : 1;
75
+ }
@@ -42,16 +42,21 @@ export function createOperations({ config, payload, platform = process.platform,
42
42
  return found;
43
43
  };
44
44
 
45
- const ensureClosed = async (allowClose) => {
45
+ const ensureClosed = async (allowClose, onStep) => {
46
46
  if (!processName || !(await isRunning(processName, platform))) return;
47
47
  if (!allowClose) throw new InstallError(`${target.productName} is running`, { code: 'RUNNING', hint: `Close ${target.productName} (check the system tray too), or let the installer close it.` });
48
+ onStep(`Closing ${target.productName}`);
48
49
  if (!(await closeApp(processName, { platform }))) throw new InstallError(`Couldn't close ${target.productName}`, { code: 'RUNNING', hint: 'Close it yourself, then try again.' });
49
50
  };
50
51
 
51
- const run = async (fn) => {
52
+ /** Runs one action, collecting its steps (and passing each to `onStep` as it happens). */
53
+ const run = async (fn, onStep = () => {}) => {
52
54
  const steps = [];
53
55
  try {
54
- const result = await fn((step) => steps.push(step));
56
+ const result = await fn((step) => {
57
+ steps.push(step);
58
+ onStep(step);
59
+ });
55
60
  return { ok: true, steps, result };
56
61
  } catch (error) {
57
62
  return { ok: false, steps, error: error.message, code: error.code ?? 'FAILED', hint: error.hint ?? null };
@@ -72,34 +77,34 @@ export function createOperations({ config, payload, platform = process.platform,
72
77
  };
73
78
  },
74
79
 
75
- install({ index, close = false, launch = false } = {}) {
76
- return run(async (onStep) => {
80
+ install({ index, close = false, launch = false, onStep } = {}) {
81
+ return run(async (step) => {
77
82
  const found = pick(index);
78
- await ensureClosed(close);
79
- const result = install({ resources: found.resources, payload, target, onStep });
83
+ await ensureClosed(close, step);
84
+ const result = install({ resources: found.resources, payload, target, onStep: step });
80
85
  if (launch && found.executable) launchApp(found.executable, { platform });
81
86
  return result;
82
- });
87
+ }, onStep);
83
88
  },
84
89
 
85
- repair({ index, close = false, launch = false } = {}) {
86
- return run(async (onStep) => {
90
+ repair({ index, close = false, launch = false, onStep } = {}) {
91
+ return run(async (step) => {
87
92
  const found = pick(index);
88
- await ensureClosed(close);
89
- const result = repair({ resources: found.resources, payload, target, id: manifest.id, onStep });
93
+ await ensureClosed(close, step);
94
+ const result = repair({ resources: found.resources, payload, target, id: manifest.id, onStep: step });
90
95
  if (launch && found.executable) launchApp(found.executable, { platform });
91
96
  return result;
92
- });
97
+ }, onStep);
93
98
  },
94
99
 
95
- uninstall({ index, purge = false, close = false, launch = false } = {}) {
96
- return run(async (onStep) => {
100
+ uninstall({ index, purge = false, close = false, launch = false, onStep } = {}) {
101
+ return run(async (step) => {
97
102
  const found = pick(index);
98
- await ensureClosed(close);
99
- const result = uninstall({ resources: found.resources, target, id: manifest.id, purge, onStep });
103
+ await ensureClosed(close, step);
104
+ const result = uninstall({ resources: found.resources, target, id: manifest.id, purge, onStep: step });
100
105
  if (launch && found.executable) launchApp(found.executable, { platform });
101
106
  return result;
102
- });
107
+ }, onStep);
103
108
  },
104
109
 
105
110
  launch({ index } = {}) {
@@ -27,6 +27,35 @@ export const defaultStrings = Object.freeze({
27
27
  needsRepair: 'The desktop app updated itself, so {name} needs to be put back.',
28
28
  otherInstalled: '{other} is installed here. Uninstall it first.',
29
29
  connectionLost: 'The installer has stopped. Close this tab and run it again.',
30
+ // The Windows setup wizard
31
+ setupWelcomeTitle: 'Welcome to {name} Setup',
32
+ setupWelcomeText: 'Setup will install {name} {version} into the Yapin’ desktop app.',
33
+ setupContinue: 'Click Next to continue.',
34
+ setupChooseTitle: 'Choose an Option',
35
+ setupChooseSubtitle: 'Choose what Setup should do with {name}.',
36
+ setupInstall: 'Install {name} {version}',
37
+ setupUpdate: 'Update to {name} {version}',
38
+ setupReinstall: 'Reinstall {name} {version}',
39
+ setupRepair: 'Repair {name}',
40
+ setupUninstall: 'Uninstall {name}',
41
+ setupNotInstalled: 'Not installed. Click Install to continue.',
42
+ setupInstalledState: 'Installed:',
43
+ setupNeedsRepairState: 'Needs repair',
44
+ setupBlockedState: 'Can’t install here',
45
+ setupInstallingTitle: 'Installing',
46
+ setupInstallingText: 'Please wait while {name} is being installed.',
47
+ setupRepairingTitle: 'Repairing',
48
+ setupRepairingText: 'Please wait while {name} is being repaired.',
49
+ setupUninstallingTitle: 'Uninstalling',
50
+ setupUninstallingText: 'Please wait while {name} is being removed.',
51
+ setupDoneTitle: 'Completed',
52
+ setupDoneText: 'Setup finished.',
53
+ setupFailedTitle: 'Setup Failed',
54
+ setupFailedText: 'Nothing was changed. The details are below.',
55
+ setupFailed: 'The installer stopped with an error.',
56
+ setupStillRunning: '{product} is still open. Close it (check the system tray too), then click Next again.',
57
+ setupFinishTitle: 'Completing {name} Setup',
58
+ setupFinishText: 'Click Finish to close Setup.',
30
59
  });
31
60
 
32
61
  export function escapeHTML(text) {
@@ -6,36 +6,91 @@ function run(command, args) {
6
6
  });
7
7
  }
8
8
 
9
- /** Whether a process with this executable name is running. null if that can't be told. */
10
- export async function isRunning(processName, platform = process.platform) {
11
- if (!processName) return null;
9
+ /** One row of `tasklist /FO CSV`: comma-separated, double-quoted fields. */
10
+ function parseCsvRow(line) {
11
+ return [...line.matchAll(/"((?:[^"]|"")*)"/g)].map((m) => m[1].replace(/""/g, '"'));
12
+ }
13
+
14
+ /**
15
+ * PIDs of every running process with this executable name, as numbers, excluding `excludePid` (the
16
+ * installer's own process, when it's running as the target app's own Node — see closeApp). null if
17
+ * that can't be told.
18
+ */
19
+ async function matchingPids(processName, platform, excludePid) {
20
+ if (!processName) return [];
12
21
  if (platform === 'win32') {
13
22
  const out = await run('tasklist', ['/FI', `IMAGENAME eq ${processName}`, '/FO', 'CSV', '/NH']);
14
23
  if (out === null) return null;
15
- return out.toLowerCase().includes(`"${processName.toLowerCase()}"`);
16
- }
17
- const out = await run('pgrep', ['-x', processName]);
18
- if (out === null) {
19
- const ps = await run('ps', ['-A', '-o', 'comm=']);
20
- if (ps === null) return null;
21
- return ps.split('\n').some((line) => line.trim().split('/').pop() === processName);
24
+ return out
25
+ .split(/\r?\n/)
26
+ .filter(Boolean)
27
+ .map(parseCsvRow)
28
+ .filter(([name]) => name?.toLowerCase() === processName.toLowerCase())
29
+ .map(([, pid]) => Number(pid))
30
+ .filter((pid) => Number.isInteger(pid) && pid !== excludePid);
22
31
  }
23
- return out.trim().length > 0;
32
+ const pgrep = await run('pgrep', ['-x', processName]);
33
+ const pids =
34
+ pgrep !== null
35
+ ? pgrep
36
+ .split('\n')
37
+ .map((s) => Number(s.trim()))
38
+ .filter(Number.isInteger)
39
+ : await (async () => {
40
+ const ps = await run('ps', ['-A', '-o', 'pid=,comm=']);
41
+ if (ps === null) return null;
42
+ return ps
43
+ .split('\n')
44
+ .map((line) => line.trim())
45
+ .filter(Boolean)
46
+ .map((line) => {
47
+ const match = /^(\d+)\s+(.*)$/.exec(line);
48
+ return match ? [Number(match[1]), match[2].split('/').pop()] : null;
49
+ })
50
+ .filter((row) => row && row[1] === processName)
51
+ .map(([pid]) => pid);
52
+ })();
53
+ return pids === null ? null : pids.filter((pid) => pid !== excludePid);
54
+ }
55
+
56
+ /**
57
+ * Whether a process with this executable name is running, other than `excludePid` (default: this
58
+ * process). That exclusion matters when the installer itself runs as the target app's own runtime
59
+ * (ELECTRON_RUN_AS_NODE), which makes the installer's own process share the app's executable name.
60
+ * null if that can't be told.
61
+ */
62
+ export async function isRunning(processName, platform = process.platform, { excludePid = process.pid } = {}) {
63
+ const pids = await matchingPids(processName, platform, excludePid);
64
+ return pids === null ? null : pids.length > 0;
24
65
  }
25
66
 
26
- /** Closes every process with this executable name. Asks nicely first, then insists after `wait` ms. */
27
- export async function closeApp(processName, { platform = process.platform, wait = 5000 } = {}) {
28
- if (platform === 'win32') await run('taskkill', ['/IM', processName]);
29
- else await run('pkill', ['-x', processName]);
67
+ /**
68
+ * Closes every process with this executable name except `excludePid` (default: this process). Asks
69
+ * nicely first (by PID, never a blanket kill-by-name), then insists after `wait` ms.
70
+ */
71
+ export async function closeApp(processName, { platform = process.platform, wait = 5000, excludePid = process.pid } = {}) {
72
+ const killEach = async (force) => {
73
+ const pids = await matchingPids(processName, platform, excludePid);
74
+ for (const pid of pids ?? []) {
75
+ if (platform === 'win32') await run('taskkill', force ? ['/F', '/PID', String(pid)] : ['/PID', String(pid)]);
76
+ else {
77
+ try {
78
+ process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
79
+ } catch {
80
+ // already gone
81
+ }
82
+ }
83
+ }
84
+ };
85
+ await killEach(false);
30
86
  const deadline = Date.now() + wait;
31
87
  while (Date.now() < deadline) {
32
- if (!(await isRunning(processName, platform))) return true;
88
+ if (!(await isRunning(processName, platform, { excludePid }))) return true;
33
89
  await new Promise((r) => setTimeout(r, 250));
34
90
  }
35
- if (platform === 'win32') await run('taskkill', ['/F', '/IM', processName]);
36
- else await run('pkill', ['-9', '-x', processName]);
91
+ await killEach(true);
37
92
  await new Promise((r) => setTimeout(r, 500));
38
- return !(await isRunning(processName, platform));
93
+ return !(await isRunning(processName, platform, { excludePid }));
39
94
  }
40
95
 
41
96
  /** Starts the app, detached from the installer. */