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.
- package/README.md +1 -1
- package/docs/README.md +1 -0
- package/docs/about-page.md +19 -2
- package/docs/configuration.md +36 -2
- package/docs/getting-started.md +1 -1
- package/docs/installer.md +39 -12
- package/docs/ui.md +20 -3
- package/docs/welcome.md +63 -0
- package/package.json +1 -1
- package/src/build/build.js +34 -22
- package/src/build/nsis.js +763 -0
- package/src/config/defaults.js +30 -3
- package/src/config/load.js +9 -1
- package/src/desktop/preload.cjs +3 -1
- package/src/desktop/shim.cjs +60 -11
- package/src/installer/main.js +5 -0
- package/src/installer/nsis.js +75 -0
- package/src/installer/operations.js +23 -18
- package/src/installer/page.js +29 -0
- package/src/installer/processes.js +74 -19
- package/src/runtime/about.js +126 -36
- package/src/runtime/builtin-pages.js +2 -2
- package/src/runtime/client.js +21 -1
- package/src/runtime/onboarding.js +196 -0
- package/src/runtime/ui.js +52 -9
- package/src/shared/constants.js +1 -1
- package/src/shared/icons.js +52 -0
- package/types/index.d.ts +38 -1
- package/types/runtime.d.ts +26 -2
package/src/config/defaults.js
CHANGED
|
@@ -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
|
});
|
package/src/config/load.js
CHANGED
|
@@ -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"');
|
package/src/desktop/preload.cjs
CHANGED
|
@@ -64,6 +64,8 @@ const PREFIX = 'yapin-client:';
|
|
|
64
64
|
},
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
|
|
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
|
})();
|
package/src/desktop/shim.cjs
CHANGED
|
@@ -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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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;
|
package/src/installer/main.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 } = {}) {
|
package/src/installer/page.js
CHANGED
|
@@ -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
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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. */
|