desktop install: stop the running app by exact path, check the installed exe's real version, verify it opened
This commit is contained in:
parent
a01c78aa20
commit
8f69a27709
6 changed files with 249 additions and 9 deletions
|
|
@ -21,7 +21,8 @@ import type { Args } from './lib/args';
|
|||
import { CONFIG_NAMES } from './lib/config';
|
||||
import { DECLINED_EXIT } from './lib/ctx';
|
||||
import { BUNDLE_ID, DOWNLOAD_LABEL, type Download, type DownloadKey, downloadsDir, downloadsFor, findInstalled, type Installed, MANIFEST, type Manifest, manifestProblems, openCommand, openFolderLink, PAGE, PUBLIC_KEY, versionManifest } from './lib/desktop-dist';
|
||||
import { consent, type Decision, decide, installPlan, isRunning, type Plan, question, runningCheck } from './lib/desktop-install';
|
||||
import { consent, type Decision, decide, installPlan, type Plan, question } from './lib/desktop-install';
|
||||
import { appExe, listCommand, logDir, matchByPath, mismatchMessage, notOpenedMessage, parseProcs, peVersion, type Proc, startedHidden, stopCommand, versionMatches } from './lib/desktop-proc';
|
||||
import { verify } from './lib/minisign';
|
||||
import { parsePlist } from './lib/plist';
|
||||
import { addFolder } from './lib/registry';
|
||||
|
|
@ -105,9 +106,11 @@ async function readBody(r: Response, d: Download, stallMs: number, say: (s: stri
|
|||
|
||||
const VERSION_RE = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/;
|
||||
|
||||
/** The version of an installed app: Info.plist on macOS, `storebound-desktop --version` elsewhere (15 s at most). */
|
||||
/** The version of an installed app, read from its own files: Info.plist on macOS, the PE version resource of the exe
|
||||
* on Windows (never the uninstall registry entry, never by starting the GUI exe), `--version` on Linux (15 s at most). */
|
||||
export function appVersion(platform: NodeJS.Platform, app: string): string | null {
|
||||
try {
|
||||
if (platform === 'win32') return peVersion(readFileSync(app));
|
||||
if (platform === 'darwin') {
|
||||
const p = parsePlist(readFileSync(join(app, 'Contents', 'Info.plist'))) as Record<string, unknown>;
|
||||
return typeof p.CFBundleShortVersionString === 'string' ? p.CFBundleShortVersionString : null;
|
||||
|
|
@ -139,7 +142,8 @@ export function runPlan(plan: Plan, platform: NodeJS.Platform, say: (s: string)
|
|||
else if ('write' in s) { writeFileSync(s.write.path, s.write.content); if (s.write.mode) chmodSync(s.write.path, s.write.mode); }
|
||||
else if ('expectVersion' in s) {
|
||||
const v = appVersion(platform, s.expectVersion.app);
|
||||
if (v && v !== s.expectVersion.version) throw new Error(`the installed app is ${v}, expected ${s.expectVersion.version}`);
|
||||
const win = platform === 'win32';
|
||||
if ((v && !versionMatches(v, s.expectVersion.version, win)) || (!v && win)) throw new Error(mismatchMessage(v, s.expectVersion.version, s.expectVersion.app));
|
||||
if (!v) say(` (could not read the new app's version; going on)`);
|
||||
}
|
||||
if (!always) say(` ${s.what}`);
|
||||
|
|
@ -162,6 +166,37 @@ async function askYes(text: string): Promise<boolean> {
|
|||
return line !== null && !/^n(o)?$/i.test(line.trim());
|
||||
}
|
||||
|
||||
/** Processes of the installed app, matched by its exact executable path; [] when they cannot be listed. */
|
||||
function appProcs(platform: NodeJS.Platform, exe: string): Proc[] {
|
||||
try {
|
||||
const r = rt.spawnSync(listCommand(platform), { timeout: 15_000 });
|
||||
return r.success ? matchByPath(platform, parseProcs(platform, r.stdout.toString()), exe) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Stops the running app (only processes started from `exe`): a graceful close, then by PID after `graceMs`. */
|
||||
async function stopApp(platform: NodeJS.Platform, exe: string, say: (s: string) => void, graceMs = 10_000): Promise<void> {
|
||||
let procs = appProcs(platform, exe);
|
||||
if (!procs.length) return;
|
||||
say(` stopping the running storebound desktop (PID ${procs.map(p => p.pid).join(', ')})`);
|
||||
for (const p of procs) try { rt.spawnSync(stopCommand(platform, p.pid, false), { timeout: 10_000 }); } catch { /* forced below */ }
|
||||
for (const end = Date.now() + graceMs; Date.now() < end && procs.length; ) { await rt.sleep(500); procs = appProcs(platform, exe); }
|
||||
for (const p of procs) try { rt.spawnSync(stopCommand(platform, p.pid, true), { timeout: 10_000 }); } catch { /* checked below */ }
|
||||
for (const end = Date.now() + 5_000; Date.now() < end && procs.length; ) { await rt.sleep(500); procs = appProcs(platform, exe); }
|
||||
if (procs.length) throw new Error(`storebound desktop is still running from ${exe} (PID ${procs.map(p => p.pid).join(', ')}): close it (also in the tray), then run this again`);
|
||||
}
|
||||
|
||||
/** Waits until a visible (not --hidden) process runs from `exe`. */
|
||||
async function waitOpened(platform: NodeJS.Platform, exe: string, timeoutMs = 15_000): Promise<'ok' | 'none' | 'hidden'> {
|
||||
let last: 'none' | 'hidden' = 'none';
|
||||
for (const end = Date.now() + timeoutMs; Date.now() < end; await rt.sleep(500)) {
|
||||
const procs = appProcs(platform, exe);
|
||||
if (procs.some(p => !startedHidden(p))) return 'ok';
|
||||
if (procs.length) last = 'hidden';
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
const tilde = (p: string) => (p.startsWith(homedir()) ? '~' + p.slice(homedir().length) : p);
|
||||
|
||||
export async function run(args: Args): Promise<void> {
|
||||
|
|
@ -233,12 +268,6 @@ export async function run(args: Args): Promise<void> {
|
|||
console.log('nothing changed.');
|
||||
return;
|
||||
}
|
||||
if (installed) {
|
||||
const chk = runningCheck(platform);
|
||||
let running = false;
|
||||
try { const r = rt.spawnSync(chk, { timeout: 10_000 }); running = isRunning(platform, r.exitCode, r.stdout.toString()); } catch { /* no pgrep / tasklist: go on */ }
|
||||
if (running) throw new Error(`storebound desktop ${decision.from} is running: quit it first, then run this again (or update it from the app: Settings > Updates)`);
|
||||
}
|
||||
const how = consent(args.has('yes'), stdinIsTTY());
|
||||
if (how !== 'yes') {
|
||||
if (how === 'declined') {
|
||||
|
|
@ -260,6 +289,8 @@ export async function run(args: Args): Promise<void> {
|
|||
console.log(`downloading ${d.file} (${(d.size / 1e6).toFixed(1)} MB)`);
|
||||
await download(d, tmp);
|
||||
console.log(' size, SHA-256 and signature checked');
|
||||
// a running app keeps its files locked (Windows) or keeps running the old code: stop it, our install path only
|
||||
await stopApp(platform, appExe(platform, installed?.path ?? plan!.target), s => console.log(s));
|
||||
runPlan(plan!, platform, s => console.log(s));
|
||||
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
||||
installed = findInstalled(platform) ?? { path: plan!.target, how: platform === 'darwin' ? 'bundle' : platform === 'win32' ? 'exe' : 'appimage' };
|
||||
|
|
@ -278,5 +309,11 @@ export async function run(args: Args): Promise<void> {
|
|||
const code = await Promise.race([p.exited, rt.sleep(30_000).then(() => -1)]);
|
||||
if (code !== 0) throw new Error(`${fresh ? 'open -a' : `open -b ${BUNDLE_ID}`} failed${code === -1 ? ' (no answer in 30 s)' : `: ${(await new Response(p.stderr).text()).trim()}`}`);
|
||||
} else p.unref();
|
||||
const exe = appExe(platform, installed!.path);
|
||||
const opened = await waitOpened(platform, exe);
|
||||
if (opened !== 'ok') {
|
||||
// an already running visible copy counts as opened (single-instance hands it the request)
|
||||
throw new Error(notOpenedMessage(opened, exe, join(logDir(platform, process.env, homedir()), 'host.log')));
|
||||
}
|
||||
console.log(link ? `storebound desktop: opening ${tilde(dir)}` : 'storebound desktop: opened');
|
||||
}
|
||||
|
|
|
|||
98
cli/lib/desktop-proc.ts
Normal file
98
cli/lib/desktop-proc.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// The running storebound desktop app, matched by its exact executable path (our install only, never another
|
||||
// program with a similar name), how to stop it before an install, how to read the real version of the installed
|
||||
// files (the Windows PE version resource, not the uninstall registry entry) and where its log is. Pure parts only;
|
||||
// cli/desktop.ts runs the commands.
|
||||
import { posix } from 'node:path';
|
||||
import { compareVersions } from './desktop-install';
|
||||
|
||||
export interface Proc { pid: number; exe: string; args: string }
|
||||
|
||||
/** The executable a process of the installed app runs from (the .app bundle's binary on macOS). */
|
||||
export function appExe(platform: NodeJS.Platform, installedPath: string): string {
|
||||
return platform === 'darwin' ? posix.join(installedPath, 'Contents', 'MacOS', 'storebound-desktop') : installedPath;
|
||||
}
|
||||
|
||||
/** The command that lists processes with their executable and arguments (never through a shell). */
|
||||
export function listCommand(platform: NodeJS.Platform): string[] {
|
||||
if (platform === 'win32') {
|
||||
return ['powershell', '-NoProfile', '-NonInteractive', '-Command',
|
||||
"Get-CimInstance Win32_Process -Filter \"Name='storebound-desktop.exe'\" | Select-Object ProcessId,ExecutablePath,CommandLine | ConvertTo-Json -Compress"];
|
||||
}
|
||||
return ['ps', '-axww', '-o', 'pid=,args='];
|
||||
}
|
||||
|
||||
/** Parses the output of listCommand. */
|
||||
export function parseProcs(platform: NodeJS.Platform, out: string): Proc[] {
|
||||
if (platform === 'win32') {
|
||||
const t = out.trim();
|
||||
if (!t) return [];
|
||||
let j: unknown;
|
||||
try { j = JSON.parse(t); } catch { return []; }
|
||||
const rows = (Array.isArray(j) ? j : [j]) as { ProcessId?: number; ExecutablePath?: string | null; CommandLine?: string | null }[];
|
||||
return rows.filter(r => typeof r?.ProcessId === 'number').map(r => ({ pid: r.ProcessId!, exe: r.ExecutablePath ?? '', args: r.CommandLine ?? '' }));
|
||||
}
|
||||
const procs: Proc[] = [];
|
||||
for (const line of out.split('\n')) {
|
||||
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
||||
if (m) procs.push({ pid: Number(m[1]), exe: '', args: m[2]! });
|
||||
}
|
||||
return procs;
|
||||
}
|
||||
|
||||
/** Processes started from exactly this executable (Windows: case-insensitive full path; elsewhere the argv[0] path). */
|
||||
export function matchByPath(platform: NodeJS.Platform, procs: Proc[], exe: string): Proc[] {
|
||||
if (platform === 'win32') {
|
||||
const want = exe.replace(/\//g, '\\').toLowerCase();
|
||||
return procs.filter(p => p.exe.replace(/\//g, '\\').toLowerCase() === want);
|
||||
}
|
||||
return procs.filter(p => p.args === exe || p.args.startsWith(exe + ' '));
|
||||
}
|
||||
|
||||
/** Was the process started hidden (the logon entry: tray only, no window)? */
|
||||
export const startedHidden = (p: Proc): boolean => /(?:^|\s|")--hidden(?:\s|"|$)/.test(p.args);
|
||||
|
||||
/** The graceful stop (Windows: WM_CLOSE through taskkill without /F; elsewhere TERM) and the forced one, by PID. */
|
||||
export function stopCommand(platform: NodeJS.Platform, pid: number, force: boolean): string[] {
|
||||
if (platform === 'win32') return force ? ['taskkill', '/F', '/PID', String(pid)] : ['taskkill', '/PID', String(pid)];
|
||||
return ['kill', force ? '-KILL' : '-TERM', String(pid)];
|
||||
}
|
||||
|
||||
/** The product version in a PE file's VS_FIXEDFILEINFO (Tauri writes the app version there), "0.8.46"; null if none. */
|
||||
export function peVersion(buf: Uint8Array): string | null {
|
||||
if (buf.length < 64) return null;
|
||||
const v = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
if (v.getUint16(0, true) !== 0x5a4d) return null; // "MZ"
|
||||
// the signature 0xFEEF04BD (DWORD aligned), then struct version, FileVersionMS/LS, ProductVersionMS/LS
|
||||
for (let i = 0; i + 24 <= buf.length; i += 4) {
|
||||
if (v.getUint32(i, true) !== 0xfeef04bd) continue;
|
||||
const ms = v.getUint32(i + 16, true), ls = v.getUint32(i + 20, true);
|
||||
const parts = [ms >>> 16, ms & 0xffff, ls >>> 16, ls & 0xffff];
|
||||
return parts[3] === 0 ? parts.slice(0, 3).join('.') : parts.join('.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The installed version matches the expected one (a PE version has no pre-release tag: compare a.b.c only). */
|
||||
export function versionMatches(found: string, expected: string, peOnly = false): boolean {
|
||||
if (peOnly) return found === expected.replace(/[-+].*$/, '');
|
||||
return compareVersions(found, expected) === 0;
|
||||
}
|
||||
|
||||
/** The app's log folder (Tauri app data dir of dev.storebound.desktop + /logs, host.log inside). */
|
||||
export function logDir(platform: NodeJS.Platform, env: Record<string, string | undefined>, home: string): string {
|
||||
const id = 'dev.storebound.desktop';
|
||||
if (platform === 'win32') return `${env.APPDATA ?? `${home}\\AppData\\Roaming`}\\${id}\\logs`;
|
||||
if (platform === 'darwin') return posix.join(home, 'Library', 'Application Support', id, 'logs');
|
||||
return posix.join(env.XDG_DATA_HOME ?? posix.join(home, '.local', 'share'), id, 'logs');
|
||||
}
|
||||
|
||||
export function mismatchMessage(found: string | null, expected: string, exe: string): string {
|
||||
return `the installed app at ${exe} is ${found ?? 'unreadable (no version found)'}, expected ${expected}: the files were not replaced, `
|
||||
+ 'most likely because storebound desktop was still running. Close storebound desktop (also in the tray) and run this again';
|
||||
}
|
||||
|
||||
export function notOpenedMessage(why: 'none' | 'hidden', exe: string, logs: string): string {
|
||||
return why === 'hidden'
|
||||
? `storebound desktop did not open a window: only a hidden (tray) copy runs from ${exe}. Click its tray icon, or quit it and run this again. Log: ${logs}`
|
||||
: `storebound desktop did not open: no process started from ${exe} within 15 s. Log: ${logs}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue