diff --git a/CHANGELOG.md b/CHANGELOG.md index 7625693..00625dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased +- `storebound desktop`: an update no longer reports "installed" while the old app stays. It stops the running app first (exact install path only, graceful close, then by PID), checks the installed exe's own version (Windows PE version resource instead of the registry entry; Info.plist on macOS) and fails with what it found, and prints "opened" only when a visible (not `--hidden`) process started from the installed path, otherwise it names the app's log. - `play products` / `play subscriptions`: regions outside Play's min / max (a 649.99 USD product in KR: "must be between ₩70 and ₩600,000") are flagged in the dry run (old -> new) and clamped to the bound on write with a warning; `--skip-out-of-range-regions` leaves them out. Limits Play reports in a 400 are learned (`.storebound/play-price-limits.json`) and the write is retried. A product or subscription that fails no longer stops the others: per-product results, exit 1 at the end. - `apple iap` / `apple subscriptions`: edits to approved (ACTIVE) localizations, which App Store Connect refuses with 409 UNMODIFIABLE, are marked "not editable now" in the dry run and skipped with a warning on write; prices and the other changes still apply. One IAP failing no longer stops the rest (results at the end, exit 1). diff --git a/cli/desktop.ts b/cli/desktop.ts index 5426361..f318e6f 100644 --- a/cli/desktop.ts +++ b/cli/desktop.ts @@ -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; 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 { 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 { + 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 { @@ -233,12 +268,6 @@ export async function run(args: Args): Promise { 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 { 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 { 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'); } diff --git a/cli/lib/desktop-proc.ts b/cli/lib/desktop-proc.ts new file mode 100644 index 0000000..fbbcc4f --- /dev/null +++ b/cli/lib/desktop-proc.ts @@ -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, 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}`; +} diff --git a/docs/desktop.md b/docs/desktop.md index 9de0347..5e96ff4 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -490,6 +490,12 @@ The CLI and the app are one product: one version number, one set of store accoun starts silently in the tray, without a window. Closing the window keeps it running in the tray; the tray icon opens it, the tray menu's Quit (macOS: Cmd+Q) quits. Turn the logon entry off in the OS (Windows: Settings > Apps > Startup). Window state never restores "visible", so a `--hidden` start stays hidden. +- **`storebound desktop` install / update**: before the installer runs it stops the running app (only processes + started from the exact install path: `%LOCALAPPDATA%\storebound\storebound-desktop.exe`, + `storebound.app/Contents/MacOS/storebound-desktop`, the AppImage; a graceful close, then by PID after 10 s). After the + install it reads the real version of the installed files (Windows: the exe's PE version resource, never the + registry entry; macOS: Info.plist; Linux: `--version`) and fails on a mismatch. "opened" is printed only when a + process without `--hidden` runs from the installed path within 15 s; otherwise it says so and names `host.log`. - **`storebound://open?path=`** (sent by `storebound desktop` in an app folder, which first puts the folder on the machine's list): the host accepts one plain absolute path (`policy::deep_link_open_path`), syncs the list, selects that app folder when it is on it, otherwise asks in a native dialog and adds it diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index bef6ea6..a22d44a 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1350,3 +1350,17 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `cli/lib/loc-lock.ts` reads the localization `state` (IAP, subscription and group localizations) and treats a 409 UNMODIFIABLE the same way; one IAP failing no longer stops the others (results at the end, exit 1). `tests/price-limits-locked-loc.test.ts` (fake ASC answers the 409 for APPROVED / ACTIVE localizations). + +## 113. `storebound desktop` said "installed" and "opened", but the old app stayed and no window came + +- **What happens:** on Windows the silent installer ran while storebound desktop was still running (often hidden in + the tray from the logon entry). The exe was locked, so the files stayed at the old version, while the uninstall + registry entry already showed the new one. The version check started the GUI exe with `--version`, read nothing + and went on; "opened" was printed without looking whether a window process started. +- **Do:** let `storebound desktop` stop the running app first (it does now, only processes started from the exact + install path: a graceful close, then by PID after 10 s). If it still says the installed app is the old version, + close storebound desktop in the tray and run it again. If it says it did not open, read the `host.log` it names. +- **Guard:** `cli/lib/desktop-proc.ts`: the exact-path process match, the PE version resource of the installed exe + (never the registry `DisplayVersion`), Info.plist on macOS, the AppImage's `--version` on Linux; a mismatch fails + with what was found. Opening waits up to 15 s for a non-`--hidden` process from the installed path + (`tests/desktop-proc.test.ts`). diff --git a/tests/desktop-proc.test.ts b/tests/desktop-proc.test.ts new file mode 100644 index 0000000..348edbd --- /dev/null +++ b/tests/desktop-proc.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test'; +import { appExe, listCommand, logDir, matchByPath, mismatchMessage, notOpenedMessage, parseProcs, peVersion, startedHidden, stopCommand, versionMatches } from '../cli/lib/desktop-proc'; + +/** A tiny fake PE: "MZ", padding, then a VS_FIXEDFILEINFO with the given product version. */ +function fakePe(a: number, b: number, c: number, d = 0, at = 128): Uint8Array { + const buf = new Uint8Array(at + 52); + const v = new DataView(buf.buffer); + v.setUint16(0, 0x5a4d, true); + v.setUint32(at, 0xfeef04bd, true); + v.setUint32(at + 4, 0x00010000, true); + v.setUint32(at + 8, (9 << 16) | 9, true); // file version differs on purpose + v.setUint32(at + 16, (a << 16) | b, true); + v.setUint32(at + 20, (c << 16) | d, true); + return buf; +} + +describe('desktop install: real version of the installed files', () => { + test('PE product version from VS_FIXEDFILEINFO', () => { + expect(peVersion(fakePe(0, 8, 46))).toBe('0.8.46'); + expect(peVersion(fakePe(2, 0, 7, 5))?.split(".")).toEqual(["2", "0", "7", "5"]); + expect(peVersion(new Uint8Array(200))).toBeNull(); // no MZ + const noInfo = new Uint8Array(200); noInfo[0] = 0x4d; noInfo[1] = 0x5a; + expect(peVersion(noInfo)).toBeNull(); + }); + test('the stale 0.8.38 exe is a mismatch for 0.8.46', () => { + expect(versionMatches('0.8.38', '0.8.46', true)).toBe(false); + expect(versionMatches('0.8.46', '0.8.46', true)).toBe(true); + expect(versionMatches('0.8.46', '0.8.46-beta.1', true)).toBe(true); + expect(versionMatches('0.8.46', '0.8.46')).toBe(true); + expect(versionMatches('0.8.45', '0.8.46')).toBe(false); + const m = mismatchMessage('0.8.38', '0.8.46', 'C:\\Users\\u\\AppData\\Local\\storebound\\storebound-desktop.exe'); + expect(m).toContain('is 0.8.38, expected 0.8.46'); + expect(m).toContain('Close storebound desktop'); + expect(mismatchMessage(null, '0.8.46', 'x')).toContain('unreadable'); + }); +}); + +describe('desktop install: the running app by exact path', () => { + const exe = 'C:\\Users\\u\\AppData\\Local\\storebound\\storebound-desktop.exe'; + test('Windows: CIM JSON, one row or many, case-insensitive exact path only', () => { + expect(listCommand('win32')[0]).toBe('powershell'); + const one = parseProcs('win32', JSON.stringify({ ProcessId: 42, ExecutablePath: exe.toUpperCase(), CommandLine: `"${exe}" --hidden` })); + expect(matchByPath('win32', one, exe).map(p => p.pid)).toEqual([42]); + expect(startedHidden(one[0]!)).toBe(true); + const many = parseProcs('win32', JSON.stringify([ + { ProcessId: 1, ExecutablePath: 'D:\\other\\storebound-desktop.exe', CommandLine: '' }, + { ProcessId: 2, ExecutablePath: exe, CommandLine: `"${exe}"` }, + { ProcessId: 3, ExecutablePath: null, CommandLine: null }, + ])); + expect(matchByPath('win32', many, exe).map(p => p.pid)).toEqual([2]); + expect(startedHidden(many[1]!)).toBe(false); + expect(parseProcs('win32', '')).toEqual([]); + expect(parseProcs('win32', 'not json')).toEqual([]); + }); + test('macOS / Linux: ps args, the bundle binary, no prefix lookalikes', () => { + const mac = appExe('darwin', '/Applications/storebound.app'); + expect(mac).toBe('/Applications/storebound.app/Contents/MacOS/storebound-desktop'); + const procs = parseProcs('darwin', [ + ` 101 ${mac}`, + ` 102 ${mac} --hidden`, + ` 103 ${mac}-helper`, + ` 104 /tmp/h/Applications/storebound.app/Contents/MacOS/storebound-desktop`, + '', + ].join('\n')); + expect(matchByPath('darwin', procs, mac).map(p => [p.pid, startedHidden(p)])).toEqual([[101, false], [102, true]]); + expect(appExe('linux', '/tmp/h/.local/bin/storebound.AppImage')).toBe('/tmp/h/.local/bin/storebound.AppImage'); + }); + test('stop: graceful first, then forced, by PID', () => { + expect(stopCommand('win32', 42, false)).toEqual(['taskkill', '/PID', '42']); + expect(stopCommand('win32', 42, true)).toEqual(['taskkill', '/F', '/PID', '42']); + expect(stopCommand('darwin', 7, false)).toEqual(['kill', '-TERM', '7']); + expect(stopCommand('linux', 7, true)).toEqual(['kill', '-KILL', '7']); + }); +}); + +describe('desktop open: the log the message points to', () => { + test('per OS', () => { + expect(logDir('win32', { APPDATA: 'C:\\Users\\u\\AppData\\Roaming' }, 'C:\\Users\\u')).toBe('C:\\Users\\u\\AppData\\Roaming\\dev.storebound.desktop\\logs'); + expect(logDir('darwin', {}, '/tmp/h')).toBe('/tmp/h/Library/Application Support/dev.storebound.desktop/logs'); + expect(logDir('linux', {}, '/tmp/h')).toBe('/tmp/h/.local/share/dev.storebound.desktop/logs'); + expect(notOpenedMessage('none', 'x.exe', 'L')).toContain('did not open: no process'); + expect(notOpenedMessage('hidden', 'x.exe', 'L')).toContain('hidden (tray)'); + }); +});