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
322 lines
10 KiB
JavaScript
322 lines
10 KiB
JavaScript
/* eslint-disable no-undef */
|
|
|
|
const { randomUUID } = require("node:crypto");
|
|
|
|
const crashLogBridge = require("../crashLogBridge.cjs");
|
|
const {
|
|
isAttachPopupClosePrepared,
|
|
registerAttachPopupAuthorization,
|
|
releaseAttachPopupAuthorization,
|
|
restoreAttachedSessionOutput,
|
|
} = require("../terminalAttachRestore.cjs");
|
|
const {
|
|
windowsFramelessContentChromeOptions,
|
|
} = require("./windowsWindowChrome.cjs");
|
|
|
|
const ATTACH_CLOSE_PREPARE_TIMEOUT_MS = 2000;
|
|
|
|
function createTerminalPopupWindowApi(ctx) {
|
|
with (ctx) {
|
|
const terminalPopupWindows = new Map();
|
|
/** attachSessionId -> popupId for AI silent-session observe windows */
|
|
const attachSessionPopups = new Map();
|
|
|
|
function isLiveWindow(win) {
|
|
return Boolean(win && typeof win.isDestroyed === "function" && !win.isDestroyed());
|
|
}
|
|
|
|
async function openTerminalPopupWindow(electronModule, options, payload) {
|
|
const { BrowserWindow, shell } = electronModule;
|
|
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir, sourceWindow } = options;
|
|
|
|
const attachSessionId = typeof payload?.attachSessionId === "string" && payload.attachSessionId
|
|
? payload.attachSessionId
|
|
: null;
|
|
const attachAuthorization = attachSessionId ? randomUUID() : null;
|
|
if (attachSessionId) {
|
|
const existingPopupId = attachSessionPopups.get(attachSessionId);
|
|
const existing = existingPopupId ? terminalPopupWindows.get(existingPopupId) : null;
|
|
if (existing && isLiveWindow(existing.win)) {
|
|
try {
|
|
showAndFocusWindow(existing.win);
|
|
} catch {
|
|
// ignore focus races
|
|
}
|
|
return { success: true, popupId: existingPopupId, reused: true };
|
|
}
|
|
if (existingPopupId) attachSessionPopups.delete(attachSessionId);
|
|
}
|
|
|
|
const osTheme = electronModule?.nativeTheme?.shouldUseDarkColors ? "dark" : "light";
|
|
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
|
|
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
|
|
const backgroundColor = frontendBackground || "#1a1a1a";
|
|
|
|
const popupWidth = 920;
|
|
const popupHeight = 580;
|
|
const { x: popupX, y: popupY } = resolveSettingsWindowBounds(electronModule, {
|
|
sourceWindow: sourceWindow || mainWindow,
|
|
settingsWidth: popupWidth,
|
|
settingsHeight: popupHeight,
|
|
});
|
|
|
|
const title = typeof payload?.title === "string" && payload.title.trim()
|
|
? payload.title.trim()
|
|
: "Terminal";
|
|
const popupId = String(payload?.popupId || randomUUID());
|
|
if (terminalPopupWindows.has(popupId)) {
|
|
throw new Error(`Terminal popup ID is already active: ${popupId}`);
|
|
}
|
|
|
|
crashLogBridge.captureDiagnostic("terminal-popup", "creating popup window", {
|
|
popupId,
|
|
title,
|
|
isDev,
|
|
popupX,
|
|
popupY,
|
|
});
|
|
|
|
const windowsChrome = windowsFramelessContentChromeOptions();
|
|
|
|
const win = new BrowserWindow({
|
|
title,
|
|
width: popupWidth,
|
|
height: popupHeight,
|
|
...(popupX !== undefined && popupY !== undefined ? { x: popupX, y: popupY } : {}),
|
|
minWidth: 480,
|
|
minHeight: 320,
|
|
backgroundColor,
|
|
icon: appIcon,
|
|
show: false,
|
|
frame: false,
|
|
...(isMac ? { trafficLightPosition: { x: 12, y: 12 } } : {}),
|
|
...windowsChrome,
|
|
webPreferences: {
|
|
preload,
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: false,
|
|
spellcheck: false,
|
|
backgroundThrottling: false,
|
|
v8CacheOptions: V8_CACHE_OPTIONS,
|
|
},
|
|
});
|
|
|
|
let lifecycleReleased = false;
|
|
let closePreparationRequested = false;
|
|
let closePreparationTimer = null;
|
|
const releaseLifecycle = () => {
|
|
if (lifecycleReleased) return;
|
|
lifecycleReleased = true;
|
|
if (closePreparationTimer) clearTimeout(closePreparationTimer);
|
|
terminalPopupWindows.delete(popupId);
|
|
if (attachSessionId && attachSessionPopups.get(attachSessionId) === popupId) {
|
|
attachSessionPopups.delete(attachSessionId);
|
|
// Window may be destroyed before React cleanup runs; always restore
|
|
// the display route from the main-process lifecycle.
|
|
try {
|
|
restoreAttachedSessionOutput(attachSessionId);
|
|
} catch (err) {
|
|
crashLogBridge.captureError?.("terminal-popup", err, {
|
|
popupId,
|
|
attachSessionId,
|
|
step: "restore attach output on close",
|
|
});
|
|
}
|
|
}
|
|
releaseAttachPopupAuthorization(attachAuthorization);
|
|
unregisterAppContentWindow(win);
|
|
notifyAppContentWindowClosed(win);
|
|
};
|
|
terminalPopupWindows.set(popupId, { releaseLifecycle, win });
|
|
if (attachSessionId) {
|
|
attachSessionPopups.set(attachSessionId, popupId);
|
|
registerAttachPopupAuthorization(
|
|
attachAuthorization,
|
|
attachSessionId,
|
|
win.webContents.id,
|
|
);
|
|
}
|
|
registerAppContentWindow(win);
|
|
crashLogBridge.captureDiagnostic("terminal-popup", "popup BrowserWindow created", {
|
|
popupId,
|
|
title,
|
|
webContentsId: win.webContents?.id,
|
|
});
|
|
|
|
try {
|
|
win.webContents?.setWindowOpenHandler?.(
|
|
createExternalOnlyWindowOpenHandler(shell),
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
win.on("close", (event) => {
|
|
if (!attachSessionId || isAttachPopupClosePrepared(attachAuthorization)) return;
|
|
event?.preventDefault?.();
|
|
if (closePreparationRequested) return;
|
|
closePreparationRequested = true;
|
|
try {
|
|
win.webContents.send("netcatty:terminal-popup:prepare-close", {
|
|
sessionId: attachSessionId,
|
|
authorization: attachAuthorization,
|
|
});
|
|
} catch {
|
|
// Timeout below force-closes and restores the route.
|
|
}
|
|
closePreparationTimer = setTimeout(() => {
|
|
closePreparationTimer = null;
|
|
try {
|
|
if (isLiveWindow(win)) win.destroy();
|
|
} catch {
|
|
releaseLifecycle();
|
|
}
|
|
}, ATTACH_CLOSE_PREPARE_TIMEOUT_MS);
|
|
});
|
|
win.on("closed", releaseLifecycle);
|
|
|
|
try {
|
|
win.webContents?.on?.("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => {
|
|
console.warn("[TerminalPopup] Failed to load renderer", {
|
|
popupId,
|
|
errorCode,
|
|
errorDescription,
|
|
validatedURL,
|
|
});
|
|
});
|
|
win.webContents?.on?.("render-process-gone", (_event, details) => {
|
|
console.warn("[TerminalPopup] Renderer process gone", { popupId, details });
|
|
if (attachSessionId) {
|
|
restoreAttachedSessionOutput(attachSessionId);
|
|
}
|
|
try {
|
|
if (isLiveWindow(win)) win.destroy();
|
|
} catch {
|
|
releaseLifecycle();
|
|
}
|
|
});
|
|
win.webContents?.on?.("console-message", (_event, level, message, line, sourceId) => {
|
|
crashLogBridge.captureDiagnostic("terminal-popup-console", message, {
|
|
popupId,
|
|
level,
|
|
line,
|
|
sourceId,
|
|
});
|
|
});
|
|
} catch {
|
|
// ignore diagnostics wiring failures
|
|
}
|
|
|
|
win.on("page-title-updated", (e) => { e.preventDefault(); });
|
|
|
|
try {
|
|
win.setBackgroundColor(backgroundColor);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
applyWindowOpacityToWindow(win);
|
|
|
|
if (isMac) {
|
|
try {
|
|
win.setWindowButtonVisibility(true);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
win.setWindowButtonPosition({ x: 12, y: 12 });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const popupPath = "#/terminal-popup";
|
|
|
|
try {
|
|
if (isDev) {
|
|
try {
|
|
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
|
crashLogBridge.captureDiagnostic("terminal-popup", "loading dev popup URL", {
|
|
popupId,
|
|
url: `${baseUrl}${popupPath}`,
|
|
});
|
|
await win.loadURL(`${baseUrl}${popupPath}`);
|
|
} catch (e) {
|
|
console.warn("[TerminalPopup] Dev server not reachable", e);
|
|
crashLogBridge.captureError("terminal-popup", e, {
|
|
popupId,
|
|
step: "load dev popup URL",
|
|
});
|
|
await win.loadURL(`app://netcatty/index.html${popupPath}`);
|
|
}
|
|
} else {
|
|
crashLogBridge.captureDiagnostic("terminal-popup", "loading packaged popup URL", {
|
|
popupId,
|
|
url: `app://netcatty/index.html${popupPath}`,
|
|
});
|
|
await win.loadURL(`app://netcatty/index.html${popupPath}`);
|
|
}
|
|
|
|
win.webContents.send("netcatty:window:terminalPopupConfig", {
|
|
...payload,
|
|
popupId,
|
|
...(attachAuthorization ? { attachAuthorization } : {}),
|
|
});
|
|
crashLogBridge.captureDiagnostic("terminal-popup", "popup config delivered", {
|
|
popupId,
|
|
title,
|
|
});
|
|
showAndFocusWindow(win);
|
|
crashLogBridge.captureDiagnostic("terminal-popup", "popup window shown", {
|
|
popupId,
|
|
title,
|
|
visible: typeof win.isVisible === "function" ? win.isVisible() : undefined,
|
|
});
|
|
return { success: true, popupId };
|
|
} catch (error) {
|
|
try {
|
|
if (isLiveWindow(win)) {
|
|
if (typeof win.destroy === "function") win.destroy();
|
|
else win.close();
|
|
}
|
|
} catch {
|
|
// Lifecycle cleanup below remains fail-safe if Electron cleanup throws.
|
|
}
|
|
releaseLifecycle();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function closeTerminalPopupWindow(popupId) {
|
|
const record = terminalPopupWindows.get(popupId);
|
|
if (!record) return;
|
|
if (!isLiveWindow(record.win)) {
|
|
record.releaseLifecycle();
|
|
return;
|
|
}
|
|
try {
|
|
record.win.close();
|
|
} catch {
|
|
try {
|
|
record.win.destroy?.();
|
|
} catch {
|
|
// The lifecycle registry must still be released below.
|
|
} finally {
|
|
record.releaseLifecycle();
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
openTerminalPopupWindow,
|
|
closeTerminalPopupWindow,
|
|
getTerminalPopupWindows() {
|
|
return Array.from(terminalPopupWindows.values());
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { createTerminalPopupWindowApi };
|