112 lines
6.7 KiB
TypeScript
112 lines
6.7 KiB
TypeScript
// 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; /** Windows only: the process has a visible top-level window. */ window?: boolean }
|
|
|
|
/** 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'\" | ForEach-Object { $g = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue; [pscustomobject]@{ ProcessId = $_.ProcessId; ExecutablePath = $_.ExecutablePath; CommandLine = $_.CommandLine; Window = [bool]($g -and $g.MainWindowHandle -ne 0) } } | 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; Window?: boolean }[];
|
|
return rows.filter(r => typeof r?.ProcessId === 'number').map(r => ({ pid: r.ProcessId!, exe: r.ExecutablePath ?? '', args: r.CommandLine ?? '', window: r.Window === true }));
|
|
}
|
|
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);
|
|
|
|
/** Is the app open for the user? Windows: a process of ours has a visible window (a running tray copy started with
|
|
* --hidden counts once the single-instance handoff showed its window). Elsewhere a window cannot be seen from here:
|
|
* a process started without --hidden, or a hidden copy that the new start handed over to (`handedOver`: the new
|
|
* process exited normally), counts. 'none' = no process, 'hidden' = only tray copies and no visible window. */
|
|
export function openState(platform: NodeJS.Platform, procs: Proc[], handedOver: boolean): 'ok' | 'none' | 'hidden' {
|
|
if (!procs.length) return 'none';
|
|
if (platform === 'win32') return procs.some(p => p.window) ? 'ok' : 'hidden';
|
|
return procs.some(p => !startedHidden(p)) || handedOver ? 'ok' : 'hidden';
|
|
}
|
|
|
|
/** Seconds to wait for a graceful quit before the forced stop: closing the window of a tray app only hides it, so on
|
|
* Windows the graceful step is short. */
|
|
export const graceMs = (platform: NodeJS.Platform): number => (platform === 'win32' ? 3_000 : 10_000);
|
|
|
|
/** 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}`;
|
|
}
|