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:
陈大猫
2026-06-06 22:13:47 +08:00
committed by GitHub
parent 06486e06dd
commit 29a6172120
19 changed files with 893 additions and 72 deletions

View File

@@ -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();

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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);