"use strict"; function emitAppLockReopen(windows) { const seen = new Set(); for (const win of Array.isArray(windows) ? windows : []) { try { if (!win || win.isDestroyed?.()) continue; const id = win.webContents?.id; if (id && seen.has(id)) continue; if (id) seen.add(id); win.webContents?.send?.("netcatty:app-lock:reopen"); } catch { // ignore } } } function shouldBackgroundLockOnHide(appLockController) { return Boolean(appLockController && typeof appLockController.setLocked === "function"); } function handleAppHide(appLockController) { if (!shouldBackgroundLockOnHide(appLockController)) return false; try { appLockController.setLocked("background"); return true; } catch { return false; } } /** * macOS keeps the process alive after the last BrowserWindow closes * (`window-all-closed` only quits on non-darwin). App-lock runtime is shared * for the process lifetime, so an unlock can otherwise survive a full * close → Dock reopen cycle. Re-apply lock with `startup` (same reason as * cold start) so a fresh renderer withholds content until unlock. Prefer this * over `background`, which waits on a reopen signal that a brand-new window * never receives. */ function ensureAppLockForFreshSession(appLockController, reason = "startup") { if (!shouldBackgroundLockOnHide(appLockController)) return false; const nextReason = typeof reason === "string" && reason.trim() !== "" ? reason : "startup"; try { appLockController.setLocked(nextReason); return true; } catch { return false; } } /** * True when no usable app-content windows remain (main / settings / etc.). * Used to decide whether recreating a main window is a "fresh session" reopen * after the last window was closed, vs. adding another window while one is open. */ function hasNoUsableAppContentWindows(appContentWindows) { if (!Array.isArray(appContentWindows) || appContentWindows.length === 0) return true; return !appContentWindows.some((win) => { try { if (!win || (typeof win.isDestroyed === "function" && win.isDestroyed())) { return false; } // Hidden prewarm windows (e.g. Settings) keep the process alive but are // not a user session — do not block fresh-session re-lock (Codex P2). // Minimized windows also report !isVisible but are still a live session // (Codex P2 on 4e6c3235). if (typeof win.isVisible === "function" && !win.isVisible()) { if (typeof win.isMinimized === "function" && win.isMinimized()) { return true; } return false; } return true; } catch { return false; } }); } function handleActivateWithMainWindow({ app, mainWindow, globalShortcutBridge, windowManager, reopenWindows, }) { if (!mainWindow || mainWindow.isDestroyed?.()) return false; try { if (mainWindow.webContents?.isCrashed?.()) { mainWindow.destroy?.(); return false; } } catch { // ignore } try { globalShortcutBridge?.clearPendingFullscreenHide?.(mainWindow); } catch { // ignore } if (typeof windowManager?.showAndFocusMainWindow === "function") { windowManager.showAndFocusMainWindow(mainWindow); } else { try { if (mainWindow.isMinimized?.()) mainWindow.restore?.(); } catch { // ignore } try { mainWindow.show?.(); } catch { // ignore } try { mainWindow.focus?.(); } catch { // ignore } } emitAppLockReopen(reopenWindows); try { app?.focus?.({ steal: true }); } catch { // ignore } return true; } function shouldCommitQuitWithoutDirtyCheck({ reachableMainWindows, queryableWebContents, }) { const hasReachableMainWindows = Array.isArray(reachableMainWindows) && reachableMainWindows.length > 0; if (!hasReachableMainWindows) return true; const hasQueryableWebContents = Array.isArray(queryableWebContents) && queryableWebContents.length > 0; return !hasQueryableWebContents; } async function handleBeforeQuit({ event, mainWindows, queryDirtyEditors, appLockController, windowManager, app, ipcMain, quitConfirmed, quitGuardChannelBusy, timeoutMs, setQuitGuardChannelBusy, setQuitConfirmed, commitQuit, cancelPendingUpdateInstall, }) { if (quitConfirmed) return { committed: false, skipped: "quit-confirmed" }; if (quitGuardChannelBusy) { event?.preventDefault?.(); return { committed: false, skipped: "busy" }; } // When the caller provides commitQuit (async plugin shutdown before the real // quit), the original quit event must stay cancelled until commitQuit // re-enters app.quit(). commitQuit owns background-lock, isQuitting and // quitConfirmed in that case. const commit = () => { if (typeof commitQuit === "function") { event?.preventDefault?.(); commitQuit(); return; } if (shouldBackgroundLockOnHide(appLockController)) { appLockController.setLocked("background"); } windowManager?.setIsQuitting?.(true); setQuitConfirmed?.(true); app?.quit?.(); }; const reachableMainWindows = (Array.isArray(mainWindows) ? mainWindows : []).filter((candidate) => ( candidate && !candidate.isDestroyed?.() )); // Keep the window alongside webContents so a dirty result can bring the // owning window forward (hidden-to-tray / unfocused) before aborting quit. const queryableWindows = reachableMainWindows.filter((candidate) => { const wc = candidate.webContents; return wc && !wc.isDestroyed?.() && !wc.isCrashed?.(); }); const queryableWebContents = queryableWindows .map((candidate) => candidate.webContents) .filter(Boolean); if (shouldCommitQuitWithoutDirtyCheck({ reachableMainWindows, queryableWebContents })) { commit(); return { committed: true, skipped: "fast-path" }; } setQuitGuardChannelBusy?.(true); event?.preventDefault?.(); try { const dirtyResults = await Promise.all( queryableWindows.map((win) => queryDirtyEditors(win.webContents, timeoutMs, { ipcMain }).then((hasDirty) => ({ win, hasDirty: Boolean(hasDirty), })), ), ); setQuitGuardChannelBusy?.(false); const dirtyWindows = dirtyResults.filter((result) => result.hasDirty).map((result) => result.win); if (dirtyWindows.length === 0) { commit(); return { committed: true, skipped: null }; } // Renderer only surfaces the dirty toast in its own window. Focus every // dirty owner so a tray-hidden or backgrounded window is visible before // we refuse to quit. for (const win of dirtyWindows) { try { if (typeof windowManager?.showAndFocusMainWindow === "function") { windowManager.showAndFocusMainWindow(win); } else { try { if (win.isMinimized?.()) win.restore?.(); } catch { // ignore } try { win.show?.(); } catch { // ignore } try { win.focus?.(); } catch { // ignore } } } catch { // ignore } } // App Lock overlay sits above renderer toasts (z-index). When the app is // already locked, focusing the dirty window does not make the unsaved // warning visible — surface a native dialog above the lock screen (Codex P2). try { const { dialog } = require("electron"); dialog.showMessageBoxSync({ type: "warning", buttons: ["OK"], defaultId: 0, message: "Unsaved changes", detail: "One or more editors have unsaved changes. If the app is locked, unlock it, then save or discard before quitting.", }); } catch { // ignore missing dialog in tests } if (windowManager?.isQuittingForUpdate?.()) { // Cancel the install bridge's in-flight state as well as the // window-manager flag so a cancelled update can be retried immediately // instead of waiting for its watchdog (#1215 review). try { cancelPendingUpdateInstall?.(); } catch { // ignore } if (windowManager.isQuittingForUpdate?.()) { windowManager.setQuittingForUpdate?.(false); } } return { committed: false, skipped: "dirty" }; } catch { setQuitGuardChannelBusy?.(false); commit(); return { committed: true, skipped: "error" }; } } module.exports = { emitAppLockReopen, ensureAppLockForFreshSession, handleAppHide, handleActivateWithMainWindow, handleBeforeQuit, hasNoUsableAppContentWindows, shouldBackgroundLockOnHide, shouldCommitQuitWithoutDirtyCheck, };