Files
NetMesh/electron/bridges/windowManager/mainWindow.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

589 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable no-undef */
const {
windowsFramelessContentChromeOptions,
} = require("./windowsWindowChrome.cjs");
const { attachDisplayRecovery } = require("./displayRecovery.cjs");
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
function setTerminalKeyboardFocusForWindow(win, focused) {
if (!win || win.isDestroyed?.() || !win.webContents) return false;
const isFocused = focused === true;
try {
win[TERMINAL_KEYBOARD_FOCUS] = isFocused;
win.webContents.setIgnoreMenuShortcuts?.(isFocused);
return true;
} catch {
return false;
}
}
function hasTerminalKeyboardFocus(win) {
return win?.[TERMINAL_KEYBOARD_FOCUS] === true;
}
function createMainWindowApi(ctx) {
with (ctx) {
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
const {
preload,
devServerUrl,
isDev,
appIcon,
isMac,
onRegisterBridge,
electronDir,
route,
onAppContentWindowClosed,
registerAsMainWindow = true,
persistWindowState = registerAsMainWindow,
registerAsAppContentWindow = true,
startHidden = false,
} = options;
const rendererHash = typeof route === "string" && route.trim()
? `#/${route.trim().replace(/^#?\/*/, "")}`
: "";
const CHROMIUM_ZOOM_FACTORS = [
0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5,
];
const isPrimaryZoomInEqualInput = (input) => {
if (input?.type !== "keyDown") return false;
if (input.alt) return false;
const hasPrimaryModifier = isMac
? Boolean(input.meta) && !input.control
: Boolean(input.control) && !input.meta;
if (!hasPrimaryModifier || input.shift) return false;
return String(input.key || "") === "=";
};
const isPrimaryZoomOutMinusInput = (input) => {
if (input?.type !== "keyDown") return false;
if (input.alt) return false;
const hasPrimaryModifier = isMac
? Boolean(input.meta) && !input.control
: Boolean(input.control) && !input.meta;
if (!hasPrimaryModifier || input.shift) return false;
return String(input.key || "") === "-";
};
const isPrimaryResetZoomInput = (input) => {
if (input?.type !== "keyDown") return false;
if (input.alt) return false;
const hasPrimaryModifier = isMac
? Boolean(input.meta) && !input.control
: Boolean(input.control) && !input.meta;
if (!hasPrimaryModifier || input.shift) return false;
return String(input.key || "") === "0";
};
const adjustWindowZoom = (mode) => {
const webContents = win?.webContents;
if (!webContents || webContents.isDestroyed?.()) return false;
const currentFactor = Number(webContents.getZoomFactor?.());
const safeCurrentFactor = Number.isFinite(currentFactor) ? currentFactor : 1;
const nextFactor = mode === "in"
? CHROMIUM_ZOOM_FACTORS.find((factor) => factor > safeCurrentFactor + 0.0001)
: mode === "out"
? [...CHROMIUM_ZOOM_FACTORS].reverse().find((factor) => factor < safeCurrentFactor - 0.0001)
: 1;
if (!nextFactor) return false;
try {
webContents.setZoomFactor?.(nextFactor);
return true;
} catch {
return false;
}
};
// Store app reference for window state persistence
electronApp = app;
const osTheme = nativeTheme?.shouldUseDarkColors ? "dark" : "light";
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
const backgroundColor = frontendBackground || "#1a1a1a";
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
// Load saved window state
const savedState = persistWindowState ? loadWindowState() : null;
let windowBounds = {
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
};
if (savedState) {
// Use saved dimensions, but clamp to the minimum so a previously
// shrunk window from an older build cannot start below the minimum.
windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH);
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
try {
// Check if the saved position is within any available display
const displays = screen?.getAllDisplays?.() || [];
const isPositionVisible = displays.some((display) => {
const { x, y, width, height } = display.bounds;
// Check if at least part of the window would be visible on this display
return (
savedState.x < x + width &&
savedState.x + savedState.width > x &&
savedState.y < y + height &&
savedState.y + savedState.height > y
);
});
if (isPositionVisible) {
windowBounds.x = savedState.x;
windowBounds.y = savedState.y;
}
} catch {
// Ignore screen check errors, just don't set position
}
}
}
const windowsChrome = !isMac ? windowsFramelessContentChromeOptions() : {};
const win = new BrowserWindow({
...windowBounds,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
backgroundColor,
icon: appIcon,
show: false,
frame: isMac,
titleBarStyle: isMac ? "hiddenInset" : undefined,
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
...windowsChrome,
webPreferences: {
preload,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
backgroundThrottling: false,
v8CacheOptions: V8_CACHE_OPTIONS,
},
});
if (registerAsMainWindow) {
if (typeof registerMainWindow === "function") {
registerMainWindow(win);
} else {
mainWindow = win;
}
} else if (registerAsAppContentWindow && typeof registerAppContentWindow === "function") {
registerAppContentWindow(win, { queryDirtyEditors: true });
}
// Multi-monitor recovery (#3244): Windows lock/sleep can temporarily
// tear down a secondary display and the OS relocates the window onto
// the primary display. When the display comes back, put the window back
// where the user left it.
let detachDisplayRecovery = null;
try {
detachDisplayRecovery = attachDisplayRecovery({ win, screen });
} catch {
detachDisplayRecovery = null;
}
// Clear reference when the main window is destroyed
win.on('closed', () => {
try {
detachDisplayRecovery?.();
} catch {
// ignore
}
try {
if (win?.webContents?.id) {
unhealthyWebContentsIds.delete(win.webContents.id);
rendererReadySeenByWebContentsId.delete(win.webContents.id);
}
} catch {
// ignore
}
if (registerAsMainWindow) {
if (typeof unregisterMainWindow === "function") {
unregisterMainWindow(win);
} else if (mainWindow === win) {
mainWindow = null;
}
} else if (registerAsAppContentWindow && typeof unregisterAppContentWindow === "function") {
unregisterAppContentWindow(win);
}
if (registerAsAppContentWindow) {
try {
if (typeof notifyAppContentWindowClosed === "function") {
notifyAppContentWindowClosed(win);
} else {
onAppContentWindowClosed?.(win);
}
} catch {
// The application-level close fallback must not disrupt teardown.
}
}
});
// Log renderer crashes for diagnostics (skip normal clean exits)
win.webContents.on("render-process-gone", (_event, details) => {
if (details?.reason === "clean-exit") return;
try {
if (win.webContents?.id) {
unhealthyWebContentsIds.add(win.webContents.id);
}
} catch {
// ignore
}
try {
const crashLogBridge = require("./crashLogBridge.cjs");
crashLogBridge.captureError("render-process-gone", new Error(
`Renderer process gone: reason=${details?.reason}, exitCode=${details?.exitCode}`
), { reason: details?.reason, exitCode: details?.exitCode });
} catch {}
console.error("[WindowManager] Renderer process gone:", details);
});
// Prevent top-level navigation away from the app origin. If a remote origin ever
// loads in a privileged window (with preload), it can become an RCE vector.
const allowedOrigins = new Set(["app://netcatty"]);
if (isDev && devServerUrl) {
try {
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
} catch {
// ignore invalid dev server URL
}
}
const isAllowedTopLevelUrl = (targetUrl) => {
try {
const parsedUrl = new URL(String(targetUrl));
if (parsedUrl.protocol === "app:" && parsedUrl.host === "netcatty") return true;
return allowedOrigins.has(parsedUrl.origin);
} catch {
return false;
}
};
win.webContents.on("did-start-navigation", (_event, targetUrl, isInPlace, isMainFrame) => {
if (isMainFrame === false || isInPlace === true || !isAllowedTopLevelUrl(targetUrl)) return;
try {
if (typeof clearRendererReadyForWebContents === "function") {
clearRendererReadyForWebContents(win.webContents?.id);
}
} catch {
// ignore
}
});
const blockUntrustedNavigation = (event, targetUrl) => {
if (isAllowedTopLevelUrl(targetUrl)) return;
try {
event.preventDefault();
} catch {
// ignore
}
debugLog("Blocked navigation to untrusted origin", { targetUrl });
};
win.webContents.on("will-navigate", blockUntrustedNavigation);
win.webContents.on("will-redirect", blockUntrustedNavigation);
// Prevent Chromium from consuming Alt+Arrow as browser back/forward navigation.
// Terminal apps need these keys to pass through to the remote shell (e.g., byobu, tmux).
// Using setIgnoreMenuShortcuts lets the keydown still reach the page (xterm.js)
// while preventing Chromium's built-in shortcuts from triggering.
win.webContents.on("before-input-event", (event, input) => {
if (isMac && shouldCloseWindowFromInput(input)) {
event.preventDefault();
requestWindowCommandClose(win);
return;
}
const isTerminalFontShortcut =
isPrimaryZoomInEqualInput(input)
|| isPrimaryZoomOutMinusInput(input)
|| isPrimaryResetZoomInput(input);
if (hasTerminalKeyboardFocus(win) && isTerminalFontShortcut) {
win.webContents.setIgnoreMenuShortcuts(true);
return;
}
if (isPrimaryZoomInEqualInput(input) && adjustWindowZoom("in")) {
event.preventDefault();
return;
}
if (isPrimaryZoomOutMinusInput(input) && adjustWindowZoom("out")) {
event.preventDefault();
return;
}
if (isPrimaryResetZoomInput(input) && adjustWindowZoom("reset")) {
event.preventDefault();
return;
}
if (input.alt && !input.control && !input.meta) {
if (input.key === "ArrowLeft" || input.key === "ArrowRight") {
win.webContents.setIgnoreMenuShortcuts(true);
return;
}
}
win.webContents.setIgnoreMenuShortcuts(false);
});
// Restore maximized state if it was saved
if (savedState?.isMaximized && !savedState?.isFullScreen) {
win.once("ready-to-show", () => {
try {
win.maximize();
} catch {
// ignore
}
});
}
// Track window bounds for saving (use last non-maximized/non-fullscreen bounds)
let lastNormalBounds = null;
let saveStateTimer = null;
let thisWindowCloseRequested = false;
let dirtyEditorCloseConfirmed = false;
const updateNormalBounds = () => {
if (!win.isDestroyed() && !win.isMaximized() && !win.isFullScreen()) {
lastNormalBounds = win.getBounds();
}
};
const scheduleSaveState = () => {
if (!persistWindowState) return;
if (saveStateTimer) clearTimeout(saveStateTimer);
saveStateTimer = setTimeout(() => {
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) queueWindowStateSave(state);
}, 500);
};
// Update normal bounds on resize/move when not maximized/fullscreen
win.on("resize", () => {
updateNormalBounds();
scheduleSaveState();
});
win.on("move", () => {
updateNormalBounds();
scheduleSaveState();
});
win.on("maximize", scheduleSaveState);
win.on("unmaximize", () => {
updateNormalBounds();
scheduleSaveState();
});
const queryDirtyEditorsBeforeClose = (event, { markCloseRequested = false } = {}) => {
event.preventDefault();
if (markCloseRequested) {
thisWindowCloseRequested = true;
}
const dirtyEditorQuery = typeof queryDirtyEditors === "function"
? queryDirtyEditors(win.webContents, 5000, { ipcMain: electronModule.ipcMain })
: false;
Promise.resolve(dirtyEditorQuery)
.then((hasDirty) => {
if (hasDirty) {
thisWindowCloseRequested = false;
return;
}
dirtyEditorCloseConfirmed = true;
try {
win.close();
} catch {
// ignore
}
})
.catch(() => {
dirtyEditorCloseConfirmed = true;
try {
win.close();
} catch {
// ignore
}
});
};
// Save state when window is about to close
win.on("close", (event) => {
if (!registerAsMainWindow && registerAsAppContentWindow && !isQuitting && !dirtyEditorCloseConfirmed) {
queryDirtyEditorsBeforeClose(event, { markCloseRequested: true });
return;
}
// Check if close-to-tray is enabled
const trackedMainWindowCount = typeof getMainWindowCount === "function" ? getMainWindowCount() : 1;
if (registerAsMainWindow && trackedMainWindowCount <= 1 && !isQuitting && getGlobalShortcutBridge().handleWindowClose(event, win)) {
// Window was hidden to tray - save state before returning
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = persistWindowState ? getWindowBoundsState(win, lastNormalBounds) : null;
if (state) saveWindowStateSync(state);
hideSettingsWindow();
return;
}
if (registerAsMainWindow && registerAsAppContentWindow && !isQuitting && !dirtyEditorCloseConfirmed) {
queryDirtyEditorsBeforeClose(event);
return;
}
if (thisWindowCloseRequested) {
return;
}
thisWindowCloseRequested = true;
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = persistWindowState ? getWindowBoundsState(win, lastNormalBounds) : null;
if (pendingWindowStateWrite) {
event.preventDefault();
if (state) queuedWindowState = state;
pendingWindowStateWrite
.catch(() => {
// ignore async write errors before closing
})
.finally(() => {
const finalState = persistWindowState ? getWindowBoundsState(win, lastNormalBounds) : null;
if (finalState) saveWindowStateSync(finalState);
if (registerAsMainWindow) closeSettingsWindow();
try {
win.close();
} catch {
// ignore
}
});
return;
}
if (state) saveWindowStateSync(state);
if (registerAsMainWindow) closeSettingsWindow();
});
const safeSend = (channel, ...args) => {
try {
if (!win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) {
win.webContents.send(channel, ...args);
}
} catch {
// Render frame disposed during HMR / reload safe to ignore
}
};
win.on("enter-full-screen", () => {
safeSend("netcatty:window:fullscreen-changed", true);
scheduleSaveState();
});
win.on("leave-full-screen", () => {
safeSend("netcatty:window:fullscreen-changed", false);
updateNormalBounds();
scheduleSaveState();
});
win.on("show", () => {
safeSend("netcatty:window:shown");
if (startHidden) {
// The hidden-launch tray pin exists so a --hidden cold start is
// never a windowless, trayless zombie; once this window is
// actually visible that concern no longer applies, so hand tray
// lifetime back to the user's normal close-to-tray preference.
try {
getGlobalShortcutBridge().releaseHiddenLaunchTrayPin?.();
} catch (err) {
console.warn("[MainWindow] Failed to release hidden-launch tray pin:", err?.message || err);
}
}
});
// Ensure native background matches frontend background, even before first paint.
try {
win.setBackgroundColor(backgroundColor);
} catch {
// ignore
}
applyWindowOpacityToWindow(win);
// Defer show until renderer is ready; use fallback timeout to avoid keeping window hidden forever.
// Production gets a shorter timeout since the splash screen provides visual feedback.
setupDeferredShow(win, { timeoutMs: isDev ? 3000 : 1500, startHidden });
win.webContents.on("did-create-window", (childWindow) => {
try {
childWindow.setMenuBarVisibility(false);
childWindow.autoHideMenuBar = true;
childWindow.removeMenu();
} catch {
// ignore
}
try {
const iconPath = resolveLiveAppIcon(appIcon);
if (iconPath && childWindow.setIcon) childWindow.setIcon(iconPath);
} catch {
// ignore
}
// Never allow chained popups from remote content windows.
try {
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
} catch {
// ignore
}
attachOAuthLoadingOverlay(childWindow);
});
win.webContents.setWindowOpenHandler(
createAppWindowOpenHandler(shell, {
backgroundColor,
appIcon,
getAppIcon: () => resolveLiveAppIcon(appIcon),
})
);
// Register window control handlers
registerWindowHandlers(electronModule.ipcMain, nativeTheme);
// Register IPC handlers BEFORE loading any URL so the renderer never
// calls a handler that hasn't been registered yet.
onRegisterBridge?.(win);
if (startHidden) {
// Belt-and-suspenders: guarantee a tray icon exists as soon as
// bridges (and electronModule) are ready, and keep it pinned
// (independent of the user's separate close-to-tray preference)
// until this window is actually shown — see releaseHiddenLaunchTrayPin
// in the "show" handler below.
try {
getGlobalShortcutBridge().pinTrayForHiddenLaunch?.();
} catch (err) {
console.warn("[MainWindow] Failed to create tray for hidden launch:", err?.message || err);
}
}
if (isDev) {
try {
await win.loadURL(`${getDevRendererBaseUrl(devServerUrl)}${rendererHash}`);
win.webContents.openDevTools({ mode: "detach" });
return win;
} catch (e) {
console.warn("Dev server not reachable, falling back to bundled dist.", e);
}
}
// Production mode - load via custom protocol.
await win.loadURL(`app://netcatty/index.html${rendererHash}`);
return win;
}
return { createWindow };
}
}
module.exports = {
createMainWindowApi,
setTerminalKeyboardFocusForWindow,
};