From 8df41a26b0a457e2d677300b270d6ecf84ea5bc5 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 23 Oct 2026 22:42:12 +0800 Subject: [PATCH] desktop open: a hidden tray copy that is already running counts once its window is visible (Windows), graceful stop wait 3 s on Windows --- CHANGELOG.md | 1 + cli/desktop.ts | 21 ++++++++++++--------- cli/lib/desktop-proc.ts | 22 ++++++++++++++++++---- skills/store-pitfalls/SKILL.md | 7 ++++--- tests/desktop-proc.test.ts | 24 +++++++++++++++++++++++- 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d65e5e..6a0a01a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased - `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `///.png` (feature: `/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`. +- `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID. ## 0.8.47 ยท 2026-10-23 diff --git a/cli/desktop.ts b/cli/desktop.ts index f318e6f..ad1343f 100644 --- a/cli/desktop.ts +++ b/cli/desktop.ts @@ -22,7 +22,7 @@ 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, 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 { appExe, graceMs, listCommand, logDir, matchByPath, mismatchMessage, notOpenedMessage, openState, parseProcs, peVersion, type Proc, stopCommand, versionMatches } from './lib/desktop-proc'; import { verify } from './lib/minisign'; import { parsePlist } from './lib/plist'; import { addFolder } from './lib/registry'; @@ -175,24 +175,25 @@ function appProcs(platform: NodeJS.Platform, exe: string): Proc[] { } /** 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 { +async function stopApp(platform: NodeJS.Platform, exe: string, say: (s: string) => void, grace = graceMs(platform)): 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 end = Date.now() + grace; 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'> { +/** Waits until the app is open for the user (see openState); `handedOver()` tells whether the launched process already + * exited normally (a running copy took the request). */ +async function waitOpened(platform: NodeJS.Platform, exe: string, handedOver: () => boolean, 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'; + const st = openState(platform, appProcs(platform, exe), handedOver()); + if (st === 'ok') return 'ok'; + if (st === 'hidden') last = 'hidden'; } return last; } @@ -310,7 +311,9 @@ export async function run(args: Args): Promise { 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); + let exitCode: number | null = null; + void p.exited.then(c => { exitCode = c; }, () => {}); + const opened = await waitOpened(platform, exe, () => exitCode === 0); 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'))); diff --git a/cli/lib/desktop-proc.ts b/cli/lib/desktop-proc.ts index fbbcc4f..3291bad 100644 --- a/cli/lib/desktop-proc.ts +++ b/cli/lib/desktop-proc.ts @@ -5,7 +5,7 @@ import { posix } from 'node:path'; import { compareVersions } from './desktop-install'; -export interface Proc { pid: number; exe: string; args: string } +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 { @@ -16,7 +16,7 @@ export function appExe(platform: NodeJS.Platform, installedPath: string): string 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"]; + "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=']; } @@ -28,8 +28,8 @@ export function parseProcs(platform: NodeJS.Platform, out: string): Proc[] { 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 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')) { @@ -51,6 +51,20 @@ export function matchByPath(platform: NodeJS.Platform, procs: Proc[], exe: strin /** 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)]; diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index a22d44a..19f7785 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1358,9 +1358,10 @@ that fails without the fix (see CONTRIBUTING.md). 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, + install path: a graceful close, then by PID after 3 s on Windows / 10 s elsewhere; closing a tray app only hides it). 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`). + with what was found. Opening waits up to 15 s: on Windows for a visible top-level window of a process + from the installed path (a running `--hidden` tray copy is shown by the single-instance handler, so a second start + without `--hidden` opens it), elsewhere for a non-`--hidden` process or a handoff (`tests/desktop-proc.test.ts`). diff --git a/tests/desktop-proc.test.ts b/tests/desktop-proc.test.ts index 348edbd..c1a859b 100644 --- a/tests/desktop-proc.test.ts +++ b/tests/desktop-proc.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { appExe, listCommand, logDir, matchByPath, mismatchMessage, notOpenedMessage, parseProcs, peVersion, startedHidden, stopCommand, versionMatches } from '../cli/lib/desktop-proc'; +import { appExe, graceMs, openState, 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 { @@ -82,3 +82,25 @@ describe('desktop open: the log the message points to', () => { expect(notOpenedMessage('hidden', 'x.exe', 'L')).toContain('hidden (tray)'); }); }); + +describe('desktop open: a hidden tray copy that is already running', () => { + const exe = "D:/app/storebound-desktop.exe"; + const hidden = { pid: 1, exe, args: `"${exe}" --hidden` }; + test('Windows: the visible window decides, read from the CIM/Get-Process JSON', () => { + const rows = parseProcs('win32', JSON.stringify([{ ProcessId: 1, ExecutablePath: exe, CommandLine: `"${exe}" --hidden`, Window: false }, { ProcessId: 2, ExecutablePath: exe, CommandLine: `"${exe}"`, Window: true }])); + expect(rows.map(r => r.window)).toEqual([false, true]); + expect(openState('win32', [rows[0]!], true)).toBe('hidden'); + expect(openState('win32', rows, false)).toBe('ok'); + expect(openState('win32', [], true)).toBe('none'); + expect(listCommand('win32').join(' ')).toContain('MainWindowHandle'); + }); + test('elsewhere: a visible start, or a hidden copy the new start handed over to', () => { + expect(openState('linux', [hidden], false)).toBe('hidden'); + expect(openState('linux', [hidden], true)).toBe('ok'); + expect(openState('darwin', [{ pid: 3, exe: '', args: '/x/storebound-desktop' }], false)).toBe('ok'); + expect(openState('darwin', [], true)).toBe('none'); + }); + test('a tray app only hides on close: short graceful wait on Windows', () => { + expect(graceMs('win32')).toBeLessThan(graceMs('linux')); + }); +});