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

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