Add duplicate tab to new window
Adds a tab context menu action to duplicate a terminal into an independent peer window, with per-window active-tab titles and multi-window lifecycle safeguards.
This commit is contained in:
@@ -237,23 +237,24 @@ function setQuittingForUpdate(enabled) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The webContents of the main window, or null if there's no usable main window
|
||||
* to talk to. Used by the install handler to (a) ask the renderer about unsaved
|
||||
* editors before committing to a quit, and (b) tell it to surface a "save
|
||||
* first" notice. Targets the main window specifically (not getAllWindows()[0])
|
||||
* so we never query the tray panel / settings window, whose renderers don't
|
||||
* participate in the dirty-editor protocol.
|
||||
* The webContents for usable main windows. Used by the install handler to ask
|
||||
* every renderer that can own editor tabs about unsaved work before committing
|
||||
* to a quit. Targets registered main windows specifically (not
|
||||
* getAllWindows()[0]) so we never query tray/settings windows, whose renderers
|
||||
* don't participate in the dirty-editor protocol.
|
||||
*/
|
||||
function getMainWebContents() {
|
||||
function getMainWebContentsList() {
|
||||
try {
|
||||
const windowManager = require("./windowManager.cjs");
|
||||
const win = windowManager.getMainWindow?.();
|
||||
if (!win || win.isDestroyed?.()) return null;
|
||||
const wc = win.webContents;
|
||||
if (!wc || wc.isDestroyed?.() || wc.isCrashed?.()) return null;
|
||||
return wc;
|
||||
const windows = typeof windowManager.getMainWindows === "function"
|
||||
? windowManager.getMainWindows()
|
||||
: [windowManager.getMainWindow?.()].filter(Boolean);
|
||||
return windows
|
||||
.filter((win) => win && !win.isDestroyed?.())
|
||||
.map((win) => win.webContents)
|
||||
.filter((wc) => wc && !wc.isDestroyed?.() && !wc.isCrashed?.());
|
||||
} catch {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,15 +486,18 @@ function registerHandlers(ipcMain) {
|
||||
// false, so it commits the quit and silently drops unsaved SFTP edits.
|
||||
//
|
||||
// So we ask the renderer here, while the window and renderer are still
|
||||
// alive. If there's unsaved work, abort the install (don't touch the
|
||||
// quitting flags, don't quitAndInstall) and tell the renderer to prompt the
|
||||
// user to save; they can click "Restart Now" again afterwards. If the main
|
||||
// window isn't reachable (no window / crashed renderer) there's no user to
|
||||
// ask, so we install directly — matching the before-quit fail-open path.
|
||||
const mainWc = getMainWebContents();
|
||||
if (mainWc) {
|
||||
const hasDirty = await queryDirtyEditorsSafe(mainWc, ipcMain);
|
||||
if (hasDirty) {
|
||||
// alive. If there's unsaved work in any main window, abort the install
|
||||
// (don't touch the quitting flags, don't quitAndInstall) and tell the
|
||||
// renderer to prompt the user to save; they can click "Restart Now" again
|
||||
// afterwards. If no main window is reachable (no window / crashed
|
||||
// renderer) there's no user to ask, so we install directly — matching the
|
||||
// before-quit fail-open path.
|
||||
const mainWebContents = getMainWebContentsList();
|
||||
if (mainWebContents.length > 0) {
|
||||
const dirtyResults = await Promise.all(
|
||||
mainWebContents.map((webContents) => queryDirtyEditorsSafe(webContents, ipcMain)),
|
||||
);
|
||||
if (dirtyResults.some(Boolean)) {
|
||||
// Broadcast so the notice reaches whichever window the user clicked
|
||||
// from (main or Settings), not just the main window we queried.
|
||||
notifyNeedsSave();
|
||||
|
||||
@@ -158,6 +158,50 @@ function makeWindowManagerWithMainWindow() {
|
||||
},
|
||||
};
|
||||
},
|
||||
getMainWindows() {
|
||||
return [this.getMainWindow()];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeWindowManagerWithMainWindows(count) {
|
||||
const windows = Array.from({ length: count }, (_unused, index) => {
|
||||
const sentChannels = [];
|
||||
const webContents = {
|
||||
id: index + 1,
|
||||
sentChannels,
|
||||
send(channel) {
|
||||
sentChannels.push(channel);
|
||||
},
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
return {
|
||||
webContents,
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
calls: [],
|
||||
windows,
|
||||
setQuittingForUpdate(value) {
|
||||
this.calls.push(value);
|
||||
},
|
||||
isQuittingForUpdate() {
|
||||
return this.calls[this.calls.length - 1] === true;
|
||||
},
|
||||
getMainWindow() {
|
||||
return windows[0] || null;
|
||||
},
|
||||
getMainWindows() {
|
||||
return windows;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,6 +439,49 @@ test("install handler aborts and notifies when the renderer reports dirty editor
|
||||
);
|
||||
});
|
||||
|
||||
test("install handler checks every main window before installing", async () => {
|
||||
const order = [];
|
||||
const autoUpdater = {
|
||||
autoDownload: true,
|
||||
autoInstallOnAppQuit: false,
|
||||
logger: undefined,
|
||||
on() {},
|
||||
quitAndInstall() {
|
||||
order.push("quitAndInstall");
|
||||
},
|
||||
};
|
||||
const fakeWindowManager = makeWindowManagerWithMainWindows(2);
|
||||
const queriedWebContents = [];
|
||||
const fakeDirtyEditorGuard = {
|
||||
queryDirtyEditors(webContents) {
|
||||
order.push(`queryDirtyEditors:${webContents.id}`);
|
||||
queriedWebContents.push(webContents);
|
||||
return Promise.resolve(webContents.id === 2);
|
||||
},
|
||||
};
|
||||
const win = makeBroadcastWindow();
|
||||
|
||||
await withMocks(
|
||||
{
|
||||
autoUpdater,
|
||||
windowManager: fakeWindowManager,
|
||||
dirtyEditorGuard: fakeDirtyEditorGuard,
|
||||
browserWindows: [win],
|
||||
},
|
||||
async ({ bridge, fakeGlobalShortcut }) => {
|
||||
const ipcMain = makeIpcMain();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
await ipcMain.invoke("netcatty:update:install");
|
||||
|
||||
assert.deepEqual(queriedWebContents, fakeWindowManager.windows.map((window) => window.webContents));
|
||||
assert.equal(order.includes("quitAndInstall"), false);
|
||||
assert.deepEqual(fakeWindowManager.calls, []);
|
||||
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
|
||||
assert.equal(win.sentChannels.includes("netcatty:update:needs-save"), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("install handler proceeds to quitAndInstall when there are no dirty editors", async () => {
|
||||
const order = [];
|
||||
const autoUpdater = {
|
||||
|
||||
@@ -28,6 +28,8 @@ const THEME_COLORS = {
|
||||
|
||||
// State
|
||||
let mainWindow = null;
|
||||
const mainWindows = new Set();
|
||||
let lastFocusedMainWindow = null;
|
||||
let settingsWindow = null;
|
||||
let currentTheme = "light";
|
||||
let currentLanguage = "en";
|
||||
@@ -268,7 +270,68 @@ function getWindowForIpcEvent(event) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return mainWindow;
|
||||
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 getMainWindowList() {
|
||||
pruneMainWindows();
|
||||
return Array.from(mainWindows).filter((win) => isWindowUsable(win));
|
||||
}
|
||||
|
||||
function rememberMainWindow(win) {
|
||||
if (!win || win.isDestroyed?.()) return;
|
||||
lastFocusedMainWindow = win;
|
||||
mainWindow = win;
|
||||
}
|
||||
|
||||
function registerMainWindow(win) {
|
||||
if (!win || win.isDestroyed?.()) return;
|
||||
mainWindows.add(win);
|
||||
rememberMainWindow(win);
|
||||
try {
|
||||
win.on("focus", () => rememberMainWindow(win));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterMainWindow(win) {
|
||||
if (!win) return;
|
||||
mainWindows.delete(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 getMainWindowCount() {
|
||||
return getMainWindowList().length;
|
||||
}
|
||||
|
||||
function isMainWindow(win) {
|
||||
if (!win || win.isDestroyed?.()) return false;
|
||||
pruneMainWindows();
|
||||
return mainWindows.has(win);
|
||||
}
|
||||
|
||||
function closeBrowserWindow(win) {
|
||||
@@ -295,9 +358,7 @@ function requestWindowCommandClose(win) {
|
||||
|
||||
function broadcastLanguageChanged() {
|
||||
try {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents?.send?.("netcatty:languageChanged", currentLanguage);
|
||||
}
|
||||
forEachMainWindow((win) => win.webContents?.send?.("netcatty:languageChanged", currentLanguage));
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.webContents?.send?.("netcatty:languageChanged", currentLanguage);
|
||||
}
|
||||
@@ -718,6 +779,9 @@ const mainWindowApi = createMainWindowApi({
|
||||
registerWindowHandlers,
|
||||
requestWindowCommandClose,
|
||||
shouldCloseWindowFromInput,
|
||||
registerMainWindow,
|
||||
unregisterMainWindow,
|
||||
getMainWindowCount,
|
||||
closeSettingsWindow: (...args) => closeSettingsWindow(...args),
|
||||
hideSettingsWindow: (...args) => hideSettingsWindow(...args),
|
||||
});
|
||||
@@ -841,6 +905,18 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
return restoreWindowInputFocus(win);
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:window:setTitle", (event, title) => {
|
||||
const win = getWindowForIpcEvent(event);
|
||||
if (!win || win.isDestroyed()) return false;
|
||||
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;
|
||||
@@ -848,9 +924,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
? (nativeTheme?.shouldUseDarkColors ? "dark" : "light")
|
||||
: theme;
|
||||
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.setBackgroundColor(themeConfig.background);
|
||||
}
|
||||
forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background));
|
||||
// Also update settings window if open
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.setBackgroundColor(themeConfig.background);
|
||||
@@ -861,9 +935,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
ipcMain.handle("netcatty:setBackgroundColor", (_event, color) => {
|
||||
const normalized = normalizeBackgroundColor(color);
|
||||
if (!normalized) return false;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.setBackgroundColor(normalized);
|
||||
}
|
||||
forEachMainWindow((win) => win.setBackgroundColor(normalized));
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.setBackgroundColor(normalized);
|
||||
}
|
||||
@@ -913,9 +985,11 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
// Notify all windows except the sender
|
||||
// Check both isDestroyed() and webContents.isDestroyed() to handle HMR refresh
|
||||
try {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed() && mainWindow.webContents.id !== senderId) {
|
||||
mainWindow.webContents.send("netcatty:settings:changed", payload);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -940,13 +1014,13 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
|
||||
menuDeps = { Menu, app, isMac };
|
||||
const closeFocusedWindow = (_menuItem, browserWindow) => {
|
||||
// 只有主窗口/设置窗口会接收 command-close;其他 BrowserWindow 直接关闭。
|
||||
if (browserWindow && browserWindow !== mainWindow && browserWindow !== settingsWindow) {
|
||||
if (browserWindow && !isMainWindow(browserWindow) && browserWindow !== settingsWindow) {
|
||||
closeBrowserWindow(browserWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS 的 Cmd+W 先交给渲染层关闭标签页;没有标签页时渲染层再关闭窗口。
|
||||
requestWindowCommandClose(browserWindow) || requestWindowCommandClose(mainWindow);
|
||||
requestWindowCommandClose(browserWindow) || requestWindowCommandClose(getMainWindow());
|
||||
};
|
||||
const template = [
|
||||
...(isMac
|
||||
@@ -1018,7 +1092,14 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
|
||||
* Get the main window instance
|
||||
*/
|
||||
function getMainWindow() {
|
||||
return mainWindow;
|
||||
const candidates = getMainWindowList();
|
||||
if (lastFocusedMainWindow && candidates.includes(lastFocusedMainWindow)) {
|
||||
return lastFocusedMainWindow;
|
||||
}
|
||||
if (mainWindow && candidates.includes(mainWindow)) {
|
||||
return mainWindow;
|
||||
}
|
||||
return candidates.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1035,6 +1116,11 @@ module.exports = {
|
||||
prewarmSettingsWindow,
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
getMainWindows: getMainWindowList,
|
||||
getMainWindowCount,
|
||||
isMainWindow,
|
||||
registerMainWindow,
|
||||
unregisterMainWindow,
|
||||
getSettingsWindow,
|
||||
isWindowUsable,
|
||||
registerWindowHandlers,
|
||||
|
||||
@@ -72,7 +72,11 @@ function createMainWindowApi(ctx) {
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow = win;
|
||||
if (typeof registerMainWindow === "function") {
|
||||
registerMainWindow(win);
|
||||
} else {
|
||||
mainWindow = win;
|
||||
}
|
||||
|
||||
// Clear reference when the main window is destroyed
|
||||
win.on('closed', () => {
|
||||
@@ -84,7 +88,11 @@ function createMainWindowApi(ctx) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (mainWindow === win) mainWindow = null;
|
||||
if (typeof unregisterMainWindow === "function") {
|
||||
unregisterMainWindow(win);
|
||||
} else if (mainWindow === win) {
|
||||
mainWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Log renderer crashes for diagnostics (skip normal clean exits)
|
||||
@@ -169,6 +177,7 @@ function createMainWindowApi(ctx) {
|
||||
// Track window bounds for saving (use last non-maximized/non-fullscreen bounds)
|
||||
let lastNormalBounds = null;
|
||||
let saveStateTimer = null;
|
||||
let thisWindowCloseRequested = false;
|
||||
|
||||
const updateNormalBounds = () => {
|
||||
if (!win.isDestroyed() && !win.isMaximized() && !win.isFullScreen()) {
|
||||
@@ -204,7 +213,8 @@ function createMainWindowApi(ctx) {
|
||||
// Save state when window is about to close
|
||||
win.on("close", (event) => {
|
||||
// Check if close-to-tray is enabled
|
||||
if (!isQuitting && getGlobalShortcutBridge().handleWindowClose(event, win)) {
|
||||
const trackedMainWindowCount = typeof getMainWindowCount === "function" ? getMainWindowCount() : 1;
|
||||
if (trackedMainWindowCount <= 1 && !isQuitting && getGlobalShortcutBridge().handleWindowClose(event, win)) {
|
||||
// Window was hidden to tray - save state before returning
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
@@ -213,10 +223,10 @@ function createMainWindowApi(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (windowStateCloseRequested) {
|
||||
if (thisWindowCloseRequested) {
|
||||
return;
|
||||
}
|
||||
windowStateCloseRequested = true;
|
||||
thisWindowCloseRequested = true;
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (pendingWindowStateWrite) {
|
||||
|
||||
@@ -4,11 +4,13 @@ const assert = require("node:assert/strict");
|
||||
const {
|
||||
buildAppMenu,
|
||||
isWindowUsable,
|
||||
registerMainWindow,
|
||||
registerWindowHandlers,
|
||||
resolveSettingsWindowBounds,
|
||||
restoreWindowInputFocus,
|
||||
requestWindowCommandClose,
|
||||
shouldCloseWindowFromInput,
|
||||
unregisterMainWindow,
|
||||
} = require("./windowManager.cjs");
|
||||
const { createMainWindowApi } = require("./windowManager/mainWindow.cjs");
|
||||
|
||||
@@ -207,6 +209,53 @@ test("buildAppMenu closes a non-app window directly when Cmd+W is invoked", () =
|
||||
assert.deepEqual(calls, ["close"]);
|
||||
});
|
||||
|
||||
test("buildAppMenu sends Cmd+W to any registered main window renderer", () => {
|
||||
let capturedTemplate = null;
|
||||
const Menu = {
|
||||
buildFromTemplate(template) {
|
||||
capturedTemplate = template;
|
||||
return { template };
|
||||
},
|
||||
};
|
||||
|
||||
const calls = [];
|
||||
const firstMainWindow = {
|
||||
isDestroyed() { return false; },
|
||||
on() {},
|
||||
webContents: {
|
||||
isDestroyed() { return false; },
|
||||
send(channel) {
|
||||
calls.push(`first:${channel}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
const secondMainWindow = {
|
||||
isDestroyed() { return false; },
|
||||
on() {},
|
||||
webContents: {
|
||||
isDestroyed() { return false; },
|
||||
send(channel) {
|
||||
calls.push(`second:${channel}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMainWindow(firstMainWindow);
|
||||
registerMainWindow(secondMainWindow);
|
||||
try {
|
||||
buildAppMenu(Menu, { name: "Netcatty" }, true);
|
||||
const windowMenu = capturedTemplate.find((item) => item.label === "Window");
|
||||
const closeItem = windowMenu.submenu.find((item) => item.accelerator === "CommandOrControl+W");
|
||||
|
||||
closeItem.click(null, firstMainWindow);
|
||||
|
||||
assert.deepEqual(calls, ["first:netcatty:window:command-close"]);
|
||||
} finally {
|
||||
unregisterMainWindow(firstMainWindow);
|
||||
unregisterMainWindow(secondMainWindow);
|
||||
}
|
||||
});
|
||||
|
||||
test("requestWindowCommandClose sends command-close to renderer-capable windows", () => {
|
||||
const sentChannels = [];
|
||||
const win = {
|
||||
@@ -342,7 +391,237 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
|
||||
assert.equal(commandCloseRequests.length, 1);
|
||||
});
|
||||
|
||||
test("window focus IPC handler focuses the sender owner window", async () => {
|
||||
test("createWindow registers each main window as an independent app window", async () => {
|
||||
const registered = [];
|
||||
const unregistered = [];
|
||||
|
||||
class BrowserWindowStub {
|
||||
constructor() {
|
||||
this.webContents = {
|
||||
id: registered.length + 1,
|
||||
on() {},
|
||||
once() {},
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
setIgnoreMenuShortcuts() {},
|
||||
setWindowOpenHandler() {},
|
||||
openDevTools() {},
|
||||
};
|
||||
}
|
||||
|
||||
on(channel, handler) {
|
||||
if (channel === "closed") this._closedHandler = handler;
|
||||
}
|
||||
once() {}
|
||||
isDestroyed() { return false; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
|
||||
setBackgroundColor() {}
|
||||
async loadURL() {}
|
||||
close() {
|
||||
this._closedHandler?.();
|
||||
}
|
||||
}
|
||||
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null,
|
||||
electronApp: null,
|
||||
currentTheme: "light",
|
||||
isQuitting: false,
|
||||
pendingWindowStateWrite: null,
|
||||
queuedWindowState: null,
|
||||
windowStateCloseRequested: false,
|
||||
DEFAULT_WINDOW_WIDTH: 1400,
|
||||
DEFAULT_WINDOW_HEIGHT: 900,
|
||||
MIN_WINDOW_WIDTH: 1100,
|
||||
MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: "bypassHeatCheck",
|
||||
THEME_COLORS: { light: { background: "#fff" } },
|
||||
unhealthyWebContentsIds: new Set(),
|
||||
rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname,
|
||||
URL,
|
||||
require,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
getGlobalShortcutBridge() {
|
||||
return { handleWindowClose: () => false };
|
||||
},
|
||||
debugLog() {},
|
||||
resolveFrontendBackgroundColor() { return null; },
|
||||
loadWindowState() { return null; },
|
||||
getDevRendererBaseUrl(url) { return url; },
|
||||
getWindowBoundsState() { return null; },
|
||||
queueWindowStateSave() {},
|
||||
saveWindowStateSync() {},
|
||||
setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; },
|
||||
createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {},
|
||||
registerWindowHandlers() {},
|
||||
requestWindowCommandClose() {
|
||||
return true;
|
||||
},
|
||||
shouldCloseWindowFromInput,
|
||||
registerMainWindow(win) {
|
||||
registered.push(win);
|
||||
},
|
||||
unregisterMainWindow(win) {
|
||||
unregistered.push(win);
|
||||
},
|
||||
closeSettingsWindow() {},
|
||||
hideSettingsWindow() {},
|
||||
});
|
||||
|
||||
const electronModule = {
|
||||
BrowserWindow: BrowserWindowStub,
|
||||
nativeTheme: {},
|
||||
app: {},
|
||||
screen: {},
|
||||
shell: {},
|
||||
ipcMain: {},
|
||||
};
|
||||
const options = {
|
||||
preload: "/tmp/preload.cjs",
|
||||
devServerUrl: "http://localhost:5173",
|
||||
isDev: true,
|
||||
appIcon: null,
|
||||
isMac: true,
|
||||
electronDir: __dirname,
|
||||
};
|
||||
|
||||
const first = await api.createWindow(electronModule, options);
|
||||
const second = await api.createWindow(electronModule, options);
|
||||
|
||||
assert.equal(registered.length, 2);
|
||||
assert.equal(registered[0], first);
|
||||
assert.equal(registered[1], second);
|
||||
assert.notEqual(first, second);
|
||||
|
||||
first.close();
|
||||
assert.deepEqual(unregistered, [first]);
|
||||
});
|
||||
|
||||
test("each main window close saves its own state", async () => {
|
||||
const closeHandlers = [];
|
||||
const savedStates = [];
|
||||
|
||||
class BrowserWindowStub {
|
||||
constructor() {
|
||||
this.webContents = {
|
||||
id: closeHandlers.length + 1,
|
||||
on() {},
|
||||
once() {},
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
setIgnoreMenuShortcuts() {},
|
||||
setWindowOpenHandler() {},
|
||||
openDevTools() {},
|
||||
};
|
||||
}
|
||||
|
||||
on(channel, handler) {
|
||||
if (channel === "close") closeHandlers.push(handler);
|
||||
}
|
||||
once() {}
|
||||
isDestroyed() { return false; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
|
||||
setBackgroundColor() {}
|
||||
async loadURL() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null,
|
||||
electronApp: null,
|
||||
currentTheme: "light",
|
||||
isQuitting: false,
|
||||
pendingWindowStateWrite: null,
|
||||
queuedWindowState: null,
|
||||
windowStateCloseRequested: false,
|
||||
DEFAULT_WINDOW_WIDTH: 1400,
|
||||
DEFAULT_WINDOW_HEIGHT: 900,
|
||||
MIN_WINDOW_WIDTH: 1100,
|
||||
MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: "bypassHeatCheck",
|
||||
THEME_COLORS: { light: { background: "#fff" } },
|
||||
unhealthyWebContentsIds: new Set(),
|
||||
rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname,
|
||||
URL,
|
||||
require,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
getGlobalShortcutBridge() {
|
||||
return { handleWindowClose: () => false };
|
||||
},
|
||||
debugLog() {},
|
||||
resolveFrontendBackgroundColor() { return null; },
|
||||
loadWindowState() { return null; },
|
||||
getDevRendererBaseUrl(url) { return url; },
|
||||
getWindowBoundsState(win) {
|
||||
return { windowId: win.webContents.id };
|
||||
},
|
||||
queueWindowStateSave() {},
|
||||
saveWindowStateSync(state) {
|
||||
savedStates.push(state);
|
||||
},
|
||||
setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; },
|
||||
createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {},
|
||||
registerWindowHandlers() {},
|
||||
requestWindowCommandClose() {
|
||||
return true;
|
||||
},
|
||||
shouldCloseWindowFromInput,
|
||||
registerMainWindow() {},
|
||||
unregisterMainWindow() {},
|
||||
closeSettingsWindow() {},
|
||||
hideSettingsWindow() {},
|
||||
});
|
||||
|
||||
const electronModule = {
|
||||
BrowserWindow: BrowserWindowStub,
|
||||
nativeTheme: {},
|
||||
app: {},
|
||||
screen: {},
|
||||
shell: {},
|
||||
ipcMain: {},
|
||||
};
|
||||
const options = {
|
||||
preload: "/tmp/preload.cjs",
|
||||
devServerUrl: "http://localhost:5173",
|
||||
isDev: true,
|
||||
appIcon: null,
|
||||
isMac: true,
|
||||
electronDir: __dirname,
|
||||
};
|
||||
|
||||
await api.createWindow(electronModule, options);
|
||||
await api.createWindow(electronModule, options);
|
||||
|
||||
assert.equal(closeHandlers.length, 2);
|
||||
closeHandlers[0]({});
|
||||
closeHandlers[1]({});
|
||||
|
||||
assert.deepEqual(savedStates, [{ windowId: 1 }, { windowId: 2 }]);
|
||||
});
|
||||
|
||||
test("window IPC handlers target the sender owner window", async () => {
|
||||
const handlers = new Map();
|
||||
const ipcMain = {
|
||||
handle(channel, handler) {
|
||||
@@ -353,10 +632,14 @@ test("window focus IPC handler focuses the sender owner window", async () => {
|
||||
},
|
||||
};
|
||||
const calls = [];
|
||||
const titles = [];
|
||||
const win = {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
setTitle(title) {
|
||||
titles.push(title);
|
||||
},
|
||||
focus() {
|
||||
calls.push("focus");
|
||||
},
|
||||
@@ -384,6 +667,17 @@ test("window focus IPC handler focuses the sender owner window", async () => {
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(calls, ["focus", "webContents.focus"]);
|
||||
const titleResult = await handlers.get("netcatty:window:setTitle")({
|
||||
sender: {
|
||||
id: 202,
|
||||
getOwnerBrowserWindow() {
|
||||
return win;
|
||||
},
|
||||
},
|
||||
}, "Prod SSH");
|
||||
|
||||
assert.equal(titleResult, true);
|
||||
assert.deepEqual(titles, ["Prod SSH"]);
|
||||
});
|
||||
|
||||
test("resolveSettingsWindowBounds centers settings on the requesting window display", () => {
|
||||
|
||||
@@ -743,34 +743,32 @@ if (!gotLock) {
|
||||
}
|
||||
|
||||
const { ipcMain: _ipcMain } = electronModule;
|
||||
// Target the main window explicitly. Falling back to
|
||||
// BrowserWindow.getAllWindows()[0] could pick the tray panel or settings
|
||||
// window, whose renderers don't listen for app:query-dirty-editors and
|
||||
// would force the 5s timeout fallback to run on every quit.
|
||||
const win = getWindowManager().getMainWindow();
|
||||
// No main window, or it's hidden (tray-panel "Quit" path) — there's no
|
||||
// visible UI to surface a "save first" toast on, so skip the round-trip
|
||||
// and quit directly. The renderer's dirty-editor check exists to warn the
|
||||
// user; if they can't see the warning, it's just dead 5-second wait.
|
||||
//
|
||||
// A minimized window is *not* hidden: the user has a taskbar/Dock entry
|
||||
// and can restore in one click, so we still want to gate the quit on the
|
||||
// dirty-editor check there. Some platforms report isVisible()=false on a
|
||||
// minimized window (see globalShortcutBridge.cjs:478), so check both.
|
||||
const isReachableByUser =
|
||||
win && !win.isDestroyed?.() &&
|
||||
(win.isVisible?.() || win.isMinimized?.());
|
||||
if (!isReachableByUser) {
|
||||
// Target all visible/recoverable main windows explicitly. Falling back to
|
||||
// BrowserWindow.getAllWindows() could pick tray/settings windows whose
|
||||
// renderers don't listen for app:query-dirty-editors and would force the
|
||||
// timeout fallback on every quit.
|
||||
const mainWindows = typeof getWindowManager().getMainWindows === "function"
|
||||
? getWindowManager().getMainWindows()
|
||||
: [getWindowManager().getMainWindow()].filter(Boolean);
|
||||
|
||||
// No reachable main window (tray-panel "Quit" path) — there's no visible
|
||||
// UI to surface a "save first" toast on, so skip the round-trip and quit
|
||||
// directly. A minimized window is still reachable via taskbar/Dock.
|
||||
const reachableMainWindows = mainWindows.filter((candidate) => (
|
||||
candidate && !candidate.isDestroyed?.() &&
|
||||
(candidate.isVisible?.() || candidate.isMinimized?.())
|
||||
));
|
||||
if (reachableMainWindows.length === 0) {
|
||||
commitQuit();
|
||||
return;
|
||||
}
|
||||
|
||||
// The renderer needs to be alive for the IPC roundtrip to make sense.
|
||||
// A crashed renderer would silently drop the message and we'd wait
|
||||
// 5 s for nothing — skip straight to quit (we can't ask the user
|
||||
// anyway, the UI is gone).
|
||||
const wc = win.webContents;
|
||||
if (!wc || wc.isDestroyed?.() || wc.isCrashed?.()) {
|
||||
// Crashed/dead renderers are skipped; there is no usable UI to warn from.
|
||||
const queryableWebContents = reachableMainWindows
|
||||
.map((candidate) => candidate.webContents)
|
||||
.filter((wc) => wc && !wc.isDestroyed?.() && !wc.isCrashed?.());
|
||||
if (queryableWebContents.length === 0) {
|
||||
commitQuit();
|
||||
return;
|
||||
}
|
||||
@@ -783,9 +781,12 @@ if (!gotLock) {
|
||||
// through queryDirtyEditors so the request/reply/timeout handling stays in
|
||||
// one place. It fails open (resolves false) on timeout / dead renderer, so
|
||||
// a hung renderer can never strand the quit.
|
||||
queryDirtyEditors(wc, QUIT_GUARD_TIMEOUT_MS, { ipcMain: _ipcMain })
|
||||
.then((hasDirty) => {
|
||||
Promise.all(
|
||||
queryableWebContents.map((wc) => queryDirtyEditors(wc, QUIT_GUARD_TIMEOUT_MS, { ipcMain: _ipcMain })),
|
||||
)
|
||||
.then((dirtyResults) => {
|
||||
quitGuardChannelBusy = false;
|
||||
const hasDirty = dirtyResults.some(Boolean);
|
||||
if (!hasDirty) {
|
||||
commitQuit();
|
||||
return;
|
||||
|
||||
@@ -299,6 +299,48 @@ function createBridgeRegistrar(context) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:window:openSession", async (_event, payload) => {
|
||||
try {
|
||||
if (!payload || typeof payload !== "object" || !payload.sourceSession) {
|
||||
return { success: false, error: "Invalid session payload" };
|
||||
}
|
||||
const title = typeof payload.title === "string" && payload.title.trim()
|
||||
? payload.title.trim()
|
||||
: "Netcatty";
|
||||
const win = await getWindowManager().createWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
appIcon,
|
||||
isMac,
|
||||
electronDir,
|
||||
onRegisterBridge: registerBridges,
|
||||
});
|
||||
try {
|
||||
win.setTitle(title);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await getWindowManager().waitForRendererReady(win, { timeoutMs: 8000 });
|
||||
} catch (err) {
|
||||
console.warn("[Main] New session window did not report ready before payload send:", err?.message || err);
|
||||
}
|
||||
if (win.isDestroyed?.() || win.webContents?.isDestroyed?.()) {
|
||||
return { success: false, error: "Window closed before session could open" };
|
||||
}
|
||||
win.webContents.send("netcatty:window:openSession", {
|
||||
title,
|
||||
sourceSession: payload.sourceSession,
|
||||
localShellType: payload.localShellType,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("[Main] Failed to open session in new window:", err);
|
||||
return { success: false, error: err?.message || "Failed to open new window" };
|
||||
}
|
||||
});
|
||||
|
||||
// Cloud sync master password (stored in-memory + persisted via safeStorage)
|
||||
ipcMain.handle("netcatty:cloudSync:session:setPassword", async (_event, password) => {
|
||||
|
||||
@@ -354,6 +354,13 @@ function createPreloadApi(ctx) {
|
||||
windowIsMaximized: () => ipcRenderer.invoke("netcatty:window:isMaximized"),
|
||||
windowIsFullscreen: () => ipcRenderer.invoke("netcatty:window:isFullscreen"),
|
||||
windowFocus: () => ipcRenderer.invoke("netcatty:window:focus"),
|
||||
setWindowTitle: (title) => ipcRenderer.invoke("netcatty:window:setTitle", title),
|
||||
openSessionInNewWindow: (payload) => ipcRenderer.invoke("netcatty:window:openSession", payload),
|
||||
onOpenSessionInNewWindow: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:openSession", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:openSession", handler);
|
||||
},
|
||||
onWindowCommandCloseRequested: (cb) => {
|
||||
const handler = () => cb();
|
||||
ipcRenderer.on("netcatty:window:command-close", handler);
|
||||
|
||||
Reference in New Issue
Block a user