/** * Window Manager - Handles Electron window creation and management * Extracted from main.cjs for single responsibility */ const path = require("node:path"); const fs = require("node:fs"); const { safeSend } = require("./ipcUtils.cjs"); const V8_CACHE_OPTIONS = "bypassHeatCheck"; function getGlobalShortcutBridge() { return require("./globalShortcutBridge.cjs"); } // Theme colors configuration const THEME_COLORS = { dark: { background: "#0b1220", titleBarColor: "#0b1220", symbolColor: "#ffffff", }, light: { background: "#ffffff", titleBarColor: "#f8fafc", symbolColor: "#1e293b", }, }; // State let mainWindow = null; const mainWindows = new Set(); const appContentWindows = new Set(); const dirtyEditorWindows = new Set(); let appContentWindowClosedHandler = null; let lastFocusedMainWindow = null; let settingsWindow = null; let currentTheme = "light"; let currentLanguage = "en"; let currentWindowOpacity = 1; let cachedNativeTheme = null; let handlersRegistered = false; // Prevent duplicate IPC handler registration let menuDeps = null; let pluginApplicationMenuProvider = null; let electronApp = null; // Reference to Electron app for userData path let isQuitting = false; // Set right before electron-updater's quitAndInstall() drives app.quit() for a // macOS/Windows in-place update. The install only succeeds if the app process // exits cleanly: Squirrel.Mac's ShipIt helper waits on the parent PID to die // before swapping the bundle. Two normal-quit behaviors would otherwise keep // the process alive and strand the installer (see #1215): // 1. close-to-tray hides the window instead of closing it, and // 2. the before-quit dirty-editor guard preventDefault()s the quit for a // 5s renderer round-trip. // This flag lets both paths recognize an update install and let the quit // through immediately. let quittingForUpdate = false; const rendererReadyCallbacksByWebContentsId = new Map(); const rendererReadySeenByWebContentsId = new Set(); const rendererReadyWaitersByWebContentsId = new Map(); const unhealthyWebContentsIds = new Set(); const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1"; const OAUTH_DEFAULT_WIDTH = 600; const OAUTH_DEFAULT_HEIGHT = 700; const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__"; const WINDOW_COMMAND_CLOSE_CHANNEL = "netcatty:window:command-close"; // The OAuth callback port is chosen dynamically by oauthBridge (prefers // 45678, falls back to an OS-assigned free port if that is in use, #823), // so the in-app popup allow-list has to consult the bridge at popup-open // time instead of a hardcoded constant. const oauthBridge = require("./oauthBridge.cjs"); const WINDOW_STATE_FILE = "window-state.json"; const DEFAULT_WINDOW_WIDTH = 1400; const DEFAULT_WINDOW_HEIGHT = 900; // Minimum window size: enough to render the expanded sidebar + a usable // host list + the 420px host details / new-host aside panel without overflow. const MIN_WINDOW_WIDTH = 1100; const MIN_WINDOW_HEIGHT = 640; function debugLog(...args) { if (!DEBUG_WINDOWS) return; try { // eslint-disable-next-line no-console console.log("[WindowManager]", ...args); } catch { // ignore } } function setIsQuitting(nextValue) { isQuitting = Boolean(nextValue); } function setAppContentWindowClosedHandler(handler) { if (handler != null && typeof handler !== "function") { throw new TypeError("App content window close handler must be a function"); } appContentWindowClosedHandler = handler ?? null; } function notifyAppContentWindowClosed(win) { appContentWindowClosedHandler?.(win); } function shouldCloseWindowFromInput(input) { return Boolean( input?.type === "keyDown" && input?.meta && !input?.control && !input?.alt && !input?.shift && String(input?.key || "").toLowerCase() === "w", ); } /** * Read the generic "app is quitting" flag. Window close handlers gate * close-to-tray / settings-window hiding on this; exposed so the update-quit * rollback can be verified. */ function getIsQuitting() { return isQuitting; } /** * Mark that the app is quitting to install a downloaded update. Mirrors the * generic isQuitting flag so the main-window close handler bypasses * close-to-tray. Call this right before electron-updater's quitAndInstall(). * * Passing false rolls BOTH flags back — used when a quitAndInstall never * actually quits the app (throw / Squirrel follow-up error / stale download). * Without resetting isQuitting too, close-to-tray and settings-window hiding * would stay disabled for the rest of the session (#1215 review). */ function setQuittingForUpdate(nextValue) { quittingForUpdate = Boolean(nextValue); isQuitting = quittingForUpdate; } /** * True when quitAndInstall() initiated the current quit. The before-quit guard * still performs the dirty-editor round-trip; if the user cancels to save, it * uses this state to roll back the update quit flags. */ function isQuittingForUpdate() { return quittingForUpdate; } /** * Get the path to the window state file */ function getWindowStatePath() { try { if (!electronApp) return null; return path.join(electronApp.getPath("userData"), WINDOW_STATE_FILE); } catch { return null; } } /** * Load saved window state from disk */ function loadWindowState() { try { const statePath = getWindowStatePath(); if (!statePath || !fs.existsSync(statePath)) { return null; } const data = fs.readFileSync(statePath, "utf8"); const state = JSON.parse(data); // Validate the loaded state has required properties if ( typeof state.width === "number" && typeof state.height === "number" && state.width > 0 && state.height > 0 ) { return state; } return null; } catch (err) { debugLog("Failed to load window state:", err?.message || err); return null; } } /** * Save window state to disk (synchronous) */ function saveWindowStateSync(state) { try { const statePath = getWindowStatePath(); if (!statePath) return false; fs.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 }); return true; } catch (err) { debugLog("Failed to save window state:", err?.message || err); return false; } } /** * Save window state to disk (asynchronous) */ async function saveWindowState(state) { try { const statePath = getWindowStatePath(); if (!statePath) return false; await fs.promises.writeFile(statePath, JSON.stringify(state, null, 2), { mode: 0o600 }); return true; } catch (err) { debugLog("Failed to save window state:", err?.message || err); return false; } } let pendingWindowStateWrite = null; let queuedWindowState = null; let windowStateCloseRequested = false; async function queueWindowStateSave(state) { if (!state) return false; if (windowStateCloseRequested) { return pendingWindowStateWrite || false; } queuedWindowState = state; if (pendingWindowStateWrite) { return pendingWindowStateWrite; } pendingWindowStateWrite = (async () => { let lastResult = true; while (queuedWindowState) { const nextState = queuedWindowState; queuedWindowState = null; lastResult = await saveWindowState(nextState); } pendingWindowStateWrite = null; return lastResult; })(); return pendingWindowStateWrite; } /** * Get the current window bounds state for saving * @param {BrowserWindow} win - The window to get bounds from * @param {Object} overrideBounds - Optional bounds to use instead of current window bounds (for normal bounds tracking) */ function getWindowBoundsState(win, overrideBounds) { if (!win || win.isDestroyed()) return null; const bounds = overrideBounds || win.getBounds(); return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, isMaximized: win.isMaximized(), isFullScreen: win.isFullScreen(), }; } const MENU_LABELS = { en: { edit: "Edit", view: "View", plugins: "Plugins", window: "Window", reload: "Reload", closeWindow: "Close Window" }, ru: { edit: "Правка", view: "Вид", plugins: "Плагины", window: "Окно", reload: "Перезагрузить", closeWindow: "Закрыть окно" }, "zh-CN": { edit: "编辑", view: "视图", plugins: "插件", window: "窗口", reload: "重新加载", closeWindow: "关闭窗口" }, "zh-TW": { edit: "編輯", view: "檢視", plugins: "外掛程式", window: "視窗", reload: "重新載入", closeWindow: "關閉視窗" }, }; function tMenu(language, key) { if (!language) return MENU_LABELS.en[key] ?? key; const direct = MENU_LABELS?.[language]?.[key]; if (direct) return direct; const base = String(language).split("-")[0]; const baseMatchKey = Object.keys(MENU_LABELS).find((k) => k === base || k.startsWith(`${base}-`)); const baseMatch = baseMatchKey ? MENU_LABELS[baseMatchKey]?.[key] : undefined; return baseMatch ?? MENU_LABELS.en[key] ?? key; } function rebuildApplicationMenu() { if (!menuDeps?.Menu || !menuDeps?.app) return; const menu = buildAppMenu(menuDeps.Menu, menuDeps.app, menuDeps.isMac, currentLanguage); menuDeps.Menu.setApplicationMenu?.(menu); } function setPluginApplicationMenuProvider(provider) { if (provider != null && typeof provider !== "function") { throw new TypeError("Plugin application menu provider must be a function"); } pluginApplicationMenuProvider = provider ?? null; rebuildApplicationMenu(); } function getCurrentLanguage() { return currentLanguage; } function getWindowForIpcEvent(event) { try { const wc = event?.sender; const win = wc?.getOwnerBrowserWindow?.(); if (win && !win.isDestroyed()) return win; } catch { // ignore } return getMainWindow(); } function pruneMainWindows() { for (const win of Array.from(mainWindows)) { if (!win || win.isDestroyed?.()) { mainWindows.delete(win); if (lastFocusedMainWindow === win) lastFocusedMainWindow = null; if (mainWindow === win) mainWindow = null; } } } function pruneAppContentWindows() { for (const win of Array.from(appContentWindows)) { if (!win || win.isDestroyed?.()) { appContentWindows.delete(win); dirtyEditorWindows.delete(win); } } for (const win of Array.from(dirtyEditorWindows)) { if (!appContentWindows.has(win)) dirtyEditorWindows.delete(win); } } function getMainWindowList() { pruneMainWindows(); return Array.from(mainWindows).filter((win) => isWindowUsable(win)); } function getAppContentWindowList() { pruneAppContentWindows(); return Array.from(appContentWindows).filter((win) => isWindowUsable(win)); } function getDirtyEditorWindowList() { pruneAppContentWindows(); return Array.from(dirtyEditorWindows).filter((win) => isWindowUsable(win)); } function rememberMainWindow(win) { if (!win || win.isDestroyed?.()) return; lastFocusedMainWindow = win; mainWindow = win; } function registerAppContentWindow(win, options = {}) { if (!win || win.isDestroyed?.()) return; appContentWindows.add(win); if (options.queryDirtyEditors === true) dirtyEditorWindows.add(win); } function unregisterAppContentWindow(win) { if (!win) return; appContentWindows.delete(win); dirtyEditorWindows.delete(win); } function registerMainWindow(win) { if (!win || win.isDestroyed?.()) return; registerAppContentWindow(win, { queryDirtyEditors: true }); mainWindows.add(win); rememberMainWindow(win); try { win.on("focus", () => rememberMainWindow(win)); } catch { // ignore } } function unregisterMainWindow(win) { if (!win) return; mainWindows.delete(win); unregisterAppContentWindow(win); if (lastFocusedMainWindow === win) lastFocusedMainWindow = null; if (mainWindow === win) mainWindow = null; const fallback = getMainWindowList().at(-1) || null; if (fallback) rememberMainWindow(fallback); } function forEachMainWindow(callback) { for (const win of getMainWindowList()) { try { callback(win); } catch { // ignore per-window broadcast failures } } } function clampWindowOpacity(opacity) { const value = Number(opacity); if (!Number.isFinite(value)) return 1; return Math.min(1, Math.max(0.5, value)); } function applyWindowOpacityToWindow(win) { if (!win || win.isDestroyed?.()) return; try { win.setOpacity?.(currentWindowOpacity); } catch { // ignore } } function applyWindowOpacity(opacity) { currentWindowOpacity = clampWindowOpacity(opacity); forEachMainWindow(applyWindowOpacityToWindow); if (settingsWindow && !settingsWindow.isDestroyed()) { applyWindowOpacityToWindow(settingsWindow); } return currentWindowOpacity; } function getMainWindowCount() { return getMainWindowList().length; } function isMainWindow(win) { if (!win || win.isDestroyed?.()) return false; pruneMainWindows(); return mainWindows.has(win); } function closeBrowserWindow(win) { if (!win || win.isDestroyed?.()) return false; try { win.close(); return true; } catch { return false; } } function requestWindowCommandClose(win) { if (!win || win.isDestroyed?.()) return false; try { const webContents = win.webContents; if (!webContents || webContents.isDestroyed?.()) return closeBrowserWindow(win); webContents.send(WINDOW_COMMAND_CLOSE_CHANNEL); return true; } catch { return closeBrowserWindow(win); } } function broadcastLanguageChanged() { try { forEachMainWindow((win) => win.webContents?.send?.("netcatty:languageChanged", currentLanguage)); if (settingsWindow && !settingsWindow.isDestroyed()) { settingsWindow.webContents?.send?.("netcatty:languageChanged", currentLanguage); } } catch { // ignore } } /** * Normalize dev server URL for local access compatibility */ function normalizeDevServerUrl(urlString) { if (!urlString) return urlString; try { const u = new URL(urlString); const host = u.hostname; // Vite often binds to 0.0.0.0; Chromium can't navigate to it. Prefer localhost. if ( host === "0.0.0.0" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host === "[::]" || host === "::" ) { u.hostname = "localhost"; return u.toString(); } return urlString; } catch { return urlString; } } function getDevRendererBaseUrl(devServerUrl) { const normalized = normalizeDevServerUrl(devServerUrl); const fallback = typeof normalized === "string" ? normalized.replace(/\/+$/, "") : ""; try { if (mainWindow && !mainWindow.isDestroyed()) { const currentUrl = mainWindow.webContents?.getURL?.(); if (currentUrl) { const origin = new URL(currentUrl).origin; if (origin && origin !== "null") return origin; } } } catch { // ignore } return fallback; } const { normalizeBackgroundColor, resolveFrontendBackgroundColor, } = require("./windowManager/backgroundColor.cjs"); function parseWindowOpenFeatures(features) { if (!features) return {}; const parts = String(features) .split(",") .map((part) => part.trim()) .filter(Boolean); const values = {}; parts.forEach((part) => { const [key, value] = part.split("=").map((entry) => entry.trim()); if (!key || !value) return; const numeric = Number.parseInt(value, 10); if (Number.isFinite(numeric)) values[key.toLowerCase()] = numeric; }); const width = values.width; const height = values.height; return { width: Number.isFinite(width) ? Math.max(360, Math.min(width, 1400)) : null, height: Number.isFinite(height) ? Math.max(480, Math.min(height, 1200)) : null, }; } /** * Track open fallback browser windows so they are garbage-collected when the * BrowserWindow is destroyed. */ const { createExternalWindowApi } = require("./windowManager/externalWindows.cjs"); const externalWindowApi = createExternalWindowApi({ get mainWindow() { return mainWindow; }, get currentTheme() { return currentTheme; }, THEME_COLORS, OAUTH_DEFAULT_WIDTH, OAUTH_DEFAULT_HEIGHT, V8_CACHE_OPTIONS, require, console, URL, parseWindowOpenFeatures, oauthBridge, }); const { openFallbackBrowser, tryOpenExternalWithFallback, createExternalOnlyWindowOpenHandler, createAppWindowOpenHandler, } = externalWindowApi; function attachOAuthLoadingOverlay(win) { if (!win || win.isDestroyed?.()) return; const overlayStyle = ` #${OAUTH_OVERLAY_ID} { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; gap: 12px; background: radial-gradient(900px circle at 15% 0%, rgba(14, 165, 233, 0.12), transparent 38%), radial-gradient(1200px circle at 85% 10%, rgba(56, 189, 248, 0.14), transparent 40%), #f7f9fc; color: #1e293b; font-family: "Space Grotesk", system-ui, -apple-system, "Segoe UI", sans-serif; z-index: 999999; } #${OAUTH_OVERLAY_ID}.dark { background: radial-gradient(900px circle at 15% 0%, rgba(14, 165, 233, 0.16), transparent 38%), radial-gradient(1200px circle at 85% 10%, rgba(56, 189, 248, 0.18), transparent 40%), #0b1220; color: #e2e8f0; } #${OAUTH_OVERLAY_ID} .spinner { width: 28px; height: 28px; border-radius: 999px; border: 3px solid rgba(148, 163, 184, 0.35); border-top-color: currentColor; animation: netcatty-oauth-spin 0.8s linear infinite; } #${OAUTH_OVERLAY_ID} .label { font-size: 14px; letter-spacing: 0.04em; } @keyframes netcatty-oauth-spin { to { transform: rotate(360deg); } } `; const injectOverlayScript = ` (() => { if (document.getElementById("${OAUTH_OVERLAY_ID}")) return; const root = document.documentElement || document.body; const style = document.createElement("style"); style.textContent = ${JSON.stringify(overlayStyle)}; style.setAttribute("data-netcatty-oauth", "style"); (document.head || root).appendChild(style); const overlay = document.createElement("div"); overlay.id = "${OAUTH_OVERLAY_ID}"; if (root.classList.contains("dark")) overlay.classList.add("dark"); overlay.innerHTML = '
Loading...
'; (document.body || root).appendChild(overlay); })(); `; const removeOverlayScript = ` (() => { const overlay = document.getElementById("${OAUTH_OVERLAY_ID}"); if (overlay) overlay.remove(); const style = document.querySelector('style[data-netcatty-oauth="style"]'); if (style) style.remove(); })(); `; win.webContents.on("did-start-loading", () => { win.webContents.executeJavaScript(injectOverlayScript, true).catch(() => { }); }); win.webContents.on("did-stop-loading", () => { win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { }); }); win.webContents.on("did-fail-load", () => { win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { }); }); } function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true, startHidden = false } = {}) { const webContentsId = (() => { try { return win?.webContents?.id; } catch { return null; } })(); let shown = false; let readyToShow = false; let rendererReady = false; let timer = null; const showOnce = () => { if (shown) return; shown = true; if (timer) clearTimeout(timer); timer = null; if (webContentsId) rendererReadyCallbacksByWebContentsId.delete(webContentsId); if (startHidden) { // Cold start via the OS login item ("--hidden"): stay hidden. The tray // icon is guaranteed (and pinned open regardless of close-to-tray) // by mainWindow.cjs right after bridges register, which runs before // this can fire (ready-to-show/renderer-ready always come later), so // electronModule is safely initialized by then. Pin here too as a // belt-and-suspenders fallback in case that ordering ever changes. try { getGlobalShortcutBridge().pinTrayForHiddenLaunch?.(); } catch (err) { console.warn("[WindowManager] Failed to create tray for hidden launch:", err?.message || err); } return; } try { if (!win.isDestroyed()) win.show(); } catch { // ignore } }; const tryShow = () => { if (shown) return; if (!readyToShow) return; if (waitForRendererReady && !rendererReady) return; showOnce(); }; const markRendererReady = () => { if (rendererReady) return; rendererReady = true; tryShow(); }; if (webContentsId) rendererReadyCallbacksByWebContentsId.set(webContentsId, markRendererReady); win.once("ready-to-show", () => { readyToShow = true; tryShow(); }); // Renderer calls netcattyBridge.rendererReady() after React mount, // which sends IPC "netcatty:renderer:ready" → markRendererReady(). // The timeout fallback (timeoutMs) ensures the window is shown even if // the signal is never received. // Dev/edge-case fallback: don't keep the window hidden forever. if (Number(timeoutMs) > 0) { timer = setTimeout(showOnce, timeoutMs); } win.on("closed", () => { if (timer) clearTimeout(timer); timer = null; if (webContentsId) rendererReadyCallbacksByWebContentsId.delete(webContentsId); }); return { showOnce, markRendererReady }; } function resolveRendererReady(wcId) { if (!wcId) return; unhealthyWebContentsIds.delete(wcId); rendererReadySeenByWebContentsId.add(wcId); const cb = rendererReadyCallbacksByWebContentsId.get(wcId); if (cb) cb(); const waiters = rendererReadyWaitersByWebContentsId.get(wcId); if (!waiters || waiters.size === 0) return; rendererReadyWaitersByWebContentsId.delete(wcId); for (const resolve of waiters) { try { resolve(); } catch { // ignore waiter errors } } } function clearRendererReadyForWebContents(wcId) { if (!wcId) return; rendererReadySeenByWebContentsId.delete(wcId); } function isWindowUsable(win, options = {}) { const requireVisible = options.requireVisible === true; if (!win || typeof win.isDestroyed !== "function" || win.isDestroyed()) { return false; } if (requireVisible) { if (typeof win.isVisible !== "function") return false; try { if (!win.isVisible()) return false; } catch { return false; } } const contents = win.webContents; if (!contents || typeof contents.isDestroyed !== "function" || contents.isDestroyed()) { return false; } const wcId = (() => { try { return contents.id; } catch { return null; } })(); if (wcId && unhealthyWebContentsIds.has(wcId)) { return false; } if (typeof contents.isCrashed === "function") { try { if (contents.isCrashed()) return false; } catch { return false; } } return true; } function waitForRendererReady(win, { timeoutMs = 15000 } = {}) { return new Promise((resolve, reject) => { const wcId = (() => { try { return win?.webContents?.id; } catch { return null; } })(); if (!win || win.isDestroyed?.() || !wcId) { reject(new Error("Main window is unavailable before renderer ready.")); return; } if (rendererReadySeenByWebContentsId.has(wcId)) { resolve(); return; } let timer = null; const cleanup = () => { if (timer) clearTimeout(timer); timer = null; try { win.removeListener("closed", handleClosed); } catch {} try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {} const waiters = rendererReadyWaitersByWebContentsId.get(wcId); if (waiters) { waiters.delete(handleReady); if (waiters.size === 0) { rendererReadyWaitersByWebContentsId.delete(wcId); } } }; const handleReady = () => { cleanup(); resolve(); }; const handleClosed = () => { cleanup(); reject(new Error("Main window closed before renderer became ready.")); }; const handleGone = (_event, details) => { cleanup(); reject(new Error(`Renderer process exited before ready: ${details?.reason || "unknown"}`)); }; let waiters = rendererReadyWaitersByWebContentsId.get(wcId); if (!waiters) { waiters = new Set(); rendererReadyWaitersByWebContentsId.set(wcId, waiters); } waiters.add(handleReady); win.once("closed", handleClosed); win.webContents?.once?.("render-process-gone", handleGone); if (Number(timeoutMs) > 0) { timer = setTimeout(() => { cleanup(); reject(new Error("Renderer did not report ready before timeout.")); }, timeoutMs); } }); } /** * Wait for a freshly created window's renderer to report ready, then deliver an * IPC message to it. The renderer only registers its IPC listeners (and reports * ready) after React mounts, and Electron does not queue messages for listeners * that do not exist yet. So if readiness times out we must NOT send — the * message would be silently dropped, leaving the window blank while the caller * still saw "success". Returning a failure here lets the caller surface the * existing error path instead. */ async function sendWhenRendererReady(win, channel, payload, options = {}) { const { timeoutMs = 8000, waitForReady = waitForRendererReady, shouldSend, cancelReason = "cancelled", } = options; try { await waitForReady(win, { timeoutMs }); } catch (err) { if (typeof shouldSend === "function" && shouldSend() === false) { return { success: false, reason: cancelReason }; } return { success: false, error: "New window did not become ready in time", reason: err?.message || String(err), }; } if (win?.isDestroyed?.() || win?.webContents?.isDestroyed?.()) { return { success: false, error: "Window closed before message could be delivered" }; } if (typeof shouldSend === "function" && shouldSend() === false) { return { success: false, reason: cancelReason }; } win.webContents.send(channel, payload); return { success: true }; } function resolveLiveAppIcon(fallback = null) { try { const appIconManager = require("./appIconManager.cjs"); const appPath = electronApp?.getAppPath?.(); if (appPath) { const iconPath = appIconManager.getAppIconPath(appPath); if (iconPath) return iconPath; } } catch { // ignore } return fallback; } /** * Create the main application window */ const { createMainWindowApi, setTerminalKeyboardFocusForWindow, } = require("./windowManager/mainWindow.cjs"); const mainWindowApi = createMainWindowApi({ get mainWindow() { return mainWindow; }, set mainWindow(value) { mainWindow = value; }, get electronApp() { return electronApp; }, set electronApp(value) { electronApp = value; }, get currentTheme() { return currentTheme; }, get isQuitting() { return isQuitting; }, get pendingWindowStateWrite() { return pendingWindowStateWrite; }, set pendingWindowStateWrite(value) { pendingWindowStateWrite = value; }, get queuedWindowState() { return queuedWindowState; }, set queuedWindowState(value) { queuedWindowState = value; }, get windowStateCloseRequested() { return windowStateCloseRequested; }, set windowStateCloseRequested(value) { windowStateCloseRequested = value; }, DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT, V8_CACHE_OPTIONS, THEME_COLORS, unhealthyWebContentsIds, rendererReadySeenByWebContentsId, __dirname, URL, require, console, setTimeout, clearTimeout, getGlobalShortcutBridge, debugLog, resolveFrontendBackgroundColor, loadWindowState, getDevRendererBaseUrl, getWindowBoundsState, queueWindowStateSave, saveWindowStateSync, setupDeferredShow, clearRendererReadyForWebContents, createExternalOnlyWindowOpenHandler, createAppWindowOpenHandler, attachOAuthLoadingOverlay, queryDirtyEditors: (...args) => require("./dirtyEditorGuard.cjs").queryDirtyEditors(...args), registerWindowHandlers, requestWindowCommandClose, shouldCloseWindowFromInput, registerMainWindow, unregisterMainWindow, registerAppContentWindow, unregisterAppContentWindow, notifyAppContentWindowClosed, setAppContentWindowClosedHandler, getMainWindowCount, applyWindowOpacityToWindow, resolveLiveAppIcon, closeSettingsWindow: (...args) => closeSettingsWindow(...args), hideSettingsWindow: (...args) => hideSettingsWindow(...args), }); const { createWindow } = mainWindowApi; /** * Create or focus the settings window */ /** * Show + reliably focus a window's renderer. Works around two Windows-specific * Electron quirks that surface when a prewarmed/hidden window is later shown * (see issue #760): * * 1. SetForegroundWindow restrictions: `BrowserWindow.focus()` invoked from * a non-foreground process is often silently rejected by Windows. The * window appears on top but never receives true OS foreground focus, so * `document.hasFocus()` stays false in the renderer. * 2. Chromium suppresses the input caret + keyboard routing whenever * `document.hasFocus()` is false, even if an `` is the active * element. The classic symptom: clicking an input selects/deletes work * but the caret never blinks and typed characters don't appear. * * The alwaysOnTop toggle is the established workaround for (1); explicitly * calling `webContents.focus()` covers (2) so the renderer marks the page as * focused regardless of whether the OS granted foreground. */ const { createSettingsWindowApi } = require("./windowManager/settingsWindow.cjs"); const settingsWindowApi = createSettingsWindowApi({ get settingsWindow() { return settingsWindow; }, set settingsWindow(value) { settingsWindow = value; }, get mainWindow() { return mainWindow; }, get currentTheme() { return currentTheme; }, get isQuitting() { return isQuitting; }, V8_CACHE_OPTIONS, THEME_COLORS, __dirname, process, URL, debugLog, resolveFrontendBackgroundColor, createAppWindowOpenHandler, createExternalOnlyWindowOpenHandler, resolveLiveAppIcon, getDevRendererBaseUrl, applyWindowOpacityToWindow, }); const { restoreWindowInputFocus, showAndFocusWindow, isLiveWindow, resolveSettingsWindowBounds, centerSettingsWindowOnSourceDisplay, openSettingsWindow, closeSettingsWindow, hideSettingsWindow, prewarmSettingsWindow, } = settingsWindowApi; const { createTerminalPopupWindowApi } = require("./windowManager/terminalPopupWindow.cjs"); const terminalPopupWindowApi = createTerminalPopupWindowApi({ get mainWindow() { return mainWindow; }, get currentTheme() { return currentTheme; }, V8_CACHE_OPTIONS, __dirname, resolveFrontendBackgroundColor, createExternalOnlyWindowOpenHandler, getDevRendererBaseUrl, applyWindowOpacityToWindow, sendWhenRendererReady, showAndFocusWindow, resolveSettingsWindowBounds, registerAppContentWindow, unregisterAppContentWindow, notifyAppContentWindowClosed, }); const { openTerminalPopupWindow, closeTerminalPopupWindow, getTerminalPopupWindows, } = terminalPopupWindowApi; /** * Register window control IPC handlers (only once) */ function registerWindowHandlers(ipcMain, nativeTheme) { // Prevent duplicate registration if (handlersRegistered) { return; } handlersRegistered = true; cachedNativeTheme = nativeTheme; ipcMain.handle("netcatty:window:minimize", (event) => { const win = getWindowForIpcEvent(event); if (win && !win.isDestroyed()) { debugLog("window:minimize", { senderId: event?.sender?.id, windowId: win.webContents?.id }); win.minimize(); } }); ipcMain.handle("netcatty:window:maximize", (event) => { const win = getWindowForIpcEvent(event); if (win && !win.isDestroyed()) { debugLog("window:maximize", { senderId: event?.sender?.id, windowId: win.webContents?.id }); if (win.isMaximized()) { win.unmaximize(); return false; } else { win.maximize(); return true; } } return false; }); ipcMain.handle("netcatty:window:close", (event) => { try { if (typeof menuDeps?.isAppLocked === "function" && menuDeps.isAppLocked()) { return { success: false, reason: "app-locked" }; } } catch { // ignore } const win = getWindowForIpcEvent(event); if (win && !win.isDestroyed()) { debugLog("window:close", { senderId: event?.sender?.id, windowId: win.webContents?.id, isMain: win === mainWindow, isSettings: win === settingsWindow, }); win.close(); } }); ipcMain.handle("netcatty:window:isMaximized", (event) => { const win = getWindowForIpcEvent(event); if (win && !win.isDestroyed()) { return win.isMaximized(); } return false; }); ipcMain.handle("netcatty:window:isFullscreen", (event) => { const win = getWindowForIpcEvent(event); if (win && !win.isDestroyed()) { return win.isFullScreen(); } return false; }); ipcMain.handle("netcatty:window:focus", (event) => { const win = getWindowForIpcEvent(event); return restoreWindowInputFocus(win); }); ipcMain.on("netcatty:window:set-terminal-keyboard-focus", (event, payload) => { const win = getWindowForIpcEvent(event); setTerminalKeyboardFocusForWindow(win, payload?.focused === true); }); ipcMain.handle("netcatty:window:setTitle", (event, title) => { const win = getWindowForIpcEvent(event); if (!win || win.isDestroyed()) return false; try { if (typeof menuDeps?.setAppLockWindowTitle === "function") { return menuDeps.setAppLockWindowTitle(win, title) === true; } } catch { // Ignore title-guard failures and keep normal title behavior. } const value = typeof title === "string" ? title.trim() : ""; try { win.setTitle(value || "Netcatty"); return true; } catch { return false; } }); ipcMain.handle("netcatty:setTheme", (_event, theme) => { currentTheme = theme; nativeTheme.themeSource = theme; rebuildApplicationMenu(); const effectiveTheme = theme === "system" ? (nativeTheme?.shouldUseDarkColors ? "dark" : "light") : theme; const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light; forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background)); if (settingsWindow && !settingsWindow.isDestroyed()) { settingsWindow.setBackgroundColor(themeConfig.background); } return true; }); ipcMain.handle("netcatty:setBackgroundColor", (_event, color) => { const normalized = normalizeBackgroundColor(color); if (!normalized) return false; forEachMainWindow((win) => win.setBackgroundColor(normalized)); if (settingsWindow && !settingsWindow.isDestroyed()) { settingsWindow.setBackgroundColor(normalized); } return true; }); ipcMain.handle("netcatty:setWindowOpacity", (_event, opacity) => { applyWindowOpacity(opacity); return true; }); ipcMain.handle("netcatty:setAppIconVariant", (_event, variant) => { const { app, BrowserWindow, nativeImage } = require("electron"); const appIconManager = require("./appIconManager.cjs"); return appIconManager.applyAppIconVariant(variant, { app, BrowserWindow, nativeImage, appPath: app.getAppPath(), isMac: process.platform === "darwin", }); }); ipcMain.handle("netcatty:setLanguage", (_event, language) => { currentLanguage = typeof language === "string" && language.length ? language : "en"; rebuildApplicationMenu(); broadcastLanguageChanged(); return true; }); // Settings window close handler ipcMain.handle("netcatty:settings:close", (event) => { // Prefer hiding the tracked settings window (reused on next open). if (settingsWindow && !settingsWindow.isDestroyed()) { debugLog("settings:close (tracked)", { senderId: event?.sender?.id, settingsId: settingsWindow.webContents?.id, }); hideSettingsWindow(); return true; } // Fallback: close the caller window if it's not the main window. const owner = getWindowForIpcEvent(event); if (owner && owner !== mainWindow && !owner.isDestroyed()) { debugLog("settings:close (owner)", { senderId: event?.sender?.id, ownerId: owner.webContents?.id, isMain: owner === mainWindow, isSettings: owner === settingsWindow, }); try { owner.close(); } catch { // ignore } } return true; }); // Broadcast settings changed to all windows (for cross-window sync) ipcMain.on("netcatty:settings:changed", (event, payload) => { const senderId = event?.sender?.id; // Apply SSH transport idle park TTL in the main process (shared shell/SFTP/ // transfer/port-forward connections). Renderer owns persistence; main owns // the registry timers. try { if (typeof payload?.value === "number" && Number.isFinite(payload.value) && payload.value >= 0) { const { setDefaultTransportIdleTtlMs, STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS, } = require("./sshConnectionPool.cjs"); if (payload?.key === STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS) { setDefaultTransportIdleTtlMs(payload.value); } } } catch { // ignore — pool may be unavailable in tests } // Notify all windows except the sender // Check both isDestroyed() and webContents.isDestroyed() to handle HMR refresh try { forEachMainWindow((win) => { if (!win.webContents.isDestroyed() && win.webContents.id !== senderId) { win.webContents.send("netcatty:settings:changed", payload); } }); if (settingsWindow && !settingsWindow.isDestroyed() && !settingsWindow.webContents.isDestroyed() && settingsWindow.webContents.id !== senderId) { settingsWindow.webContents.send("netcatty:settings:changed", payload); } } catch { // ignore - frame may be disposed during HMR } }); // Renderer reports first meaningful paint/mount; used to avoid initial blank screen. ipcMain.on("netcatty:renderer:ready", (event) => { const wcId = event?.sender?.id; if (!wcId) return; resolveRendererReady(wcId); }); } /** * Build the application menu */ function buildAppMenu(Menu, app, isMac, language = currentLanguage, options = {}) { // Save deps so later language changes can rebuild the menu. menuDeps = { Menu, app, isMac, isAppLocked: typeof options.isAppLocked === "function" ? options.isAppLocked : (typeof menuDeps?.isAppLocked === "function" ? menuDeps.isAppLocked : undefined), setAppLockWindowTitle: typeof options.setAppLockWindowTitle === "function" ? options.setAppLockWindowTitle : (typeof menuDeps?.setAppLockWindowTitle === "function" ? menuDeps.setAppLockWindowTitle : undefined), }; const closeFocusedWindow = (_menuItem, browserWindow, event) => { // Block native Close while app lock is visible so popups/sessions are not // torn down behind the overlay (Codex P2 on 79603979). try { if (typeof menuDeps?.isAppLocked === "function" && menuDeps.isAppLocked()) return; } catch { // ignore } // 只有主窗口/设置窗口会接收 command-close;其他 BrowserWindow 直接关闭。 if (browserWindow && !isMainWindow(browserWindow) && browserWindow !== settingsWindow) { closeBrowserWindow(browserWindow); return; } // Selecting Close Window with the mouse remains an explicit window-close // action. Only the menu accelerator is routed through the configurable // close-tab shortcut in the renderer. if (event?.triggeredByAccelerator === false) { closeBrowserWindow(browserWindow || getMainWindow()); return; } // macOS 的 Cmd+W 先交给渲染层关闭标签页;没有标签页时渲染层再关闭窗口。 requestWindowCommandClose(browserWindow) || requestWindowCommandClose(getMainWindow()); }; const template = [ ...(isMac ? [ { label: app.name, submenu: [ { role: "about" }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" }, ], }, ] : []), { label: tMenu(language, "edit"), submenu: [ { role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }, ], }, { label: tMenu(language, "view"), submenu: [ { label: tMenu(language, "reload"), click: (_, win) => { // Block reload while app lock is up so lock cannot be bypassed. try { if (typeof menuDeps?.isAppLocked === "function" && menuDeps.isAppLocked()) return; } catch { /* ignore */ } if (win) win.reload(); }, }, { label: "Toggle Developer Tools", accelerator: "Alt+CommandOrControl+I", click: (_, win) => { try { if (typeof menuDeps?.isAppLocked === "function" && menuDeps.isAppLocked()) return; } catch { /* ignore */ } if (win && !win.isDestroyed?.()) win.webContents?.toggleDevTools?.(); }, }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }, ], }, ...(() => { let items = []; try { items = pluginApplicationMenuProvider?.() ?? []; } catch {} if (!Array.isArray(items) || items.length === 0) return []; const sortedItems = [...items].sort((left, right) => ( String(left.group ?? "").localeCompare(String(right.group ?? "")) || Number(left.order ?? 0) - Number(right.order ?? 0) || String(left.id ?? "").localeCompare(String(right.id ?? "")) )); const submenu = []; let previousGroup = null; for (const item of sortedItems) { const group = String(item.group ?? ""); if (previousGroup != null && group !== previousGroup) submenu.push({ type: "separator" }); previousGroup = group; submenu.push({ id: item.id, label: item.label, enabled: item.enabled !== false, type: item.checked == null ? "normal" : "checkbox", checked: item.checked === true, accelerator: item.accelerator, icon: item.icon, click: item.click, }); } return [{ label: tMenu(language, "plugins"), submenu, }]; })(), { label: tMenu(language, "window"), submenu: [ { role: "minimize" }, { role: "zoom" }, ...(isMac ? [ { type: "separator" }, { label: tMenu(language, "closeWindow"), accelerator: "CommandOrControl+W", click: closeFocusedWindow, }, { role: "front" }, ] : [{ role: "close" }]), ], }, ]; return Menu.buildFromTemplate(template); } /** * Get the main window instance */ function getMainWindow() { const candidates = getMainWindowList(); if (lastFocusedMainWindow && candidates.includes(lastFocusedMainWindow)) { return lastFocusedMainWindow; } if (mainWindow && candidates.includes(mainWindow)) { return mainWindow; } return candidates.at(-1) || null; } /** * Get the settings window instance */ function getSettingsWindow() { return settingsWindow; } /** * Show the main window and restore reliable keyboard/caret routing (#760, #1722). * Global hotkeys and tray entry points invoke this from non-foreground contexts * where bare BrowserWindow.focus() is silently rejected on Windows. */ function showAndFocusMainWindow(win) { if (!win || win.isDestroyed?.()) return false; if (win.isMinimized?.()) { try { win.restore(); } catch { // ignore } } const restored = restoreWindowInputFocus(win, { show: true }); if (restored) notifyWindowFocusRequested(win); return restored; } /** * Renderer input-focus recovery is only valid after an explicit foreground * request (hotkey, tray, Dock, deep link). Plain BrowserWindow "show" events * can also come from OS window/space transitions and must not steal focus. */ function notifyWindowFocusRequested(win) { if (!win || win.isDestroyed?.()) return; safeSend(win.webContents, "netcatty:window:focus-requested"); } /** * Tell the renderer to dismiss transient overlays before the native hide (#1722). * Must run before BrowserWindow.hide(), not from Electron's post-hide event. */ function notifyWindowWillHide(win) { if (!win || win.isDestroyed?.()) return; safeSend(win.webContents, "netcatty:window:will-hide"); } module.exports = { createWindow, openSettingsWindow, closeSettingsWindow, openTerminalPopupWindow, closeTerminalPopupWindow, getTerminalPopupWindows, prewarmSettingsWindow, buildAppMenu, getCurrentLanguage, setPluginApplicationMenuProvider, getMainWindow, getMainWindows: getMainWindowList, getAppContentWindows: getAppContentWindowList, getDirtyEditorWindows: getDirtyEditorWindowList, getMainWindowCount, isMainWindow, registerMainWindow, unregisterMainWindow, registerAppContentWindow, unregisterAppContentWindow, setAppContentWindowClosedHandler, getSettingsWindow, isWindowUsable, registerWindowHandlers, restoreWindowInputFocus, showAndFocusMainWindow, notifyWindowFocusRequested, notifyWindowWillHide, requestWindowCommandClose, shouldCloseWindowFromInput, WINDOW_COMMAND_CLOSE_CHANNEL, waitForRendererReady, sendWhenRendererReady, setIsQuitting, getIsQuitting, setQuittingForUpdate, isQuittingForUpdate, openFallbackBrowser, tryOpenExternalWithFallback, resolveSettingsWindowBounds, THEME_COLORS, clampWindowOpacity, applyWindowOpacity, applyWindowOpacityToWindow, };