diff --git a/src/main/index.ts b/src/main/index.ts index a0cff875..d9af1eaf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,7 +17,7 @@ ipcMain.on("debug:log", (_, msg: string) => { import { ExtensionManifestSchema } from "../shared/extension-types"; import webSearchPackageJson from "../extensions/mail-ext-web-search/package.json"; import calendarPackageJson from "../extensions/mail-ext-calendar/package.json"; -import { createWindow, getIconPath } from "./window"; +import { createWindow, getIconPath, markAppQuitting, showMainWindow } from "./window"; import { registerGmailIpc } from "./ipc/gmail.ipc"; import { registerAnalysisIpc } from "./ipc/analysis.ipc"; import { registerDraftsIpc } from "./ipc/drafts.ipc"; @@ -346,6 +346,7 @@ function handleMailtoUrl(url: string): void { } // Ensure window is visible if (win.isMinimized()) win.restore(); + if (!win.isVisible()) win.show(); win.focus(); win.webContents.send("mailto:open", parseMailtoUrl(url)); } @@ -367,6 +368,7 @@ app.on("second-instance", (_event, argv) => { if (wins.length > 0) { const win = wins[0]; if (win.isMinimized()) win.restore(); + if (!win.isVisible()) win.show(); win.focus(); } const mailtoArg = argv.find((arg) => arg.toLowerCase().startsWith("mailto:")); @@ -616,11 +618,10 @@ app.whenReady().then(async () => { agentCoordinator.start(mainWindow); app.on("activate", function () { - // On macOS re-create a window when dock icon is clicked and no windows are open - if (BrowserWindow.getAllWindows().length === 0) { - const newWindow = createWindow(); - agentCoordinator.setMainWindow(newWindow); - } + // Reuse a hidden renderer after a normal macOS window close. If the + // renderer was genuinely destroyed, showMainWindow creates a replacement. + const activatedWindow = showMainWindow(); + agentCoordinator.setMainWindow(activatedWindow); }); }); @@ -642,6 +643,7 @@ const walCheckpointInterval = setInterval(() => { // Without this, infrequent writes (e.g. memories) can be stranded in the // WAL file and lost if the file is corrupted or removed during an update. app.on("before-quit", () => { + markAppQuitting(); // Stop all interval-based services before closing the DB — // otherwise their timers fire after the DB is gone and crash. clearInterval(walCheckpointInterval); @@ -653,3 +655,7 @@ app.on("before-quit", () => { closeDatabase(); closeLogs(); }); + +// Covers updater-driven quits that may reach the final quit phase without a +// normal before-quit delivery. The close handler is idempotent once latched. +app.on("will-quit", markAppQuitting); diff --git a/src/main/ipc/sync.ipc.ts b/src/main/ipc/sync.ipc.ts index 068c81c3..6783cd82 100644 --- a/src/main/ipc/sync.ipc.ts +++ b/src/main/ipc/sync.ipc.ts @@ -55,6 +55,9 @@ const activeClients: Map = new Map(); // Track in-progress OAuth flow so it can be cancelled let pendingAddClient: GmailClient | null = null; let retryingConnections = false; +let syncInitInvocationCount = 0; +let syncInitInFlight: Promise> | null = null; +let backgroundProcessingStarted = false; // Email data saved before optimistic trash deletion, keyed by emailId. // Used to restore the email to DB if a queued trash action fails permanently. @@ -679,266 +682,321 @@ export function registerSyncIpc(): void { ); // Initialize accounts on startup - ipcMain.handle("sync:init", async (): Promise> => { - const t0 = performance.now(); - log.info(`[PERF] sync:init START`); + ipcMain.handle("sync:init", (): Promise> => { + syncInitInvocationCount += 1; + const invocation = syncInitInvocationCount; - if (useFakeData) { - // In demo mode, ensure the demo account exists and populate with fake emails - saveAccount("default", "me@example.com", undefined, true); - const { DEMO_INBOX_EMAILS, DEMO_STYLE_SEED_EMAILS } = await import("../demo/fake-inbox"); - for (const email of DEMO_INBOX_EMAILS) { - saveEmail(email, "default"); - } - // Style seed emails are only in the DB (for style profiling), not shown in inbox - for (const email of DEMO_STYLE_SEED_EMAILS) { - saveEmail(email, "default"); - } - log.info( - `[Demo] Saved ${DEMO_INBOX_EMAILS.length + DEMO_STYLE_SEED_EMAILS.length} demo emails to database`, - ); + if (syncInitInFlight) { + log.info(`[PERF] sync:init #${invocation} coalesced with in-flight initialization`); + return syncInitInFlight; + } - // Save demo analysis data for each email so archive-ready checks pass - const { DEMO_EXPECTED_ANALYSIS } = await import("../demo/fake-inbox"); - for (const [emailId, analysis] of Object.entries(DEMO_EXPECTED_ANALYSIS)) { - saveAnalysis(emailId, analysis.needsReply, analysis.reason); - } - - // Seed demo AI drafts so draft-edit learning can be tested - const { DEMO_DRAFT_RESPONSES } = await import("../demo/fake-inbox"); - for (const [emailId, draftBody] of Object.entries(DEMO_DRAFT_RESPONSES)) { - saveDraft(emailId, draftBody, "pending"); - } - log.info(`[Demo] Saved ${Object.keys(DEMO_DRAFT_RESPONSES).length} demo drafts to database`); - - // Save demo archive-ready data so the Archive Ready view has content - const demoArchiveReady = [ - { - threadId: "thread-project-alpha", - reason: - "User confirmed availability and agreed on 7-week timeline - conversation is complete", - }, - { threadId: "thread-github-ci", reason: "Automated CI notification - no response needed" }, - { threadId: "thread-newsletter", reason: "Newsletter subscription - informational only" }, - { threadId: "thread-amazon-ship", reason: "Shipping confirmation - no response needed" }, - { threadId: "thread-calendar", reason: "Calendar notification - no response needed" }, - { threadId: "thread-html-test", reason: "Product update newsletter - informational only" }, - ]; - for (const { threadId, reason } of demoArchiveReady) { - saveArchiveReady(threadId, "default", true, reason); - } - log.info(`[Demo] Saved ${demoArchiveReady.length} demo archive-ready records`); - - // Clear stale snooze data (e.g. from previous e2e test runs sharing this DB) - clearSnoozedEmails("default"); - - // Seed demo snoozed emails so the Snoozed tab has content - const demoSnoozed = [ - { - id: "snooze-demo-1", - emailId: "demo-010", - threadId: "thread-lunch", - snoozeUntil: Date.now() + 4 * 60 * 60 * 1000, - }, - { - id: "snooze-demo-2", - emailId: "demo-meeting", - threadId: "thread-meeting-request", - snoozeUntil: Date.now() + 24 * 60 * 60 * 1000, - }, - ]; - for (const s of demoSnoozed) { - snoozeEmail(s.id, s.emailId, s.threadId, "default", s.snoozeUntil); - } - log.info(`[Demo] Saved ${demoSnoozed.length} demo snoozed records`); - - // Seed correspondent profiles for style testing contacts - saveCorrespondentProfile({ - email: "dalton.caldwell@gmail.com", - accountId: "default", - displayName: "Dalton Caldwell", - emailCount: 10, - avgWordCount: 7, - dominantGreeting: "hey", - dominantSignoff: "none", - formalityScore: 0.12, - lastComputedAt: Date.now(), - }); - saveCorrespondentProfile({ - email: "g.ralston@whitfield-partners.com", - accountId: "default", - displayName: "Dr. Geoff Ralston", - emailCount: 10, - avgWordCount: 120, - dominantGreeting: "dear", - dominantSignoff: "regards", - formalityScore: 0.88, - lastComputedAt: Date.now(), - }); - log.info("[Demo] Saved 2 demo correspondent profiles for style testing"); + const initialization = (async (): Promise> => { + const t0 = performance.now(); + log.info(`[PERF] sync:init #${invocation} START`); - log.info(`[PERF] sync:init END (demo) ${(performance.now() - t0).toFixed(1)}ms`); - return { - success: true, - data: [{ accountId: "default", email: "me@example.com", isConnected: true }], - }; - } + if (useFakeData) { + // In demo mode, ensure the demo account exists and populate with fake emails + saveAccount("default", "me@example.com", undefined, true); + const { DEMO_INBOX_EMAILS, DEMO_STYLE_SEED_EMAILS } = await import("../demo/fake-inbox"); + for (const email of DEMO_INBOX_EMAILS) { + saveEmail(email, "default"); + } + // Style seed emails are only in the DB (for style profiling), not shown in inbox + for (const email of DEMO_STYLE_SEED_EMAILS) { + saveEmail(email, "default"); + } + log.info( + `[Demo] Saved ${DEMO_INBOX_EMAILS.length + DEMO_STYLE_SEED_EMAILS.length} demo emails to database`, + ); - try { - const t1 = performance.now(); - let accounts = getAccounts(); - log.info(`[PERF] sync:init getAccounts took ${(performance.now() - t1).toFixed(1)}ms`); - const connectedAccounts: AccountInfo[] = []; - - // If no accounts in database, try to connect with default account - // This handles the case where user completed OAuth before account saving was implemented - if (accounts.length === 0) { - try { - const client = new GmailClient("default"); - await client.connect(); - - // Get profile and save account - const profile = await client.getProfile(); - const displayName = await client.fetchDisplayName(); - saveAccount("default", profile.emailAddress, displayName ?? undefined, true); - log.info(`[Sync] Migrated existing OAuth to account: ${profile.emailAddress}`); - - // Refresh accounts list - accounts = getAccounts(); - } catch (_err) { - // No valid tokens - user needs to complete setup - log.info("[Sync] No existing OAuth tokens found"); + // Save demo analysis data for each email so archive-ready checks pass + const { DEMO_EXPECTED_ANALYSIS } = await import("../demo/fake-inbox"); + for (const [emailId, analysis] of Object.entries(DEMO_EXPECTED_ANALYSIS)) { + saveAnalysis(emailId, analysis.needsReply, analysis.reason); + } + + // Seed demo AI drafts so draft-edit learning can be tested + const { DEMO_DRAFT_RESPONSES } = await import("../demo/fake-inbox"); + for (const [emailId, draftBody] of Object.entries(DEMO_DRAFT_RESPONSES)) { + saveDraft(emailId, draftBody, "pending"); + } + log.info( + `[Demo] Saved ${Object.keys(DEMO_DRAFT_RESPONSES).length} demo drafts to database`, + ); + + // Save demo archive-ready data so the Archive Ready view has content + const demoArchiveReady = [ + { + threadId: "thread-project-alpha", + reason: + "User confirmed availability and agreed on 7-week timeline - conversation is complete", + }, + { + threadId: "thread-github-ci", + reason: "Automated CI notification - no response needed", + }, + { threadId: "thread-newsletter", reason: "Newsletter subscription - informational only" }, + { threadId: "thread-amazon-ship", reason: "Shipping confirmation - no response needed" }, + { threadId: "thread-calendar", reason: "Calendar notification - no response needed" }, + { + threadId: "thread-html-test", + reason: "Product update newsletter - informational only", + }, + ]; + for (const { threadId, reason } of demoArchiveReady) { + saveArchiveReady(threadId, "default", true, reason); } + log.info(`[Demo] Saved ${demoArchiveReady.length} demo archive-ready records`); + + // Clear stale snooze data (e.g. from previous e2e test runs sharing this DB) + clearSnoozedEmails("default"); + + // Seed demo snoozed emails so the Snoozed tab has content + const demoSnoozed = [ + { + id: "snooze-demo-1", + emailId: "demo-010", + threadId: "thread-lunch", + snoozeUntil: Date.now() + 4 * 60 * 60 * 1000, + }, + { + id: "snooze-demo-2", + emailId: "demo-meeting", + threadId: "thread-meeting-request", + snoozeUntil: Date.now() + 24 * 60 * 60 * 1000, + }, + ]; + for (const s of demoSnoozed) { + snoozeEmail(s.id, s.emailId, s.threadId, "default", s.snoozeUntil); + } + log.info(`[Demo] Saved ${demoSnoozed.length} demo snoozed records`); + + // Seed correspondent profiles for style testing contacts + saveCorrespondentProfile({ + email: "dalton.caldwell@gmail.com", + accountId: "default", + displayName: "Dalton Caldwell", + emailCount: 10, + avgWordCount: 7, + dominantGreeting: "hey", + dominantSignoff: "none", + formalityScore: 0.12, + lastComputedAt: Date.now(), + }); + saveCorrespondentProfile({ + email: "g.ralston@whitfield-partners.com", + accountId: "default", + displayName: "Dr. Geoff Ralston", + emailCount: 10, + avgWordCount: 120, + dominantGreeting: "dear", + dominantSignoff: "regards", + formalityScore: 0.88, + lastComputedAt: Date.now(), + }); + log.info("[Demo] Saved 2 demo correspondent profiles for style testing"); + + log.info( + `[PERF] sync:init #${invocation} END (demo) ${(performance.now() - t0).toFixed(1)}ms`, + ); + return { + success: true, + data: [{ accountId: "default", email: "me@example.com", isConnected: true }], + }; } - log.info(`[Sync] Found ${accounts.length} accounts in database`); - for (const account of accounts) { - const tAccount = performance.now(); - log.info(`[PERF] sync:init connecting account ${account.id} START`); + try { + const t1 = performance.now(); + let accounts = getAccounts(); + log.info(`[PERF] sync:init getAccounts took ${(performance.now() - t1).toFixed(1)}ms`); + const connectedAccounts: AccountInfo[] = []; - // Skip accounts already set up by the onboarding flow — they're - // registered, synced, and have their sync loop running. - if (emailSyncService.isAccountRegistered(account.id)) { - const onboardingClient = getOnboardingClient(account.id); - if (onboardingClient) { - activeClients.set(account.id, onboardingClient); - clearOnboardingClient(account.id); - connectedAccounts.push({ - accountId: account.id, - email: account.email, - isConnected: true, - }); - log.info( - `[Sync] Account ${account.id} already registered (onboarding), reusing client`, - ); - continue; + // If no accounts in database, try to connect with default account + // This handles the case where user completed OAuth before account saving was implemented + if (accounts.length === 0) { + try { + const client = new GmailClient("default"); + await client.connect(); + + // Get profile and save account + const profile = await client.getProfile(); + const displayName = await client.fetchDisplayName(); + saveAccount("default", profile.emailAddress, displayName ?? undefined, true); + log.info(`[Sync] Migrated existing OAuth to account: ${profile.emailAddress}`); + + // Refresh accounts list + accounts = getAccounts(); + } catch (_err) { + // No valid tokens - user needs to complete setup + log.info("[Sync] No existing OAuth tokens found"); } - // Onboarding client not found — fall through to create a new client - log.info( - `[Sync] Account ${account.id} registered but onboarding client missing, creating new client`, - ); } - try { - // Create client for existing account - const client = new GmailClient(account.id); - const tConnect = performance.now(); - await client.connect(); - log.info( - `[PERF] sync:init client.connect took ${(performance.now() - tConnect).toFixed(1)}ms`, - ); - - // Register and start syncing - const tRegister = performance.now(); - const accountInfo = await emailSyncService.registerAccount(client); - log.info( - `[PERF] sync:init registerAccount took ${(performance.now() - tRegister).toFixed(1)}ms`, - ); - activeClients.set(account.id, client); + log.info(`[Sync] Found ${accounts.length} accounts in database`); + for (const account of accounts) { + const tAccount = performance.now(); + log.info(`[PERF] sync:init connecting account ${account.id} START`); + + // A healthy registered account belongs to the long-lived main process, + // not to a particular renderer. Reuse it when a renderer asks to + // initialize again instead of reconnecting and replacing its timer. + if (emailSyncService.isAccountRegistered(account.id)) { + const onboardingClient = getOnboardingClient(account.id); + const registeredAccount = emailSyncService + .getAccounts() + .find((registered) => registered.accountId === account.id); + const registeredClient = + onboardingClient ?? emailSyncService.getClientForAccount(account.id); + + if (registeredAccount?.isConnected && registeredClient) { + activeClients.set(account.id, registeredClient); + if (onboardingClient) clearOnboardingClient(account.id); + // A registered account can outlive its timer when the network + // goes offline or a renderer-triggered stop leaves it idle. + // Reuse the client, but always restore the missing sync loop. + if (!emailSyncService.hasActiveSync(account.id)) { + emailSyncService.startSync(account.id); + } + connectedAccounts.push({ + accountId: account.id, + email: account.email, + displayName: account.displayName, + isConnected: true, + }); + log.info( + `[Sync] Account ${account.id} already registered, reusing main-process client`, + ); + continue; + } - // Backfill display name for existing accounts that don't have one - if (!account.displayName && accountInfo.displayName) { - updateAccountDisplayName(account.id, accountInfo.displayName); - client.clearAccountInfoCache(); + // An errored registration is intentionally replaced so a subsequent + // init can recover after credentials or connectivity change. log.info( - `[Sync] Backfilled display name for ${account.email}: ${accountInfo.displayName}`, + `[Sync] Account ${account.id} registered but disconnected, reconnecting client`, ); } - const tStartSync = performance.now(); - emailSyncService.startSync(account.id); - log.info( - `[PERF] sync:init startSync took ${(performance.now() - tStartSync).toFixed(1)}ms`, - ); + try { + // Create client for existing account + const client = new GmailClient(account.id); + const tConnect = performance.now(); + await client.connect(); + log.info( + `[PERF] sync:init client.connect took ${(performance.now() - tConnect).toFixed(1)}ms`, + ); - connectedAccounts.push(accountInfo); - log.info( - `[PERF] sync:init account ${account.id} total ${(performance.now() - tAccount).toFixed(1)}ms`, - ); - } catch (err) { - log.error({ err: err }, `[Sync] Failed to connect account ${account.id}`); + // Register and start syncing + const tRegister = performance.now(); + const accountInfo = await emailSyncService.registerAccount(client); + log.info( + `[PERF] sync:init registerAccount took ${(performance.now() - tRegister).toFixed(1)}ms`, + ); + activeClients.set(account.id, client); + + // Backfill display name for existing accounts that don't have one + if (!account.displayName && accountInfo.displayName) { + updateAccountDisplayName(account.id, accountInfo.displayName); + client.clearAccountInfoCache(); + log.info( + `[Sync] Backfilled display name for ${account.email}: ${accountInfo.displayName}`, + ); + } - // Still store the client reference so reauth can use it - const client = new GmailClient(account.id); - activeClients.set(account.id, client); + const tStartSync = performance.now(); + emailSyncService.startSync(account.id); + log.info( + `[PERF] sync:init startSync took ${(performance.now() - tStartSync).toFixed(1)}ms`, + ); - connectedAccounts.push({ - accountId: account.id, - email: account.email, - isConnected: false, - }); + connectedAccounts.push(accountInfo); + log.info( + `[PERF] sync:init account ${account.id} total ${(performance.now() - tAccount).toFixed(1)}ms`, + ); + } catch (err) { + log.error({ err: err }, `[Sync] Failed to connect account ${account.id}`); - // If this is an auth error, notify the renderer after init completes - if (isAuthError(err)) { - // Defer to after the response is sent so the renderer has set up listeners - setTimeout(() => { - const win = getMainWindow(); - if (win) { - log.info(`[Auth] Sending startup token-expired for ${account.email}`); - win.webContents.send("auth:token-expired", { - accountId: account.id, - email: account.email, - source: "gmail", - }); - } - }, 1000); + // Still store the client reference so reauth can use it + const client = new GmailClient(account.id); + activeClients.set(account.id, client); + + connectedAccounts.push({ + accountId: account.id, + email: account.email, + isConnected: false, + }); + + // If this is an auth error, notify the renderer after init completes + if (isAuthError(err)) { + // Defer to after the response is sent so the renderer has set up listeners + setTimeout(() => { + const win = getMainWindow(); + if (win) { + log.info(`[Auth] Sending startup token-expired for ${account.email}`); + win.webContents.send("auth:token-expired", { + accountId: account.id, + email: account.email, + source: "gmail", + }); + } + }, 1000); + } } } - } - - // After all accounts are connected, start background processing - if (connectedAccounts.some((a) => a.isConnected)) { - // Delay 3 seconds to let the UI fully load first - // Skip if any account is doing a first-time sync — fullSync with - // runTriage will handle queueing only the recent emails after triage. - // Calendar sync is time-sensitive — run immediately, not after the 3s delay - calendarSyncService.syncNow(); - setTimeout(async () => { - if (emailSyncService.hasFirstSyncPending()) { - log.info("[Prefetch] Skipping processAllPending — first-time sync in progress"); - prefetchService.closeStartupCache(); + // After all accounts are connected, run retryable background work on + // every successful initialization. Only the delayed prefetch scan is + // process-lifetime work; outbox/calendar operations are idempotent + // and must be retried after a transient failure or renderer reload. + if (connectedAccounts.some((a) => a.isConnected)) { + // Delay 3 seconds to let the UI fully load first + // Skip if any account is doing a first-time sync — fullSync with + // runTriage will handle queueing only the recent emails after triage. + // Calendar sync is time-sensitive — run immediately, not after the 3s delay + calendarSyncService.syncNow(); + + // Process any queued outbox messages from previous session + outboxService.processQueue().catch((err) => log.error({ err }, "Unhandled error")); + + if (!backgroundProcessingStarted) { + backgroundProcessingStarted = true; + setTimeout(async () => { + if (emailSyncService.hasFirstSyncPending()) { + log.info("[Prefetch] Skipping processAllPending — first-time sync in progress"); + prefetchService.closeStartupCache(); + } else { + log.info("[PERF] prefetch starting (3s after sync:init)"); + await prefetchService.processAllPending().catch((error) => { + log.error({ err: error }, "[Sync] Error starting prefetch"); + }); + } + }, 3000); } else { - log.info("[PERF] prefetch starting (3s after sync:init)"); - await prefetchService.processAllPending().catch((error) => { - log.error({ err: error }, "[Sync] Error starting prefetch"); - }); + log.info("[Sync] Delayed prefetch already scheduled; retrying background work"); } - }, 3000); + } - // Process any queued outbox messages from previous session - outboxService.processQueue().catch((err) => log.error({ err }, "Unhandled error")); + const diagnostics = emailSyncService.getDiagnostics(); + log.info( + `[PERF] sync:init #${invocation} END total ${(performance.now() - t0).toFixed(1)}ms ` + + `(registered=${diagnostics.registeredAccounts}, intervals=${diagnostics.activeIntervals}, inFlight=${diagnostics.inFlightSyncs})`, + ); + return { success: true, data: connectedAccounts }; + } catch (error) { + log.info(`[PERF] sync:init #${invocation} ERROR ${(performance.now() - t0).toFixed(1)}ms`); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; } + })(); - log.info(`[PERF] sync:init END total ${(performance.now() - t0).toFixed(1)}ms`); - return { success: true, data: connectedAccounts }; - } catch (error) { - log.info(`[PERF] sync:init ERROR ${(performance.now() - t0).toFixed(1)}ms`); - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } + syncInitInFlight = initialization; + return initialization.finally(() => { + if (syncInitInFlight === initialization) { + syncInitInFlight = null; + } + }); }); // Archive an email (offline-aware: queues when offline or on network error) diff --git a/src/main/services/email-sync.ts b/src/main/services/email-sync.ts index e0cd4b30..f6f2f33b 100644 --- a/src/main/services/email-sync.ts +++ b/src/main/services/email-sync.ts @@ -40,6 +40,7 @@ type SyncAccount = { intervalId: NodeJS.Timeout | null; status: SyncStatus; lastError?: string; + needsReauth?: boolean; // Set at registration when account has no stored emails — consumed by // the first fullSync to run triage. Prevents race conditions where emails // arrive between registration and the fullSync check. @@ -50,6 +51,8 @@ const HEALTH_CHECK_INTERVAL = 15 * 60 * 1000; // 15 minutes class EmailSyncService { private accounts: Map = new Map(); + private syncingAccounts: Set = new Set(); + private syncPromises: Map> = new Map(); private syncInterval: number = DEFAULT_SYNC_INTERVAL; private healthCheckIntervalId: NodeJS.Timeout | null = null; // Tracks whether we've done the one-time sent backfill per account @@ -154,11 +157,22 @@ class EmailSyncService { ); } + // Re-authentication and renderer re-initialization can both attempt to + // replace a registered client. Clear the previous timer before replacing + // the map entry; otherwise the interval handle becomes unreachable while + // continuing to call syncAccount forever. + const previousAccount = this.accounts.get(accountId); + if (previousAccount?.intervalId) { + clearInterval(previousAccount.intervalId); + log.info(`[Sync] Cleared previous sync interval while replacing ${previousAccount.email}`); + } + this.accounts.set(accountId, { client, email: profile.emailAddress, intervalId: null, status: "idle", + needsReauth: false, // Mark for first-sync triage when no full sync has completed before. // This is captured at registration to avoid race conditions. needsFirstSyncTriage: !hasCompletedFullSync, @@ -185,6 +199,7 @@ class EmailSyncService { if (account.intervalId) { clearInterval(account.intervalId); } + this.syncPromises.delete(accountId); this.accounts.delete(accountId); log.info(`[Sync] Unregistered account: ${accountId}`); } @@ -197,10 +212,32 @@ class EmailSyncService { return Array.from(this.accounts.entries()).map(([accountId, account]) => ({ accountId, email: account.email, - isConnected: account.status !== "error", + isConnected: account.status !== "error" && !account.needsReauth, })); } + /** Lightweight counters for diagnosing duplicate initialization/timers. */ + getDiagnostics(): { + registeredAccounts: number; + activeIntervals: number; + inFlightSyncs: number; + } { + let activeIntervals = 0; + for (const account of this.accounts.values()) { + if (account.intervalId) activeIntervals += 1; + } + return { + registeredAccounts: this.accounts.size, + activeIntervals, + inFlightSyncs: this.syncPromises.size, + }; + } + + /** Whether this account currently has a live background sync timer. */ + hasActiveSync(accountId: string): boolean { + return this.accounts.get(accountId)?.intervalId != null; + } + /** * Start automatic syncing for an account */ @@ -466,7 +503,25 @@ class EmailSyncService { /** * Perform incremental sync for an account using History API */ - private async syncAccount(accountId: string): Promise { + private syncAccount(accountId: string): Promise { + const inFlight = this.syncPromises.get(accountId); + if (inFlight) { + log.info(`[Sync] Coalescing sync request for account ${accountId}`); + return inFlight; + } + + const sync = this.performSyncAccount(accountId); + this.syncPromises.set(accountId, sync); + const clearInFlight = () => { + if (this.syncPromises.get(accountId) === sync) { + this.syncPromises.delete(accountId); + } + }; + void sync.then(clearInFlight, clearInFlight); + return sync; + } + + private async performSyncAccount(accountId: string): Promise { const account = this.accounts.get(accountId); if (!account) return; @@ -509,6 +564,7 @@ class EmailSyncService { log.error(`[Sync] Auth error for ${account.email}, stopping sync`); this.stopSync(accountId); account.status = "error"; + account.needsReauth = true; account.lastError = "Authentication expired"; this.onSyncStatusChange?.(accountId, "error"); this.onAuthErrorCallback?.(accountId, account.email); @@ -524,6 +580,7 @@ class EmailSyncService { log.error(`[Sync] Auth error during full sync for ${account.email}`); this.stopSync(accountId); account.status = "error"; + account.needsReauth = true; account.lastError = "Authentication expired"; this.onSyncStatusChange?.(accountId, "error"); this.onAuthErrorCallback?.(accountId, account.email); @@ -536,6 +593,7 @@ class EmailSyncService { } } else { account.status = "error"; + account.needsReauth = false; account.lastError = errMsg; this.onSyncStatusChange?.(accountId, "error"); } diff --git a/src/main/window-lifecycle.ts b/src/main/window-lifecycle.ts new file mode 100644 index 00000000..4950b876 --- /dev/null +++ b/src/main/window-lifecycle.ts @@ -0,0 +1,8 @@ +/** + * macOS apps commonly stay resident after their last window is closed. Exo's + * renderer owns navigation state and its cached inbox, so destroying that last + * window turns the next Dock activation into an avoidable cold start. + */ +export function shouldHideWindowOnClose(platform: NodeJS.Platform, isQuitting: boolean): boolean { + return platform === "darwin" && !isQuitting; +} diff --git a/src/main/window.ts b/src/main/window.ts index 6f59f2a7..9f67bb60 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -3,11 +3,14 @@ import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { is } from "@electron-toolkit/utils"; import { getConfig } from "./ipc/settings.ipc"; +import { createLogger } from "./services/logger"; +import { shouldHideWindowOnClose } from "./window-lifecycle"; // __dirname is undefined in ESM. After the @anthropic-ai/claude-agent-sdk // 0.3.x upgrade, electron-vite emits the main bundle as ESM, so we resolve // the directory portably from import.meta.url. const __dirname = dirname(fileURLToPath(import.meta.url)); +const log = createLogger("window"); export function getIconPath(): string { if (app.isPackaged) { @@ -17,6 +20,10 @@ export function getIconPath(): string { } let mainWindow: BrowserWindow | null = null; +let isQuitting = false; +let createdWindowCount = 0; +let reusedWindowCount = 0; +let hiddenWindowCloseCount = 0; // Check if running in test/headless mode const isTestMode = process.env.NODE_ENV === "test" || process.env.EXO_HEADLESS === "true"; @@ -34,7 +41,7 @@ function getInitialBackgroundColor(): string { } export function createWindow(): BrowserWindow { - mainWindow = new BrowserWindow({ + const window = new BrowserWindow({ width: 1200, height: 800, minWidth: 900, @@ -60,22 +67,45 @@ export function createWindow(): BrowserWindow { }, }); - mainWindow.on("ready-to-show", () => { + mainWindow = window; + createdWindowCount += 1; + log.info( + `[Window] Created renderer window #${createdWindowCount} (reused=${reusedWindowCount}, hiddenCloses=${hiddenWindowCloseCount})`, + ); + + window.on("ready-to-show", () => { // Don't show window in test/headless mode if (!isTestMode) { - mainWindow?.show(); + window.show(); + } + }); + + window.on("close", (event) => { + if (!shouldHideWindowOnClose(process.platform, isQuitting)) return; + + event.preventDefault(); + hiddenWindowCloseCount += 1; + window.hide(); + log.info( + `[Window] Hid renderer window instead of destroying it (hiddenCloses=${hiddenWindowCloseCount})`, + ); + }); + + window.on("closed", () => { + if (mainWindow === window) { + mainWindow = null; } }); // Intercept keyboard shortcuts before they reach the page. - mainWindow.webContents.on("before-input-event", (event, input) => { + window.webContents.on("before-input-event", (event, input) => { if (input.type !== "keyDown") return; // Cmd/Ctrl+F → open find bar const isFindModifier = process.platform === "darwin" ? input.meta : input.control; if (input.key === "f" && isFindModifier) { event.preventDefault(); - mainWindow?.webContents.send("find:open"); + window.webContents.send("find:open"); return; } @@ -84,21 +114,44 @@ export function createWindow(): BrowserWindow { // input methods (e.g. CDP key injection). }); - mainWindow.webContents.setWindowOpenHandler((details) => { + window.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url); return { action: "deny" }; }); // HMR for renderer base on electron-vite cli if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { - mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]); + window.loadURL(process.env["ELECTRON_RENDERER_URL"]); } else { - mainWindow.loadFile(join(__dirname, "../renderer/index.html")); + window.loadFile(join(__dirname, "../renderer/index.html")); } - return mainWindow; + return window; } export function getMainWindow(): BrowserWindow | null { - return mainWindow; + return mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; +} + +/** + * Reveal the existing renderer when possible. This is the fast path used by + * Dock activation after a macOS red-button close. + */ +export function showMainWindow(): BrowserWindow { + const existing = getMainWindow(); + if (!existing) return createWindow(); + + reusedWindowCount += 1; + if (existing.isMinimized()) existing.restore(); + if (!existing.isVisible()) existing.show(); + existing.focus(); + log.info( + `[Window] Reused renderer window (reused=${reusedWindowCount}, created=${createdWindowCount})`, + ); + return existing; +} + +/** Allow BrowserWindow close events to proceed during an intentional app quit. */ +export function markAppQuitting(): void { + isQuitting = true; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9fff4a44..f1f1b7f8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -55,10 +55,12 @@ import type { IpcResponse, InboxSplit, Snippet, + NavigationStateSnapshot, } from "../shared/types"; import type { ScopedAgentEvent, AgentProviderConfig } from "../shared/agent-types"; import { mergeAndThreadSearchResults } from "./utils/searchResults"; import type { EmailThread } from "./store"; +import { resolveInitialAccountId, sanitizeNavigationState } from "./navigation-persistence"; function decodeHtmlEntities(text: string): string { const textarea = document.createElement("textarea"); @@ -635,6 +637,8 @@ export default function App() { const [scheduledMessages, setScheduledMessages] = useState([]); const scheduledPanelRef = useRef(null); const extensionsRegistered = useRef(false); + const navigationHydrated = useRef(false); + const latestNavigationState = useRef(null); // State values — individual selectors to avoid re-rendering the entire App on unrelated changes const showSettings = useAppStore((s) => s.showSettings); @@ -648,6 +652,10 @@ export default function App() { const isAgentPaletteOpen = useAppStore((s) => s.isAgentPaletteOpen); const isAgentsSidebarOpen = useAppStore((s) => s.isAgentsSidebarOpen); const viewMode = useAppStore((s) => s.viewMode); + const selectedEmailId = useAppStore((s) => s.selectedEmailId); + const selectedThreadId = useAppStore((s) => s.selectedThreadId); + const focusedThreadEmailId = useAppStore((s) => s.focusedThreadEmailId); + const currentSplitId = useAppStore((s) => s.currentSplitId); const activeSearchQuery = useAppStore((s) => s.activeSearchQuery); const _activeSearchResults = useAppStore((s) => s.activeSearchResults); const expiredAccountIds = useAppStore((s) => s.expiredAccountIds); @@ -865,23 +873,22 @@ export default function App() { // Fall back to primary/first if the persisted account no longer // exists (account was removed) or this is a first run. const settingsResult = await window.api.settings.get(); - const persisted = (settingsResult as { data?: { lastSelectedAccountId?: string | null } }) - ?.data?.lastSelectedAccountId; + const persistedSettings = ( + settingsResult as { + data?: { + lastSelectedAccountId?: string | null; + navigationState?: NavigationStateSnapshot; + }; + } + )?.data; + const persisted = persistedSettings?.lastSelectedAccountId; + const persistedNavigation = persistedSettings?.navigationState; const primaryAccount = fullAccounts.find((a) => a.isPrimary) || fullAccounts[0]; - let initialAccountId: string | null; - if (persisted === null && fullAccounts.length > 1) { - // Unified ("All Inboxes") only makes sense with 2+ accounts. If - // the user persisted unified previously but has since removed - // every account but one, fall back to that one account. - initialAccountId = null; - } else if ( - typeof persisted === "string" && - fullAccounts.some((a) => a.id === persisted) - ) { - initialAccountId = persisted; - } else { - initialAccountId = primaryAccount?.id ?? null; - } + const initialAccountId = resolveInitialAccountId( + fullAccounts, + persisted, + persistedNavigation, + ); // Load cached emails for ALL accounts BEFORE flipping // currentAccountId. Without this ordering, the first render in @@ -927,8 +934,20 @@ export default function App() { // Now safe to flip currentAccountId — store.emails has every // account's data, so useThreadedEmails will resolve the full // union on first paint. + // setCurrentAccountId intentionally clears account-scoped selection; + // restore the persisted navigation snapshot immediately afterwards. setCurrentAccountId(initialAccountId); + const restoredNavigation = sanitizeNavigationState( + persistedNavigation, + initialAccountId, + [...allEmails, ...allSentEmails], + ); + if (restoredNavigation) { + useAppStore.setState(restoredNavigation); + } + navigationHydrated.current = true; + if (primaryAccount) { // Identify user in PostHog using primary email identifyUser(primaryAccount.email, { @@ -958,6 +977,64 @@ export default function App() { } }, [setAccounts, setCurrentAccountId, addEmails, setSentEmails]); + // Persist only after initialization has consumed the previous snapshot, so + // the store's empty boot defaults cannot overwrite a valid saved selection. + useEffect(() => { + if (!navigationHydrated.current) return; + + latestNavigationState.current = { + accountId: currentAccountId, + currentSplitId, + selectedEmailId, + selectedThreadId, + focusedThreadEmailId, + viewMode, + }; + + const timeout = window.setTimeout(() => { + const navigationState = latestNavigationState.current; + if (navigationState) { + void window.api.settings.set({ + lastSelectedAccountId: navigationState.accountId, + navigationState, + }); + } + }, 150); + + return () => window.clearTimeout(timeout); + }, [ + currentAccountId, + currentSplitId, + focusedThreadEmailId, + selectedEmailId, + selectedThreadId, + viewMode, + ]); + + // Flush the latest selection before Chromium hides or tears down the + // renderer. This closes the debounce window used by the normal persistence + // path without adding synchronous work to every navigation change. + useEffect(() => { + const flushNavigationState = () => { + const navigationState = latestNavigationState.current; + if (navigationState) { + void window.api.settings.set({ + lastSelectedAccountId: navigationState.accountId, + navigationState, + }); + } + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") flushNavigationState(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("beforeunload", flushNavigationState); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("beforeunload", flushNavigationState); + }; + }, []); + // Set up sync event listeners useEffect(() => { // Listen for new emails — buffered to avoid interrupting j/k navigation diff --git a/src/renderer/navigation-persistence.ts b/src/renderer/navigation-persistence.ts new file mode 100644 index 00000000..a36e309c --- /dev/null +++ b/src/renderer/navigation-persistence.ts @@ -0,0 +1,103 @@ +import type { DashboardEmail, NavigationStateSnapshot } from "../shared/types"; + +type AccountIdentity = { + id: string; + isPrimary: boolean; +}; + +export type RestoredNavigationState = { + currentSplitId: string | null; + selectedEmailId: string | null; + selectedThreadId: string | null; + focusedThreadEmailId: string | null; + viewMode: "split" | "full"; +}; + +// These virtual splits are available without account-specific split data. +// Custom splits must be reset if the account they belonged to disappears. +export const ALWAYS_VISIBLE_SPLITS = new Set([ + "__priority__", + "__other__", + "__archive-ready__", + "__sent__", +]); + +/** Resolve the account view, preferring the newer full navigation snapshot. */ +export function resolveInitialAccountId( + accounts: AccountIdentity[], + lastSelectedAccountId: string | null | undefined, + navigationState: NavigationStateSnapshot | undefined, +): string | null { + const persistedAccountId = navigationState ? navigationState.accountId : lastSelectedAccountId; + + if (persistedAccountId === null && accounts.length > 1) return null; + if ( + typeof persistedAccountId === "string" && + accounts.some((account) => account.id === persistedAccountId) + ) { + return persistedAccountId; + } + + return accounts.find((account) => account.isPrimary)?.id ?? accounts[0]?.id ?? null; +} + +/** + * Validate a persisted selection against the freshly loaded cache. If the + * exact message disappeared but its thread remains, restore the newest message + * in that thread. If the thread is gone, fall back to split view. + */ +export function sanitizeNavigationState( + navigationState: NavigationStateSnapshot | undefined, + accountId: string | null, + emails: DashboardEmail[], +): RestoredNavigationState | null { + if (!navigationState) return null; + + const accountEmails = + accountId === null ? emails : emails.filter((email) => email.accountId === accountId); + const emailsById = new Map(accountEmails.map((email) => [email.id, email])); + + let selectedEmail = navigationState.selectedEmailId + ? emailsById.get(navigationState.selectedEmailId) + : undefined; + + const desiredThreadId = navigationState.selectedThreadId ?? selectedEmail?.threadId ?? null; + const threadEmails = desiredThreadId + ? accountEmails.filter((email) => email.threadId === desiredThreadId) + : []; + + if (!selectedEmail && threadEmails.length > 0) { + selectedEmail = [...threadEmails].sort( + (left, right) => new Date(right.date).getTime() - new Date(left.date).getTime(), + )[0]; + } + + const selectedThreadId = + desiredThreadId && threadEmails.length > 0 + ? desiredThreadId + : selectedEmail?.threadId + ? selectedEmail.threadId + : null; + const selectedEmailId = selectedEmail?.id ?? null; + + const persistedFocus = navigationState.focusedThreadEmailId + ? emailsById.get(navigationState.focusedThreadEmailId) + : undefined; + const focusedThreadEmailId = + persistedFocus && persistedFocus.threadId === selectedThreadId + ? persistedFocus.id + : selectedEmailId; + const currentSplitId = + navigationState.currentSplitId === null || + ALWAYS_VISIBLE_SPLITS.has(navigationState.currentSplitId) + ? navigationState.currentSplitId + : "__priority__"; + + return { + currentSplitId, + selectedEmailId, + selectedThreadId, + focusedThreadEmailId, + viewMode: navigationState.viewMode === "full" && selectedThreadId ? "full" : "split", + }; +} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 315aa23c..d0a9f226 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -16,6 +16,7 @@ import type { LocalDraft, } from "../../shared/types"; import { threadMatchesSplit as threadMatchesSplitShared } from "../utils/split-conditions"; +import { ALWAYS_VISIBLE_SPLITS } from "../navigation-persistence"; import type { AgentProviderConfig, AgentTaskInfo, @@ -1003,12 +1004,6 @@ export const useAppStore = create((set, get) => ({ // Reset account-scoped and conditionally-rendered splits. Only preserve // virtual splits that are always visible regardless of account data. // Default to __priority__ when resetting (matches main's convention). - const ALWAYS_VISIBLE_SPLITS = new Set([ - "__priority__", - "__other__", - "__archive-ready__", - "__sent__", - ]); const { currentSplitId } = get(); const nextSplitId = currentSplitId !== null && !ALWAYS_VISIBLE_SPLITS.has(currentSplitId) diff --git a/src/shared/types.ts b/src/shared/types.ts index 074d8939..c973e8c0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -416,6 +416,17 @@ export const OllamaCloudConfigSchema = z.object({ * Extensions card — renderer-safe (same pattern as DEFAULT_OLLAMA_MODEL). */ export const DEFAULT_HOSTLER_HARNESS = "opencode"; +export const NavigationStateSnapshotSchema = z.object({ + accountId: z.string().nullable(), + currentSplitId: z.string().nullable(), + selectedEmailId: z.string().nullable(), + selectedThreadId: z.string().nullable(), + focusedThreadEmailId: z.string().nullable(), + viewMode: z.enum(["split", "full"]), +}); + +export type NavigationStateSnapshot = z.infer; + // Config schema export const ConfigSchema = z.object({ maxEmails: z.number().default(50), @@ -475,6 +486,9 @@ export const ConfigSchema = z.object({ // null → unified "All Inboxes" view // undefined → first run; renderer falls back to primary account lastSelectedAccountId: z.string().nullable().optional(), + // Compact renderer navigation fallback for genuine renderer loss/app restart. + // Normal macOS window close keeps the live renderer and does not need this. + navigationState: NavigationStateSnapshotSchema.optional(), openclaw: z .object({ enabled: z.boolean().default(false), diff --git a/tests/e2e/window-resume.spec.ts b/tests/e2e/window-resume.spec.ts new file mode 100644 index 00000000..59c37c2c --- /dev/null +++ b/tests/e2e/window-resume.spec.ts @@ -0,0 +1,58 @@ +import { test, expect, ElectronApplication } from "@playwright/test"; +import { launchElectronApp } from "./launch-helpers"; + +test.describe("macOS window resume", () => { + test.skip(process.platform !== "darwin", "macOS owns the close-to-hide lifecycle"); + + let electronApp: ElectronApplication; + + test.beforeAll(async ({}, testInfo) => { + ({ app: electronApp } = await launchElectronApp({ workerIndex: testInfo.workerIndex })); + }); + + test.afterAll(async () => { + if (electronApp) await electronApp.close(); + }); + + test("red-button close and Dock activation reuse the same renderer", async () => { + const result = await electronApp.evaluate(async ({ app, BrowserWindow }) => { + const originalWindow = BrowserWindow.getAllWindows()[0]; + if (!originalWindow) throw new Error("No BrowserWindow found"); + + const originalWebContentsId = originalWindow.webContents.id; + await originalWindow.webContents.executeJavaScript( + 'window.__exoResumeMarker = "renderer-retained"', + ); + + originalWindow.close(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const hiddenWindowCount = BrowserWindow.getAllWindows().length; + const hiddenAfterClose = !originalWindow.isVisible(); + + app.emit("activate"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const resumedWindow = BrowserWindow.getAllWindows()[0]; + const marker = await resumedWindow.webContents.executeJavaScript( + "window.__exoResumeMarker", + ); + + return { + hiddenWindowCount, + hiddenAfterClose, + visibleAfterActivate: resumedWindow.isVisible(), + sameWebContents: resumedWindow.webContents.id === originalWebContentsId, + marker, + }; + }); + + expect(result).toEqual({ + hiddenWindowCount: 1, + hiddenAfterClose: true, + visibleAfterActivate: true, + sameWebContents: true, + marker: "renderer-retained", + }); + }); +}); diff --git a/tests/unit/navigation-persistence.spec.ts b/tests/unit/navigation-persistence.spec.ts new file mode 100644 index 00000000..3c30f215 --- /dev/null +++ b/tests/unit/navigation-persistence.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from "@playwright/test"; +import type { DashboardEmail, NavigationStateSnapshot } from "../../src/shared/types"; +import { + resolveInitialAccountId, + sanitizeNavigationState, +} from "../../src/renderer/navigation-persistence"; + +const accounts = [ + { id: "account-a", isPrimary: true }, + { id: "account-b", isPrimary: false }, +]; + +const snapshot: NavigationStateSnapshot = { + accountId: null, + currentSplitId: "__priority__", + selectedEmailId: "message-1", + selectedThreadId: "thread-1", + focusedThreadEmailId: "message-1", + viewMode: "full", +}; + +const emails = [ + { + id: "message-1", + threadId: "thread-1", + accountId: "account-b", + subject: "Selected message", + from: "sender@example.com", + to: ["me@example.com"], + date: "2026-08-05T12:00:00.000Z", + snippet: "Selected", + labelIds: ["INBOX"], + }, +] as DashboardEmail[]; + +test.describe("navigation persistence", () => { + test("the navigation snapshot restores All Inboxes", () => { + expect(resolveInitialAccountId(accounts, "account-a", snapshot)).toBeNull(); + }); + + test("a removed persisted account falls back to the primary account", () => { + expect( + resolveInitialAccountId(accounts, "account-b", { + ...snapshot, + accountId: "removed-account", + }), + ).toBe("account-a"); + }); + + test("restores a valid selected message and full-thread view", () => { + expect(sanitizeNavigationState(snapshot, null, emails)).toEqual({ + currentSplitId: "__priority__", + selectedEmailId: "message-1", + selectedThreadId: "thread-1", + focusedThreadEmailId: "message-1", + viewMode: "full", + }); + }); + + test("falls back to split view when the selected thread no longer exists", () => { + expect(sanitizeNavigationState(snapshot, null, [])).toEqual({ + currentSplitId: "__priority__", + selectedEmailId: null, + selectedThreadId: null, + focusedThreadEmailId: null, + viewMode: "split", + }); + }); + + test("resets account-scoped custom splits when restoring a snapshot", () => { + const restored = sanitizeNavigationState( + { + accountId: "account-a", + currentSplitId: "custom-account-a", + selectedEmailId: null, + selectedThreadId: null, + focusedThreadEmailId: null, + viewMode: "split", + }, + "account-b", + [], + ); + + expect(restored?.currentSplitId).toBe("__priority__"); + }); +}); diff --git a/tests/unit/shared-types.spec.ts b/tests/unit/shared-types.spec.ts index d9c50067..e7d51150 100644 --- a/tests/unit/shared-types.spec.ts +++ b/tests/unit/shared-types.spec.ts @@ -288,6 +288,34 @@ test.describe("ConfigSchema", () => { }); expect(result.success).toBe(false); }); + + test("validates a persisted navigation snapshot", () => { + const result = ConfigSchema.safeParse({ + navigationState: { + accountId: null, + currentSplitId: "__priority__", + selectedEmailId: "message-1", + selectedThreadId: "thread-1", + focusedThreadEmailId: "message-1", + viewMode: "full", + }, + }); + expect(result.success).toBe(true); + }); + + test("rejects an invalid persisted navigation view mode", () => { + const result = ConfigSchema.safeParse({ + navigationState: { + accountId: null, + currentSplitId: "__priority__", + selectedEmailId: null, + selectedThreadId: null, + focusedThreadEmailId: null, + viewMode: "detail", + }, + }); + expect(result.success).toBe(false); + }); }); // ============================================================ diff --git a/tests/unit/window-lifecycle.spec.ts b/tests/unit/window-lifecycle.spec.ts new file mode 100644 index 00000000..1427d641 --- /dev/null +++ b/tests/unit/window-lifecycle.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from "@playwright/test"; +import { shouldHideWindowOnClose } from "../../src/main/window-lifecycle"; + +test.describe("window close lifecycle", () => { + test("normal macOS window close preserves the renderer", () => { + expect(shouldHideWindowOnClose("darwin", false)).toBe(true); + }); + + test("intentional macOS app quit destroys the window", () => { + expect(shouldHideWindowOnClose("darwin", true)).toBe(false); + }); + + test("non-macOS window close keeps the native close behavior", () => { + expect(shouldHideWindowOnClose("win32", false)).toBe(false); + expect(shouldHideWindowOnClose("linux", false)).toBe(false); + }); +});