1525 lines
46 KiB
JavaScript
1525 lines
46 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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 = '<div class="spinner"></div><div class="label">Loading...</div>';
|
|||
|
|
(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 `<input>` 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,
|
|||
|
|
};
|