// 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, 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}`; }