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:
parent
d4cf1ad3a1
commit
8df41a26b0
5 changed files with 58 additions and 17 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue