desktop open: a hidden tray copy that is already running counts once its window is visible (Windows), graceful stop wait 3 s on Windows

This commit is contained in:
CRTkafa 2026-10-23 22:42:12 +08:00
parent d4cf1ad3a1
commit 8df41a26b0
5 changed files with 58 additions and 17 deletions

View file

@ -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 `<frames>/<lang>/<size>/<name>.png` (feature: `<lang>/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

View file

@ -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<void> {
async function stopApp(platform: NodeJS.Platform, exe: string, say: (s: string) => void, grace = graceMs(platform)): 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 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<void> {
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')));

View file

@ -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)];

View file

@ -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`).

View file

@ -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'));
});
});