/**
* 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,
};