diff --git a/App/shell/desktop/src/main/main.ts b/App/shell/desktop/src/main/main.ts index 77b1fab23..d009eccf3 100644 --- a/App/shell/desktop/src/main/main.ts +++ b/App/shell/desktop/src/main/main.ts @@ -85,6 +85,8 @@ let isReplayingMainWindowAction = false; let isQuitting = false; let isQuitCleanupInProgress = false; let isQuitCleanupComplete = false; +let shouldRelaunchAfterQuitCleanup = false; +let lastMacosSecondInstanceAt = 0; let quitCleanupForceExitTimer: ReturnType | null = null; let areIpcHandlersRegistered = false; let isBootReady = false; @@ -118,10 +120,9 @@ const WINDOWS_UPDATE_INSTALL_PROCESS_POLL_MS = 250; const WINDOWS_PREPARED_UPDATE_RELAUNCH_DELAY_MS = 500; const APP_QUIT_CLEANUP_FORCE_EXIT_DELAY_MS = 5000; const APP_QUIT_ANALYTICS_GRACE_MS = 150; -const SINGLE_INSTANCE_LOCK_RETRY_INTERVAL_MS = 500; -const SINGLE_INSTANCE_LOCK_WAIT_DEADLINE_MS = 10000; -const SECOND_INSTANCE_ACTIVATE_DEBOUNCE_MS = 3000; -const MACOS_STALE_REOPEN_QUIT_GRACE_MS = 20000; +const MACOS_REOPEN_SECOND_INSTANCE_GRACE_MS = 2000; +const MACOS_MICROPHONE_RELAUNCH_MARKER_FILE = "macos-microphone-relaunch.json"; +const MACOS_MICROPHONE_RELAUNCH_MARKER_TTL_MS = 5 * 60 * 1000; const MAIN_WINDOW_ROUTE_TARGET_CHANNEL = "memmy:route-target-request"; const UPDATE_DOWNLOAD_PROGRESS_CHANNEL = "memmy:update-download-progress"; const PREPARED_REQUIRED_UPDATE_FILE = "prepared-required-update.json"; @@ -2909,7 +2910,14 @@ async function requestMicrophoneAccess(): Promise { try { const granted = await systemPreferences.askForMediaAccess("microphone"); - return granted ? "granted" : getMicrophoneAccessStatus(); + if (granted) { + clearMacosMicrophoneRelaunchMarker(); + return "granted"; + } + // Remember that the app just entered the macOS microphone permission flow, so a later + // quit/reopen can be recognized even if the new process races ahead of the old one. + markMacosMicrophoneRelaunchRequested(); + return getMicrophoneAccessStatus(); } catch { return "unsupported"; } @@ -4331,100 +4339,39 @@ async function sendAppExitEventBeforeQuit(): Promise { await Promise.race([exitEvent, delay(APP_QUIT_ANALYTICS_GRACE_MS)]); } -let hasSingleInstanceLock = app.requestSingleInstanceLock(); -let lastSecondInstanceActivateAt = 0; -let didWaitForSingleInstanceLock = false; -let hasIgnoredStaleReopenQuit = false; -let shouldRelaunchAfterQuitCleanup = false; -const appProcessStartedAt = Date.now(); - -/** - * Waits for the single-instance lock while a previous instance finishes exiting. - * - * macOS "Quit & Reopen" (shown after privacy toggles such as the microphone permission) starts the - * replacement instance while the old process is still running its quit cleanup and therefore still - * holds the lock. Instead of giving up immediately — which turns "Quit & Reopen" into a plain quit — - * retry until the old instance releases the lock or the deadline passes. The deadline comfortably - * exceeds the quit-cleanup force-exit delay, so a genuinely running healthy instance is the only - * case that still reaches the timeout. - * - * @returns Whether this instance owns the single-instance lock. - */ -async function waitForSingleInstanceLock(): Promise { - const deadline = Date.now() + SINGLE_INSTANCE_LOCK_WAIT_DEADLINE_MS; - while (!hasSingleInstanceLock && Date.now() < deadline) { - didWaitForSingleInstanceLock = true; - await delay(SINGLE_INSTANCE_LOCK_RETRY_INTERVAL_MS); - hasSingleInstanceLock = app.requestSingleInstanceLock(); - } - return hasSingleInstanceLock; -} - -/** - * Detects the stale quit request macOS delivers to a freshly reopened instance. - * - * The "Quit & Reopen" flow quits the old instance and reopens the app, but when the old instance - * shuts down slowly the reopen races ahead and the quit request is delivered to the replacement - * instance instead — the reopened window flashes briefly and the app is gone. Having waited for the - * single-instance lock is the fingerprint of that reopen race (a normal launch acquires the lock on - * the first try), so shortly after such a launch the first quit request is treated as stale and - * ignored once. Every later quit request behaves normally. - * - * @returns Whether the current quit request should be ignored as stale. - */ -function shouldIgnoreStaleReopenQuit(): boolean { - return process.platform === "darwin" - && didWaitForSingleInstanceLock - && !hasIgnoredStaleReopenQuit - && Date.now() - appProcessStartedAt <= MACOS_STALE_REOPEN_QUIT_GRACE_MS; -} - -app.on("second-instance", () => { - if (isQuitting || isQuitCleanupInProgress) { - // The other instance is a replacement waiting for this instance's lock; let it take over. - return; - } +const hasSingleInstanceLock = app.requestSingleInstanceLock(); - // A waiting replacement instance retries the lock every few hundred milliseconds and each retry - // fires second-instance; debounce so a healthy primary does not keep re-stealing focus. - const now = Date.now(); - if (now - lastSecondInstanceActivateAt < SECOND_INSTANCE_ACTIVATE_DEBOUNCE_MS) { - return; - } - lastSecondInstanceActivateAt = now; +if (!hasSingleInstanceLock) { + // An instance is already running: this instance exits directly, to avoid a second instance + // contending for the fixed ports (memory 18799 / agent-gateway 18997) and causing a startup failure. + app.quit(); +} else { + app.on("second-instance", () => { + if (process.platform === "darwin") { + lastMacosSecondInstanceAt = Date.now(); + } - if (isBootReady) { - activateMainWindow(); - } -}); + if (isQuitting || isQuitCleanupInProgress) { + // macOS System Settings can quit and reopen the app while the old process still owns + // the single-instance lock. Relaunch from the old process after cleanup releases it. + shouldRelaunchAfterQuitCleanup = true; + return; + } -app.whenReady().then(async () => { - if (!(await waitForSingleInstanceLock())) { - // An instance is already running: this instance exits directly, to avoid a second instance - // contending for the fixed ports (memory 18799 / agent-gateway 18997) and causing a startup failure. + if (isBootReady) { + activateMainWindow(); + } + }); + app.whenReady().then(boot).catch(async (error: unknown) => { + console.error(error); + closeSplashWindow(); // Close the splash even on boot failure, so it does not stay stuck on screen + await writePackagedStartupLog(`boot:error\n${formatStartupError(error)}`); + showPackagedStartupError(error); app.quit(); - return; - } - - await boot(); -}).catch(async (error: unknown) => { - console.error(error); - closeSplashWindow(); // Close the splash even on boot failure, so it does not stay stuck on screen - await writePackagedStartupLog(`boot:error\n${formatStartupError(error)}`); - showPackagedStartupError(error); - app.quit(); -}); + }); +} app.on("activate", () => { - if (isQuitting || isQuitCleanupInProgress) { - // macOS "Quit & Reopen" delivers the reopen as an activate event to this still-dying instance - // when the quit cleanup is slow, so no replacement process is ever spawned. Honor the reopen - // by relaunching once cleanup finishes instead of letting the app end up fully closed. - shouldRelaunchAfterQuitCleanup = true; - void writePackagedStartupLog("quit:reopen-requested-during-quit"); - return; - } - if (isBootReady) { if (consumePetWindowCloseActivateSuppression()) { return; @@ -4441,12 +4388,6 @@ app.on("window-all-closed", () => { }); app.on("before-quit", (event) => { - if (shouldIgnoreStaleReopenQuit()) { - hasIgnoredStaleReopenQuit = true; - event.preventDefault(); - void writePackagedStartupLog("quit:ignored-stale-reopen-quit"); - return; - } if (!hasSingleInstanceLock) { return; } @@ -4456,13 +4397,17 @@ app.on("before-quit", (event) => { event.preventDefault(); isQuitting = true; + if (shouldTreatMacosMicrophonePermissionReopen()) { + // macOS privacy toggles can start the replacement instance just before the old instance enters + // before-quit. Preserve that intent so cleanup does not turn "Quit & Reopen" into "Quit". + shouldRelaunchAfterQuitCleanup = true; + } hideAppShellForQuit(); if (isQuitCleanupInProgress) { return; } isQuitCleanupInProgress = true; - void writePackagedStartupLog("quit:cleanup-start"); armQuitCleanupForceExitTimer(); void cleanupBeforeQuit() .catch(async (error: unknown) => { @@ -4478,21 +4423,6 @@ app.on("before-quit", (event) => { }); }); -/** - * Relaunches the app after quit cleanup when a reopen request arrived mid-quit. - * - * @returns Nothing. - */ -function relaunchAfterQuitCleanupIfRequested(): void { - if (!shouldRelaunchAfterQuitCleanup) { - return; - } - - shouldRelaunchAfterQuitCleanup = false; - void writePackagedStartupLog("quit:relaunching-after-cleanup"); - app.relaunch(); -} - function armQuitCleanupForceExitTimer(): void { clearQuitCleanupForceExitTimer(); quitCleanupForceExitTimer = setTimeout(() => { @@ -4506,6 +4436,100 @@ function armQuitCleanupForceExitTimer(): void { quitCleanupForceExitTimer.unref?.(); } +function relaunchAfterQuitCleanupIfRequested(): void { + if (!shouldRelaunchAfterQuitCleanup) { + return; + } + + shouldRelaunchAfterQuitCleanup = false; + app.relaunch(); +} + +function shouldTreatRecentMacosSecondInstanceAsReopen(): boolean { + if (process.platform !== "darwin" || lastMacosSecondInstanceAt <= 0) { + return false; + } + + return Date.now() - lastMacosSecondInstanceAt <= MACOS_REOPEN_SECOND_INSTANCE_GRACE_MS; +} + +function shouldTreatMacosMicrophonePermissionReopen(): boolean { + const requestedAt = readMacosMicrophoneRelaunchRequestedAt(); + if (!requestedAt) { + return false; + } + + if (Date.now() - requestedAt.getTime() > MACOS_MICROPHONE_RELAUNCH_MARKER_TTL_MS) { + clearMacosMicrophoneRelaunchMarker(); + return false; + } + + const hasRecentReopenAttempt = shouldTreatRecentMacosSecondInstanceAsReopen(); + const microphoneAccessStatus = getMicrophoneAccessStatus(); + if (microphoneAccessStatus === "granted") { + clearMacosMicrophoneRelaunchMarker(); + return hasRecentReopenAttempt; + } + + return false; +} + +function markMacosMicrophoneRelaunchRequested(): void { + if (process.platform !== "darwin") { + return; + } + + try { + writeFileSync(resolveMacosMicrophoneRelaunchMarkerPath(), JSON.stringify({ + requestedAt: new Date().toISOString() + }), "utf8"); + } catch (error) { + console.warn("macOS microphone relaunch marker write failed:", error); + } +} + +function readMacosMicrophoneRelaunchRequestedAt(): Date | null { + if (process.platform !== "darwin") { + return null; + } + + try { + const raw = readFileSync(resolveMacosMicrophoneRelaunchMarkerPath(), "utf8"); + const parsed = JSON.parse(raw) as { requestedAt?: unknown }; + if (typeof parsed.requestedAt !== "string" || !parsed.requestedAt.trim()) { + clearMacosMicrophoneRelaunchMarker(); + return null; + } + + const requestedAt = new Date(parsed.requestedAt); + if (Number.isNaN(requestedAt.getTime())) { + clearMacosMicrophoneRelaunchMarker(); + return null; + } + + return requestedAt; + } catch { + clearMacosMicrophoneRelaunchMarker(); + return null; + } +} + +function clearMacosMicrophoneRelaunchMarker(): void { + if (process.platform !== "darwin") { + return; + } + + try { + unlinkSync(resolveMacosMicrophoneRelaunchMarkerPath()); + } catch { + // Best-effort cleanup only. + } +} + +function resolveMacosMicrophoneRelaunchMarkerPath(): string { + return join(app.getPath("userData"), MACOS_MICROPHONE_RELAUNCH_MARKER_FILE); +} + function hideAppShellForQuit(): void { for (const targetWindow of BrowserWindow.getAllWindows()) { if (!targetWindow.isDestroyed()) { diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index 20dd1a633..9e5c3f5a2 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -719,7 +719,6 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain("write_desktop_edition_manifest"); expect(source).toContain('"signing": "$package_signing"'); expect(source).toContain("npm run build -w @memmy/memory"); - expect(source).toContain("npm install --workspace @memmy/frontend-desktop --no-package-lock"); expect(source).toContain('npm ci --prefix "$AGENT_DIR"'); expect(source).not.toContain('npm install --prefix "$AGENT_DIR"'); expect(source).not.toContain('if [ ! -x "$AGENT_DIR/node_modules/.bin/tsc" ]'); diff --git a/App/shell/desktop/tests/window-mode.test.ts b/App/shell/desktop/tests/window-mode.test.ts index 91336889e..f6a5d2887 100644 --- a/App/shell/desktop/tests/window-mode.test.ts +++ b/App/shell/desktop/tests/window-mode.test.ts @@ -171,56 +171,48 @@ describe("desktop pet window mode", () => { expect(source).toContain("if (consumePetWindowCloseActivateSuppression()) {\n return;\n }\n\n activateMainWindow();"); }); - it("retries the single-instance lock so macOS quit-and-reopen survives slow old-instance exit", () => { + it("relaunches after quit cleanup when macOS reopens during or just before old instance exit", () => { const source = readFileSync(mainSourcePath, "utf8"); - const secondInstanceIndex = source.indexOf('app.on("second-instance"'); - const secondInstanceBlock = source.slice(secondInstanceIndex, source.indexOf("app.whenReady()", secondInstanceIndex)); - const whenReadyIndex = source.indexOf("app.whenReady()", secondInstanceIndex); - const whenReadyBlock = source.slice(whenReadyIndex, source.indexOf('app.on("activate"', whenReadyIndex)); + const singleInstanceIndex = source.indexOf("const hasSingleInstanceLock = app.requestSingleInstanceLock();"); + const singleInstanceBlock = source.slice(singleInstanceIndex, source.indexOf('app.on("activate"', singleInstanceIndex)); const beforeQuitIndex = source.indexOf('app.on("before-quit"'); const beforeQuitBlock = source.slice(beforeQuitIndex, source.indexOf("function armQuitCleanupForceExitTimer(): void", beforeQuitIndex)); + const secondInstanceIndex = source.indexOf('app.on("second-instance"'); + const secondInstanceBlock = source.slice(secondInstanceIndex, source.indexOf("app.whenReady()", secondInstanceIndex)); + const forceExitBlock = source.slice( + source.indexOf("function armQuitCleanupForceExitTimer(): void"), + source.indexOf("function hideAppShellForQuit(): void") + ); - // The replacement instance started by macOS "Quit & Reopen" retries the lock until the old - // instance finishes cleanup, instead of quitting immediately or relaunching heuristically. - expect(source).toContain("let hasSingleInstanceLock = app.requestSingleInstanceLock();"); - expect(source).toContain("const SINGLE_INSTANCE_LOCK_RETRY_INTERVAL_MS = 500;"); - expect(source).toContain("const SINGLE_INSTANCE_LOCK_WAIT_DEADLINE_MS = 10000;"); - expect(source).toContain("const SECOND_INSTANCE_ACTIVATE_DEBOUNCE_MS = 3000;"); - expect(source).toContain("async function waitForSingleInstanceLock(): Promise"); - expect(source).toContain("hasSingleInstanceLock = app.requestSingleInstanceLock();"); - expect(source).toContain("await delay(SINGLE_INSTANCE_LOCK_RETRY_INTERVAL_MS);"); - expect(whenReadyBlock).toContain("if (!(await waitForSingleInstanceLock()))"); - expect(whenReadyBlock).toContain("app.quit();"); - expect(whenReadyBlock).toContain("await boot();"); - - // While quitting, the old instance stays out of the way and lets the replacement take over; - // a healthy primary debounces the activations caused by the replacement's lock retries. + expect(source).toContain("const hasSingleInstanceLock = app.requestSingleInstanceLock();"); + expect(singleInstanceBlock).toContain("if (!hasSingleInstanceLock)"); + expect(singleInstanceBlock).toContain("app.quit();"); + expect(source).toContain("let shouldRelaunchAfterQuitCleanup = false;"); + expect(source).toContain("let lastMacosSecondInstanceAt = 0;"); + expect(source).toContain("const MACOS_REOPEN_SECOND_INSTANCE_GRACE_MS = 2000;"); + expect(source).toContain('const MACOS_MICROPHONE_RELAUNCH_MARKER_FILE = "macos-microphone-relaunch.json";'); + expect(source).toContain("const MACOS_MICROPHONE_RELAUNCH_MARKER_TTL_MS = 5 * 60 * 1000;"); + expect(secondInstanceBlock).toContain('if (process.platform === "darwin")'); + expect(secondInstanceBlock).toContain("lastMacosSecondInstanceAt = Date.now();"); expect(secondInstanceBlock).toContain("if (isQuitting || isQuitCleanupInProgress)"); - expect(secondInstanceBlock).toContain("SECOND_INSTANCE_ACTIVATE_DEBOUNCE_MS"); - expect(secondInstanceBlock).toContain("activateMainWindow();"); + expect(secondInstanceBlock).toContain("shouldRelaunchAfterQuitCleanup = true;"); + expect(secondInstanceBlock).toContain("return;"); expect(beforeQuitBlock).toContain("if (!hasSingleInstanceLock)"); - - // macOS can deliver the "Quit & Reopen" quit request to the freshly reopened instance when the - // old instance exits slowly; that stale quit is ignored once so the reopened window survives. - expect(source).toContain("const MACOS_STALE_REOPEN_QUIT_GRACE_MS = 20000;"); - expect(source).toContain("function shouldIgnoreStaleReopenQuit(): boolean"); - expect(source).toContain("didWaitForSingleInstanceLock = true;"); - expect(beforeQuitBlock).toContain("if (shouldIgnoreStaleReopenQuit())"); - expect(beforeQuitBlock).toContain("hasIgnoredStaleReopenQuit = true;"); - expect(beforeQuitBlock).toContain("event.preventDefault();"); - - // When the reopen instead reaches the dying instance as an activate event (no replacement - // process is spawned), the dying instance honors it by relaunching after cleanup. - const activateIndex = source.indexOf('app.on("activate"'); - const activateBlock = source.slice(activateIndex, source.indexOf('app.on("window-all-closed"', activateIndex)); - expect(activateBlock).toContain("if (isQuitting || isQuitCleanupInProgress)"); - expect(activateBlock).toContain("shouldRelaunchAfterQuitCleanup = true;"); + expect(beforeQuitBlock).toContain("if (shouldTreatMacosMicrophonePermissionReopen())"); + expect(source).toContain("function shouldTreatRecentMacosSecondInstanceAsReopen(): boolean"); + expect(source).toContain("function shouldTreatMacosMicrophonePermissionReopen(): boolean"); + expect(source).toContain("function markMacosMicrophoneRelaunchRequested(): void"); + expect(source).toContain("function readMacosMicrophoneRelaunchRequestedAt(): Date | null"); + expect(source).toContain("function clearMacosMicrophoneRelaunchMarker(): void"); + expect(source).toContain("function resolveMacosMicrophoneRelaunchMarkerPath(): string"); + expect(source).toContain("markMacosMicrophoneRelaunchRequested();"); + expect(source).toContain('if (granted) {\n clearMacosMicrophoneRelaunchMarker();\n return "granted";\n }'); + expect(source).toContain("const hasRecentReopenAttempt = shouldTreatRecentMacosSecondInstanceAsReopen();"); + expect(source).toContain('if (microphoneAccessStatus === "granted") {\n clearMacosMicrophoneRelaunchMarker();\n return hasRecentReopenAttempt;\n }'); expect(source).toContain("function relaunchAfterQuitCleanupIfRequested(): void"); expect(source).toContain("relaunchAfterQuitCleanupIfRequested();\n app.quit();"); - expect(source).toContain("relaunchAfterQuitCleanupIfRequested();\n app.exit(0);"); - - // The reopen intent comes only from concrete signals (activate during quit): no marker files. - expect(source).not.toContain("macos-microphone-relaunch"); + expect(forceExitBlock).toContain("runtimeServices?.terminateSync();\n relaunchAfterQuitCleanupIfRequested();\n app.exit(0);"); + expect(source).toContain("app.relaunch();"); }); it("restores an existing full window without reloading the renderer", () => {