From 05f5771660ea7ecc8d5591bc65d60f3882da0b92 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:25:14 +0300 Subject: [PATCH] fix(windows): say what to do when the Store tray app cannot be spawned A Store-installed desktop app puts the tray exe under WindowsApps, and launching it by path from a console reports EPERM. The install then died with "Menubar install failed: spawn EPERM" even though nothing about the install had failed. The launch hook now answers for its own spawn: the Store route points at the Start menu entry the package does allow, the msi routes report the launch error without unwinding the install's own outcome, and the --quit signal logs a spawn failure without aborting, beside its taskkill fallback. Fixes #1520. --- src/menubar-installer.ts | 73 ++++++++++++++++++++++--- tests/menubar-installer-windows.test.ts | 27 +++++++++ 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index 0e924eff1..43b5f8b3e 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -813,7 +813,12 @@ export type WindowsInstallHooks = { killTray?: () => Promise /// The wait between two `isTrayRunning` polls, so tests need not spend the real one. sleep?: (ms: number) => Promise - launch?: (exePath: string, args?: string[]) => void + /// Start the installed tray app. Returns an error message when the process could not be + /// spawned at all (the EPERM a console-locked-down app-container binary reports, a missing + /// file, a rejected execution), or null when the process started; an empty string counts + /// as null. A started process is not a promise it stays up: the tray is detached, so this + /// answers only the spawn. + launch?: (exePath: string, args?: string[]) => Promise | string | null | void log?: (message: string) => void stagingDir?: string env?: NodeJS.ProcessEnv @@ -1310,10 +1315,32 @@ async function runMsiexec(exe: string, args: string[]): Promise { }) } -function launchWindowsApp(exePath: string, args: string[] = []): void { - const proc = spawn(exePath, args, { detached: true, stdio: 'ignore' }) - proc.on('error', err => console.error(`Could not launch ${exePath}: ${err.message}`)) - proc.unref() +function launchWindowsApp(exePath: string, args: string[] = []): Promise { + return new Promise((resolve) => { + const proc = spawn(exePath, args, { detached: true, stdio: 'ignore' }) + proc.on('error', err => resolve(`Could not launch ${exePath}: ${err.message}`)) + proc.on('spawn', () => { + proc.unref() + resolve(null) + }) + }) +} + +/// The tray app is a second process that leaves; only its spawn is awaitable. A spawn failure +/// (the EPERM a direct CreateProcess against a console-locked-down app-container binary +/// reports, #1520) answers with the error message, and the route that asked owns its meaning: +/// the Store route refuses the conflicting .msi, routes that just installed an .msi say so. +async function awaitLaunch( + launch: (exePath: string, args?: string[]) => Promise | string | null | void, + exePath: string, + args?: string[], +): Promise { + try { + const outcome = await launch(exePath, args) + return typeof outcome === 'string' && outcome.length > 0 ? outcome : null + } catch (err) { + return err instanceof Error ? err.message : String(err) + } } async function trayHasProcess(env: NodeJS.ProcessEnv): Promise { @@ -1359,7 +1386,11 @@ async function stopRunningMenubar( if (exePath) { log('CodeBurn Menubar is running; asking it to quit before installing...') - launch(exePath, ['--quit']) + // The quit signal is best effort: the polls below answer whether the tray heard it, and a + // spawn failure (a Store-packaged tray an outside console cannot reach, #1520) is logged, + // not fatal - the install still has taskkill as its fallback. + const quitError = await awaitLaunch(launch, exePath, ['--quit']) + if (quitError) log(quitError) // Counted polls rather than a wall clock, so the wait is the same length however long the // supplied sleep actually takes. for (let attempt = 0; attempt < Math.ceil(TRAY_QUIT_TIMEOUT_MS / TRAY_QUIT_POLL_MS); attempt++) { @@ -1435,7 +1466,17 @@ async function installWindowsMenubarApp(options: InstallOptions): Promise { expect(logs.some(line => line.includes('--force') && line.includes('leaves the Store copy in place'))).toBe(true) }) + it('tells the user to start the store copy from the start menu when the spawn fails', async () => { + const promise = installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ launch: async () => 'Could not launch tray: spawn EPERM' }), + }) + + await expect(promise).rejects.toThrow(/Start CodeBurn Menubar from the Start menu instead/) + expect(installerCalls).toEqual([]) + }) + + it('says the install itself succeeded when the launch after an msi install fails', async () => { + let queries = 0 + const promise = installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryStorePackage: async () => '', + queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20), + launch: async () => 'Could not launch tray: spawn EPERM', + }), + }) + + await expect(promise).rejects.toThrow(/install itself succeeded/) + expect(installerCalls).toHaveLength(1) + }) + it('takes the msi route when the store package is not installed', async () => { const result = await installMenubarApp({ platform: 'win32',