[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
36
electron/bridges/windowManager/appContentRegistry.test.cjs
Normal file
36
electron/bridges/windowManager/appContentRegistry.test.cjs
Normal file
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const windowManager = require("../windowManager.cjs");
|
||||
|
||||
function windowStub(id) {
|
||||
return {
|
||||
id,
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
id,
|
||||
isCrashed: () => false,
|
||||
isDestroyed: () => false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("lifecycle-only app windows are excluded from dirty-editor queries", (context) => {
|
||||
const editorWindow = windowStub(101);
|
||||
const terminalPopup = windowStub(102);
|
||||
context.after(() => {
|
||||
windowManager.unregisterAppContentWindow(editorWindow);
|
||||
windowManager.unregisterAppContentWindow(terminalPopup);
|
||||
});
|
||||
|
||||
windowManager.registerAppContentWindow(editorWindow, { queryDirtyEditors: true });
|
||||
windowManager.registerAppContentWindow(terminalPopup);
|
||||
|
||||
assert.deepEqual(windowManager.getAppContentWindows(), [editorWindow, terminalPopup]);
|
||||
assert.deepEqual(windowManager.getDirtyEditorWindows(), [editorWindow]);
|
||||
windowManager.unregisterAppContentWindow(editorWindow);
|
||||
assert.deepEqual(windowManager.getAppContentWindows(), [terminalPopup]);
|
||||
assert.deepEqual(windowManager.getDirtyEditorWindows(), []);
|
||||
});
|
||||
116
electron/bridges/windowManager/backgroundColor.cjs
Normal file
116
electron/bridges/windowManager/backgroundColor.cjs
Normal file
@@ -0,0 +1,116 @@
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const frontendBackgroundColorCache = new Map();
|
||||
|
||||
function hslToHex(h, s, l) {
|
||||
const hue = ((h % 360) + 360) % 360;
|
||||
const sat = Math.max(0, Math.min(100, s)) / 100;
|
||||
const light = Math.max(0, Math.min(100, l)) / 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * light - 1)) * sat;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = light - c / 2;
|
||||
|
||||
let r1 = 0;
|
||||
let g1 = 0;
|
||||
let b1 = 0;
|
||||
|
||||
if (hue < 60) {
|
||||
r1 = c; g1 = x; b1 = 0;
|
||||
} else if (hue < 120) {
|
||||
r1 = x; g1 = c; b1 = 0;
|
||||
} else if (hue < 180) {
|
||||
r1 = 0; g1 = c; b1 = x;
|
||||
} else if (hue < 240) {
|
||||
r1 = 0; g1 = x; b1 = c;
|
||||
} else if (hue < 300) {
|
||||
r1 = x; g1 = 0; b1 = c;
|
||||
} else {
|
||||
r1 = c; g1 = 0; b1 = x;
|
||||
}
|
||||
|
||||
const toHex = (n) => Math.round((n + m) * 255).toString(16).padStart(2, "0");
|
||||
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
|
||||
}
|
||||
|
||||
function normalizeBackgroundColor(value) {
|
||||
if (!value) return null;
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith("#")) return raw;
|
||||
|
||||
const parts = raw.split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
const h = Number(parts[0]);
|
||||
const s = Number(String(parts[1]).replace("%", ""));
|
||||
const l = Number(String(parts[2]).replace("%", ""));
|
||||
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) return null;
|
||||
return hslToHex(h, s, l);
|
||||
}
|
||||
|
||||
function parseBackgroundFromIndexHtml(indexHtml, theme) {
|
||||
if (!indexHtml) return null;
|
||||
|
||||
const block =
|
||||
theme === "dark"
|
||||
? indexHtml.match(/\.dark\s*\{[\s\S]*?\}/)
|
||||
: indexHtml.match(/:root\s*\{[\s\S]*?\}/);
|
||||
|
||||
const within = block?.[0] || indexHtml;
|
||||
const m = within.match(/--background:\s*([^;]+);/);
|
||||
const raw = m?.[1]?.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const parts = raw.split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
const h = Number(parts[0]);
|
||||
const s = Number(String(parts[1]).replace("%", ""));
|
||||
const l = Number(String(parts[2]).replace("%", ""));
|
||||
|
||||
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) return null;
|
||||
return hslToHex(h, s, l);
|
||||
}
|
||||
|
||||
function resolveIndexHtmlPath(electronDir) {
|
||||
const dist = path.join(electronDir, "../dist/index.html");
|
||||
const root = path.join(electronDir, "../index.html");
|
||||
if (fs.existsSync(dist)) return dist;
|
||||
if (fs.existsSync(root)) return root;
|
||||
return dist;
|
||||
}
|
||||
|
||||
function trimFrontendBackgroundColorCache() {
|
||||
if (frontendBackgroundColorCache.size <= 8) return;
|
||||
const firstKey = frontendBackgroundColorCache.keys().next().value;
|
||||
if (firstKey) frontendBackgroundColorCache.delete(firstKey);
|
||||
}
|
||||
|
||||
function resolveFrontendBackgroundColor(electronDir, theme) {
|
||||
try {
|
||||
const htmlPath = resolveIndexHtmlPath(electronDir);
|
||||
if (!htmlPath || !fs.existsSync(htmlPath)) return null;
|
||||
const stat = fs.statSync(htmlPath);
|
||||
const cacheKey = `${htmlPath}:${stat.mtimeMs}:${theme || "default"}`;
|
||||
if (frontendBackgroundColorCache.has(cacheKey)) {
|
||||
return frontendBackgroundColorCache.get(cacheKey);
|
||||
}
|
||||
const indexHtml = fs.readFileSync(htmlPath, "utf8");
|
||||
const color = parseBackgroundFromIndexHtml(indexHtml, theme);
|
||||
frontendBackgroundColorCache.set(cacheKey, color);
|
||||
trimFrontendBackgroundColorCache();
|
||||
return color;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
hslToHex,
|
||||
normalizeBackgroundColor,
|
||||
parseBackgroundFromIndexHtml,
|
||||
resolveIndexHtmlPath,
|
||||
resolveFrontendBackgroundColor,
|
||||
};
|
||||
464
electron/bridges/windowManager/displayRecovery.cjs
Normal file
464
electron/bridges/windowManager/displayRecovery.cjs
Normal file
@@ -0,0 +1,464 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Multi-monitor display recovery for content windows (#3244).
|
||||
*
|
||||
* On Windows, locking the session and letting a secondary display power off
|
||||
* (or sleeping the machine) temporarily tears that display down. The OS then
|
||||
* relocates the window onto the primary display. That relocation looks like a
|
||||
* normal move, so persisted window state is polluted too. When the display
|
||||
* returns after unlock, put the window back.
|
||||
*
|
||||
* Recovery is scoped to lock/sleep (and a short grace after unlock/resume).
|
||||
* Ordinary unplug/replug while the session is unlocked is out of scope.
|
||||
* User placement always wins: a manual move/resize, or Win+Shift+Arrow,
|
||||
* cancels an in-flight restore.
|
||||
*/
|
||||
|
||||
const DEFAULT_RESTORE_GRACE_MS = 8000;
|
||||
|
||||
let powerMonitor = null;
|
||||
try {
|
||||
const electron = require("electron");
|
||||
if (electron && typeof electron === "object") {
|
||||
powerMonitor = electron.powerMonitor || null;
|
||||
}
|
||||
} catch {
|
||||
// Not running inside Electron.
|
||||
}
|
||||
|
||||
function isFiniteBounds(bounds) {
|
||||
return Boolean(
|
||||
bounds &&
|
||||
Number.isFinite(bounds.x) &&
|
||||
Number.isFinite(bounds.y) &&
|
||||
Number.isFinite(bounds.width) &&
|
||||
Number.isFinite(bounds.height) &&
|
||||
bounds.width > 0 &&
|
||||
bounds.height > 0
|
||||
);
|
||||
}
|
||||
|
||||
function boundsEqual(a, b) {
|
||||
return Boolean(
|
||||
a &&
|
||||
b &&
|
||||
a.x === b.x &&
|
||||
a.y === b.y &&
|
||||
a.width === b.width &&
|
||||
a.height === b.height
|
||||
);
|
||||
}
|
||||
|
||||
function boundsIntersectDisplay(bounds, displayBounds) {
|
||||
if (!isFiniteBounds(bounds) || !isFiniteBounds(displayBounds)) return false;
|
||||
return (
|
||||
bounds.x < displayBounds.x + displayBounds.width &&
|
||||
bounds.x + bounds.width > displayBounds.x &&
|
||||
bounds.y < displayBounds.y + displayBounds.height &&
|
||||
bounds.y + bounds.height > displayBounds.y
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDisplayId(displayId) {
|
||||
return typeof displayId === "number" && Number.isFinite(displayId) && displayId >= 0
|
||||
? displayId
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeRecoveryCandidate(candidate) {
|
||||
if (!candidate) return null;
|
||||
if (isFiniteBounds(candidate)) return { bounds: candidate, displayId: null };
|
||||
if (isFiniteBounds(candidate.bounds)) {
|
||||
return {
|
||||
bounds: candidate.bounds,
|
||||
displayId: normalizeDisplayId(candidate.displayId),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickDisplayRecoveryBounds({ addedDisplay, currentBounds, candidates }) {
|
||||
if (!addedDisplay || !isFiniteBounds(addedDisplay.bounds)) return null;
|
||||
if (!isFiniteBounds(currentBounds)) return null;
|
||||
if (boundsIntersectDisplay(currentBounds, addedDisplay.bounds)) return null;
|
||||
const addedDisplayId = normalizeDisplayId(addedDisplay.id);
|
||||
for (const candidate of candidates || []) {
|
||||
const normalized = normalizeRecoveryCandidate(candidate);
|
||||
if (!normalized) continue;
|
||||
if (normalized.displayId !== null && addedDisplayId !== null) {
|
||||
if (normalized.displayId === addedDisplayId) return normalized.bounds;
|
||||
continue;
|
||||
}
|
||||
if (boundsIntersectDisplay(normalized.bounds, addedDisplay.bounds)) {
|
||||
return normalized.bounds;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function displayPlacementRect(display) {
|
||||
if (!display) return null;
|
||||
if (isFiniteBounds(display.workArea)) return display.workArea;
|
||||
return isFiniteBounds(display.bounds) ? display.bounds : null;
|
||||
}
|
||||
|
||||
function clampBoundsToDisplay(bounds, displayBounds) {
|
||||
if (!isFiniteBounds(bounds) || !isFiniteBounds(displayBounds)) return null;
|
||||
const width = Math.min(bounds.width, displayBounds.width);
|
||||
const height = Math.min(bounds.height, displayBounds.height);
|
||||
const x = Math.min(
|
||||
Math.max(bounds.x, displayBounds.x),
|
||||
displayBounds.x + displayBounds.width - width
|
||||
);
|
||||
const y = Math.min(
|
||||
Math.max(bounds.y, displayBounds.y),
|
||||
displayBounds.y + displayBounds.height - height
|
||||
);
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function attachDisplayRecovery({
|
||||
win,
|
||||
screen,
|
||||
restoreGraceMs = DEFAULT_RESTORE_GRACE_MS,
|
||||
platform = process.platform,
|
||||
powerMonitor: injectedPowerMonitor = null,
|
||||
}) {
|
||||
if (platform !== "win32" || !win || !screen || typeof screen.on !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let remembered = null;
|
||||
let pendingRestore = null;
|
||||
let restoreUntil = null;
|
||||
const activeInterruptions = new Set();
|
||||
let attached = true;
|
||||
const activePowerMonitor = injectedPowerMonitor || powerMonitor;
|
||||
|
||||
const isInterrupted = () => activeInterruptions.size > 0;
|
||||
|
||||
const canRestore = () =>
|
||||
isInterrupted() ||
|
||||
(restoreUntil !== null && Date.now() < restoreUntil);
|
||||
|
||||
const isMaximizedOrFullScreen = () => {
|
||||
try {
|
||||
return Boolean(win.isMaximized?.() || win.isFullScreen?.());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyBounds = () => {
|
||||
try {
|
||||
if (isMaximizedOrFullScreen() && typeof win.getNormalBounds === "function") {
|
||||
const normal = win.getNormalBounds();
|
||||
if (isFiniteBounds(normal)) return { ...normal };
|
||||
}
|
||||
const bounds = win.getBounds();
|
||||
return isFiniteBounds(bounds) ? { ...bounds } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isPrimaryDisplay = (display) => {
|
||||
try {
|
||||
const primary = screen.getPrimaryDisplay?.();
|
||||
if (!display || !primary) return false;
|
||||
const displayId = normalizeDisplayId(display.id);
|
||||
const primaryId = normalizeDisplayId(primary.id);
|
||||
if (displayId !== null && primaryId !== null) return displayId === primaryId;
|
||||
return boundsEqual(display.bounds, primary.bounds);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const findRememberedDisplay = () => {
|
||||
if (!remembered) return null;
|
||||
try {
|
||||
return (screen.getAllDisplays?.() || []).find((display) => {
|
||||
if (!display || isPrimaryDisplay(display) || !isFiniteBounds(display.bounds)) {
|
||||
return false;
|
||||
}
|
||||
const displayId = normalizeDisplayId(display.id);
|
||||
if (remembered.displayId !== null && displayId !== null) {
|
||||
return remembered.displayId === displayId;
|
||||
}
|
||||
return boundsIntersectDisplay(remembered.bounds, display.bounds);
|
||||
}) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const applyRestore = (display, bounds) => {
|
||||
const clamped = clampBoundsToDisplay(bounds, displayPlacementRect(display));
|
||||
if (!clamped) return false;
|
||||
const displayId = normalizeDisplayId(display?.id) ?? remembered?.displayId ?? null;
|
||||
remembered = { bounds: clamped, displayId };
|
||||
if (isMaximizedOrFullScreen()) {
|
||||
pendingRestore = { bounds: clamped, displayId };
|
||||
return true;
|
||||
}
|
||||
pendingRestore = null;
|
||||
try {
|
||||
win.setBounds(clamped);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const restoreToDisplay = (display) => {
|
||||
if (!attached || !canRestore() || !remembered || !display || isPrimaryDisplay(display)) {
|
||||
return false;
|
||||
}
|
||||
const currentBounds = copyBounds();
|
||||
if (
|
||||
isFiniteBounds(currentBounds) &&
|
||||
boundsIntersectDisplay(currentBounds, display.bounds)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let restored = pickDisplayRecoveryBounds({
|
||||
addedDisplay: display,
|
||||
currentBounds,
|
||||
candidates: [remembered],
|
||||
});
|
||||
// Dual-screen lock/sleep often re-enumerates the only secondary with a
|
||||
// transient id and moved bounds. Identity and geometry both miss; the
|
||||
// sole remaining secondary is still that display.
|
||||
if (!restored && isFiniteBounds(currentBounds)) {
|
||||
try {
|
||||
const secondaries = (screen.getAllDisplays?.() || []).filter(
|
||||
(candidate) => candidate && !isPrimaryDisplay(candidate)
|
||||
);
|
||||
if (
|
||||
secondaries.length === 1 &&
|
||||
(secondaries[0] === display ||
|
||||
normalizeDisplayId(secondaries[0].id) === normalizeDisplayId(display.id) ||
|
||||
boundsEqual(secondaries[0].bounds, display.bounds))
|
||||
) {
|
||||
restored = remembered.bounds;
|
||||
}
|
||||
} catch {
|
||||
// Fall through without guessing.
|
||||
}
|
||||
}
|
||||
if (!restored) return false;
|
||||
return applyRestore(display, restored);
|
||||
};
|
||||
|
||||
const restoreIfNeeded = () => {
|
||||
const display = findRememberedDisplay();
|
||||
if (!display) return false;
|
||||
return restoreToDisplay(display);
|
||||
};
|
||||
|
||||
const rememberPlacement = () => {
|
||||
if (!attached || isInterrupted()) return;
|
||||
if (canRestore()) {
|
||||
// Queued OS relocation can still arrive after unlock. Put the window
|
||||
// back unless the user has already cancelled recovery.
|
||||
restoreIfNeeded();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bounds = copyBounds();
|
||||
if (!bounds) return;
|
||||
const display = screen.getDisplayMatching?.(bounds);
|
||||
if (!display) return;
|
||||
if (isPrimaryDisplay(display)) {
|
||||
const rememberedDisplay = findRememberedDisplay();
|
||||
// Still-connected secondary + primary placement is a user move.
|
||||
if (rememberedDisplay) remembered = null;
|
||||
return;
|
||||
}
|
||||
remembered = {
|
||||
bounds,
|
||||
displayId: normalizeDisplayId(display.id),
|
||||
};
|
||||
} catch {
|
||||
// Screen queries can fail during display teardown.
|
||||
}
|
||||
};
|
||||
|
||||
const cancelUserIntent = () => {
|
||||
if (isInterrupted()) return;
|
||||
pendingRestore = null;
|
||||
restoreUntil = null;
|
||||
// A manual move/resize after unlock is the user's new placement. Drop the
|
||||
// frozen secondary snapshot so a later ordinary lock/unlock cannot restore
|
||||
// it after the display happens to reconnect.
|
||||
remembered = null;
|
||||
};
|
||||
|
||||
const onManualPlacement = (_event, nextBounds) => {
|
||||
if (!attached) return;
|
||||
cancelUserIntent();
|
||||
if (isFiniteBounds(nextBounds)) {
|
||||
try {
|
||||
const display = screen.getDisplayMatching?.(nextBounds);
|
||||
if (display && !isPrimaryDisplay(display)) {
|
||||
remembered = {
|
||||
bounds: { ...nextBounds },
|
||||
displayId: normalizeDisplayId(display.id),
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to ordinary tracking.
|
||||
}
|
||||
}
|
||||
rememberPlacement();
|
||||
};
|
||||
|
||||
const onBeforeInputEvent = (_event, input) => {
|
||||
if (
|
||||
input?.type === "keyDown" &&
|
||||
input.meta === true &&
|
||||
input.shift === true &&
|
||||
(input.key === "ArrowLeft" || input.key === "ArrowRight")
|
||||
) {
|
||||
cancelUserIntent();
|
||||
}
|
||||
};
|
||||
|
||||
const onSessionInterrupted = (signal) => {
|
||||
activeInterruptions.add(signal);
|
||||
restoreUntil = null;
|
||||
// Freeze the last secondary placement. If the window is still on a
|
||||
// secondary display at lock/sleep time, refresh the snapshot.
|
||||
try {
|
||||
const bounds = copyBounds();
|
||||
const display = bounds ? screen.getDisplayMatching?.(bounds) : null;
|
||||
if (bounds && display && !isPrimaryDisplay(display)) {
|
||||
remembered = {
|
||||
bounds,
|
||||
displayId: normalizeDisplayId(display.id),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Keep whatever placement we already have.
|
||||
}
|
||||
};
|
||||
|
||||
const onSessionResumed = (signal) => {
|
||||
activeInterruptions.delete(signal);
|
||||
if (activeInterruptions.size > 0) return;
|
||||
restoreUntil = Date.now() + restoreGraceMs;
|
||||
// display-added can land while the window still overlaps the returning
|
||||
// secondary. Windows may then queue the relocation to the primary before
|
||||
// unlock, and that move is ignored while the session is interrupted. Retry
|
||||
// now; there may be no later display or window event.
|
||||
restoreIfNeeded();
|
||||
};
|
||||
|
||||
const onDisplayRemoved = (_event, oldDisplay) => {
|
||||
if (!attached || !oldDisplay) return;
|
||||
const currentBounds = copyBounds();
|
||||
if (
|
||||
isFiniteBounds(currentBounds) &&
|
||||
isFiniteBounds(oldDisplay.bounds) &&
|
||||
boundsIntersectDisplay(currentBounds, oldDisplay.bounds) &&
|
||||
!isPrimaryDisplay(oldDisplay)
|
||||
) {
|
||||
remembered = {
|
||||
bounds: currentBounds,
|
||||
displayId: normalizeDisplayId(oldDisplay.id) ?? remembered?.displayId ?? null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const onDisplayAdded = (_event, display) => {
|
||||
if (!attached) return;
|
||||
restoreToDisplay(display);
|
||||
};
|
||||
|
||||
const applyPendingRestore = () => {
|
||||
if (!attached || !pendingRestore || isMaximizedOrFullScreen()) return;
|
||||
const display = findRememberedDisplay();
|
||||
if (!display) return;
|
||||
const currentBounds = copyBounds();
|
||||
if (
|
||||
isFiniteBounds(currentBounds) &&
|
||||
boundsIntersectDisplay(currentBounds, display.bounds)
|
||||
) {
|
||||
pendingRestore = null;
|
||||
return;
|
||||
}
|
||||
applyRestore(display, pendingRestore.bounds);
|
||||
};
|
||||
|
||||
const onSuspend = () => onSessionInterrupted("suspend");
|
||||
const onLockScreen = () => onSessionInterrupted("lock-screen");
|
||||
const onResume = () => onSessionResumed("suspend");
|
||||
const onUnlockScreen = () => onSessionResumed("lock-screen");
|
||||
|
||||
try {
|
||||
activePowerMonitor?.on?.("suspend", onSuspend);
|
||||
activePowerMonitor?.on?.("lock-screen", onLockScreen);
|
||||
activePowerMonitor?.on?.("resume", onResume);
|
||||
activePowerMonitor?.on?.("unlock-screen", onUnlockScreen);
|
||||
} catch {
|
||||
// Lock/sleep tracking is best-effort.
|
||||
}
|
||||
|
||||
try {
|
||||
screen.on("display-removed", onDisplayRemoved);
|
||||
screen.on("display-added", onDisplayAdded);
|
||||
screen.on("display-metrics-changed", onDisplayAdded);
|
||||
win.on?.("will-move", onManualPlacement);
|
||||
win.on?.("will-resize", onManualPlacement);
|
||||
win.on?.("move", rememberPlacement);
|
||||
win.on?.("resize", rememberPlacement);
|
||||
win.on?.("unmaximize", applyPendingRestore);
|
||||
win.on?.("leave-full-screen", applyPendingRestore);
|
||||
rememberPlacement();
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
try {
|
||||
win.webContents?.on?.("before-input-event", onBeforeInputEvent);
|
||||
} catch {
|
||||
// Keyboard cancellation is best-effort.
|
||||
}
|
||||
|
||||
return function detach() {
|
||||
attached = false;
|
||||
try {
|
||||
activePowerMonitor?.removeListener?.("suspend", onSuspend);
|
||||
activePowerMonitor?.removeListener?.("lock-screen", onLockScreen);
|
||||
activePowerMonitor?.removeListener?.("resume", onResume);
|
||||
activePowerMonitor?.removeListener?.("unlock-screen", onUnlockScreen);
|
||||
} catch {}
|
||||
try { screen.removeListener?.("display-removed", onDisplayRemoved); } catch {}
|
||||
try { screen.removeListener?.("display-added", onDisplayAdded); } catch {}
|
||||
try { screen.removeListener?.("display-metrics-changed", onDisplayAdded); } catch {}
|
||||
try { win.removeListener?.("will-move", onManualPlacement); } catch {}
|
||||
try { win.removeListener?.("will-resize", onManualPlacement); } catch {}
|
||||
try { win.removeListener?.("move", rememberPlacement); } catch {}
|
||||
try { win.removeListener?.("resize", rememberPlacement); } catch {}
|
||||
try { win.removeListener?.("unmaximize", applyPendingRestore); } catch {}
|
||||
try { win.removeListener?.("leave-full-screen", applyPendingRestore); } catch {}
|
||||
try {
|
||||
win.webContents?.removeListener?.("before-input-event", onBeforeInputEvent);
|
||||
} catch {}
|
||||
remembered = null;
|
||||
pendingRestore = null;
|
||||
restoreUntil = null;
|
||||
activeInterruptions.clear();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachDisplayRecovery,
|
||||
boundsIntersectDisplay,
|
||||
clampBoundsToDisplay,
|
||||
isFiniteBounds,
|
||||
pickDisplayRecoveryBounds,
|
||||
};
|
||||
570
electron/bridges/windowManager/displayRecovery.test.cjs
Normal file
570
electron/bridges/windowManager/displayRecovery.test.cjs
Normal file
@@ -0,0 +1,570 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
attachDisplayRecovery: attachDisplayRecoveryForPlatform,
|
||||
boundsIntersectDisplay,
|
||||
clampBoundsToDisplay,
|
||||
pickDisplayRecoveryBounds,
|
||||
} = require("./displayRecovery.cjs");
|
||||
|
||||
function attachDisplayRecovery(options) {
|
||||
return attachDisplayRecoveryForPlatform({ ...options, platform: "win32" });
|
||||
}
|
||||
|
||||
const PRIMARY = { id: 1, bounds: { x: 0, y: 0, width: 1920, height: 1080 } };
|
||||
const SECONDARY = { id: 2, bounds: { x: 1920, y: 0, width: 2560, height: 1440 } };
|
||||
|
||||
function createMockWindow(initialBounds) {
|
||||
const listeners = new Map();
|
||||
const webContentsListeners = new Map();
|
||||
const win = {
|
||||
bounds: { ...initialBounds },
|
||||
destroyed: false,
|
||||
maximized: false,
|
||||
fullScreen: false,
|
||||
setBoundsCalls: [],
|
||||
webContents: {
|
||||
on(event, handler) {
|
||||
if (!webContentsListeners.has(event)) webContentsListeners.set(event, []);
|
||||
webContentsListeners.get(event).push(handler);
|
||||
},
|
||||
removeListener(event, handler) {
|
||||
const list = webContentsListeners.get(event) || [];
|
||||
const index = list.indexOf(handler);
|
||||
if (index >= 0) list.splice(index, 1);
|
||||
},
|
||||
emit(event, ...args) {
|
||||
for (const handler of webContentsListeners.get(event) || []) handler(...args);
|
||||
},
|
||||
},
|
||||
isDestroyed() {
|
||||
return win.destroyed;
|
||||
},
|
||||
isMaximized() {
|
||||
return win.maximized;
|
||||
},
|
||||
isFullScreen() {
|
||||
return win.fullScreen;
|
||||
},
|
||||
getBounds() {
|
||||
return { ...win.bounds };
|
||||
},
|
||||
getNormalBounds() {
|
||||
return { ...(win.normalBounds || win.bounds) };
|
||||
},
|
||||
unmaximize() {
|
||||
win.maximized = false;
|
||||
for (const handler of listeners.get("unmaximize") || []) handler();
|
||||
},
|
||||
setBounds(next) {
|
||||
win.setBoundsCalls.push({ ...next });
|
||||
win.bounds = { ...next };
|
||||
for (const handler of listeners.get("move") || []) handler();
|
||||
},
|
||||
on(event, handler) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event).push(handler);
|
||||
},
|
||||
removeListener(event, handler) {
|
||||
const list = listeners.get(event) || [];
|
||||
const index = list.indexOf(handler);
|
||||
if (index >= 0) list.splice(index, 1);
|
||||
},
|
||||
__listeners: listeners,
|
||||
__webContentsListeners: webContentsListeners,
|
||||
};
|
||||
return win;
|
||||
}
|
||||
|
||||
function createMockScreen({ primary = PRIMARY, displays = [PRIMARY, SECONDARY] } = {}) {
|
||||
const listeners = new Map();
|
||||
const connected = [...displays];
|
||||
const mock = {
|
||||
on(event, handler) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event).push(handler);
|
||||
},
|
||||
removeListener(event, handler) {
|
||||
const list = listeners.get(event) || [];
|
||||
const index = list.indexOf(handler);
|
||||
if (index >= 0) list.splice(index, 1);
|
||||
},
|
||||
emit(event, ...args) {
|
||||
const display = args[1] || args[0];
|
||||
if (event === "display-removed" && display) {
|
||||
const displayIdIsTransient =
|
||||
typeof display.id !== "number" || !Number.isFinite(display.id) || display.id < 0;
|
||||
let index = displayIdIsTransient
|
||||
? connected.indexOf(display)
|
||||
: connected.findIndex((candidate) => candidate.id === display.id);
|
||||
if (index < 0 && display.bounds) {
|
||||
index = connected.findIndex(
|
||||
(candidate) =>
|
||||
candidate.bounds?.x === display.bounds.x &&
|
||||
candidate.bounds?.y === display.bounds.y &&
|
||||
candidate.bounds?.width === display.bounds.width &&
|
||||
candidate.bounds?.height === display.bounds.height
|
||||
);
|
||||
}
|
||||
if (index >= 0) connected.splice(index, 1);
|
||||
}
|
||||
if (event === "display-added" && display) {
|
||||
const displayIdIsTransient =
|
||||
typeof display.id !== "number" || !Number.isFinite(display.id) || display.id < 0;
|
||||
if (
|
||||
(displayIdIsTransient && !connected.includes(display)) ||
|
||||
(!displayIdIsTransient && !connected.some((candidate) => candidate.id === display.id))
|
||||
) {
|
||||
connected.push(display);
|
||||
}
|
||||
}
|
||||
for (const handler of listeners.get(event) || []) handler(...args);
|
||||
},
|
||||
getPrimaryDisplay() {
|
||||
return primary;
|
||||
},
|
||||
getAllDisplays() {
|
||||
return [...connected];
|
||||
},
|
||||
getDisplayMatching(bounds) {
|
||||
let best = null;
|
||||
let bestArea = 0;
|
||||
for (const display of connected) {
|
||||
const overlap = boundsIntersectDisplay(bounds, display.bounds)
|
||||
? Math.min(bounds.x + bounds.width, display.bounds.x + display.bounds.width) -
|
||||
Math.max(bounds.x, display.bounds.x)
|
||||
: 0;
|
||||
if (overlap > bestArea) {
|
||||
bestArea = overlap;
|
||||
best = display;
|
||||
}
|
||||
}
|
||||
return best || connected[0];
|
||||
},
|
||||
__listeners: listeners,
|
||||
};
|
||||
return mock;
|
||||
}
|
||||
|
||||
function createMockPowerMonitor() {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
on(event, handler) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event).push(handler);
|
||||
},
|
||||
removeListener(event, handler) {
|
||||
const list = listeners.get(event) || [];
|
||||
const index = list.indexOf(handler);
|
||||
if (index >= 0) list.splice(index, 1);
|
||||
},
|
||||
emit(event) {
|
||||
for (const handler of listeners.get(event) || []) handler();
|
||||
},
|
||||
__listeners: listeners,
|
||||
};
|
||||
}
|
||||
|
||||
function moveWindowManually(win, nextBounds) {
|
||||
for (const handler of win.__listeners.get("will-move") || []) {
|
||||
handler({}, { ...nextBounds });
|
||||
}
|
||||
win.bounds = { ...nextBounds };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
}
|
||||
|
||||
function lockSleepUnlockRestore({
|
||||
win,
|
||||
screen,
|
||||
powerMonitor,
|
||||
secondary = SECONDARY,
|
||||
afterLockBounds = { x: 100, y: 100, width: 1400, height: 900 },
|
||||
} = {}) {
|
||||
powerMonitor.emit("lock-screen");
|
||||
screen.emit("display-removed", {}, secondary);
|
||||
win.bounds = { ...afterLockBounds };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
screen.emit("display-added", {}, secondary);
|
||||
powerMonitor.emit("unlock-screen");
|
||||
}
|
||||
|
||||
test("boundsIntersectDisplay detects overlap and rejects invalid input", () => {
|
||||
assert.equal(
|
||||
boundsIntersectDisplay({ x: 2000, y: 100, width: 800, height: 600 }, SECONDARY.bounds),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
boundsIntersectDisplay({ x: 0, y: 0, width: 800, height: 600 }, SECONDARY.bounds),
|
||||
false
|
||||
);
|
||||
assert.equal(boundsIntersectDisplay(null, SECONDARY.bounds), false);
|
||||
});
|
||||
|
||||
test("pickDisplayRecoveryBounds restores a remembered placement on the re-added display", () => {
|
||||
const restored = pickDisplayRecoveryBounds({
|
||||
addedDisplay: SECONDARY,
|
||||
currentBounds: { x: 100, y: 100, width: 1200, height: 800 },
|
||||
candidates: [{ x: 2000, y: 100, width: 1200, height: 800 }],
|
||||
});
|
||||
assert.deepEqual(restored, { x: 2000, y: 100, width: 1200, height: 800 });
|
||||
});
|
||||
|
||||
test("pickDisplayRecoveryBounds does nothing when the window is already on the display", () => {
|
||||
const restored = pickDisplayRecoveryBounds({
|
||||
addedDisplay: SECONDARY,
|
||||
currentBounds: { x: 2000, y: 100, width: 1200, height: 800 },
|
||||
candidates: [{ x: 2100, y: 100, width: 1200, height: 800 }],
|
||||
});
|
||||
assert.equal(restored, null);
|
||||
});
|
||||
|
||||
test("pickDisplayRecoveryBounds matches a candidate by display identity when bounds changed", () => {
|
||||
const restored = pickDisplayRecoveryBounds({
|
||||
addedDisplay: { id: 2, bounds: { x: 1920, y: 0, width: 1024, height: 768 } },
|
||||
currentBounds: { x: 100, y: 100, width: 1200, height: 800 },
|
||||
candidates: [{ bounds: { x: 2000, y: 100, width: 1200, height: 800 }, displayId: 2 }],
|
||||
});
|
||||
assert.deepEqual(restored, { x: 2000, y: 100, width: 1200, height: 800 });
|
||||
});
|
||||
|
||||
test("pickDisplayRecoveryBounds falls back to geometry while the added display id is unknown", () => {
|
||||
const rememberedBounds = { x: 2000, y: 100, width: 1400, height: 900 };
|
||||
const restored = pickDisplayRecoveryBounds({
|
||||
addedDisplay: { id: -1, bounds: SECONDARY.bounds },
|
||||
currentBounds: { x: 100, y: 100, width: 1200, height: 800 },
|
||||
candidates: [{ bounds: rememberedBounds, displayId: SECONDARY.id }],
|
||||
});
|
||||
assert.deepEqual(restored, rememberedBounds);
|
||||
});
|
||||
|
||||
test("clampBoundsToDisplay keeps the restored window fully visible", () => {
|
||||
const clamped = clampBoundsToDisplay(
|
||||
{ x: 3000, y: -200, width: 3000, height: 2000 },
|
||||
SECONDARY.bounds
|
||||
);
|
||||
assert.deepEqual(clamped, { x: 1920, y: 0, width: 2560, height: 1440 });
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery restores after lock and display sleep", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
lockSleepUnlockRestore({ win, screen, powerMonitor });
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.deepEqual(win.setBoundsCalls[0], secondaryBounds);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery restores when the display returns with changed bounds", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const returning = { id: 2, bounds: { x: 1920, y: 0, width: 1024, height: 768 } };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
screen.emit("display-added", {}, returning);
|
||||
powerMonitor.emit("unlock-screen");
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.equal(boundsIntersectDisplay(win.setBoundsCalls[0], returning.bounds), true);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery restores a sole secondary that returns with an unknown id", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const returning = { id: -1, bounds: { x: -2560, y: 0, width: 2560, height: 1440 } };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
screen.emit("display-added", {}, returning);
|
||||
powerMonitor.emit("unlock-screen");
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.equal(boundsIntersectDisplay(win.setBoundsCalls[0], returning.bounds), true);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery keeps the snapshot when Windows relocates before display-removed", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
screen.emit("display-added", {}, SECONDARY);
|
||||
powerMonitor.emit("unlock-screen");
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.deepEqual(win.setBoundsCalls[0], secondaryBounds);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery restores after a long sleep because lock/suspend stays active", () => {
|
||||
const realNow = Date.now;
|
||||
let now = 1_000_000;
|
||||
Date.now = () => now;
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
try {
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
powerMonitor.emit("suspend");
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
now += 8 * 60 * 60 * 1000;
|
||||
powerMonitor.emit("resume");
|
||||
powerMonitor.emit("unlock-screen");
|
||||
screen.emit("display-added", {}, SECONDARY);
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.deepEqual(win.setBoundsCalls[0], secondaryBounds);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery keeps a deferred restore until the window is unmaximized", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
win.maximized = true;
|
||||
win.normalBounds = { ...secondaryBounds };
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
win.normalBounds = { ...win.bounds };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
screen.emit("display-added", {}, SECONDARY);
|
||||
powerMonitor.emit("unlock-screen");
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
win.unmaximize();
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.deepEqual(win.setBoundsCalls[0], secondaryBounds);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery retries restore when unlock follows a locked relocation", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
screen.emit("display-added", {}, SECONDARY);
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
|
||||
// Windows relocates the window while the session is still locked. Unlock
|
||||
// must retry; no later display event is guaranteed.
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
powerMonitor.emit("unlock-screen");
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.deepEqual(win.setBoundsCalls[0], secondaryBounds);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery restores a late OS move that arrives after the display returns", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
screen.emit("display-added", {}, SECONDARY);
|
||||
powerMonitor.emit("unlock-screen");
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.deepEqual(win.setBoundsCalls[0], secondaryBounds);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery does not restore a user move to the primary before lock", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const primaryBounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor, restoreGraceMs: 0 });
|
||||
moveWindowManually(win, primaryBounds);
|
||||
lockSleepUnlockRestore({ win, screen, powerMonitor, afterLockBounds: primaryBounds });
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
assert.deepEqual(win.bounds, primaryBounds);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery does not restore a stale snapshot on a later lock after the user stayed on primary", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const primaryBounds = { x: 300, y: 200, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
powerMonitor.emit("unlock-screen");
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
|
||||
moveWindowManually(win, primaryBounds);
|
||||
screen.emit("display-added", {}, SECONDARY);
|
||||
powerMonitor.emit("lock-screen");
|
||||
powerMonitor.emit("unlock-screen");
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
assert.deepEqual(win.bounds, primaryBounds);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery lets an immediate post-unlock user move win", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const primaryBounds = { x: 300, y: 200, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
lockSleepUnlockRestore({ win, screen, powerMonitor });
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
|
||||
moveWindowManually(win, primaryBounds);
|
||||
win.bounds = { x: 80, y: 80, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.deepEqual(win.bounds, { x: 80, y: 80, width: 1400, height: 900 });
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery does not restore ordinary unplug while unlocked", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor, restoreGraceMs: 0 });
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
screen.emit("display-added", {}, SECONDARY);
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery cancels restore when the user presses Win+Shift+Arrow", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
screen.emit("display-added", {}, SECONDARY);
|
||||
powerMonitor.emit("unlock-screen");
|
||||
|
||||
win.webContents.emit("before-input-event", {}, {
|
||||
type: "keyDown",
|
||||
key: "ArrowLeft",
|
||||
meta: true,
|
||||
shift: true,
|
||||
});
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery clamps restored windows to the display work area", () => {
|
||||
const DOCKED = {
|
||||
id: 2,
|
||||
bounds: { x: 1920, y: 0, width: 2560, height: 1440 },
|
||||
workArea: { x: 1920, y: 40, width: 2560, height: 1400 },
|
||||
};
|
||||
const win = createMockWindow({ x: 2000, y: 20, width: 1400, height: 900 });
|
||||
const screen = createMockScreen({ displays: [PRIMARY, DOCKED] });
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
powerMonitor.emit("lock-screen");
|
||||
screen.emit("display-removed", {}, DOCKED);
|
||||
win.bounds = { x: 100, y: 100, width: 1400, height: 900 };
|
||||
for (const handler of win.__listeners.get("move") || []) handler();
|
||||
screen.emit("display-added", {}, DOCKED);
|
||||
powerMonitor.emit("unlock-screen");
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 1);
|
||||
assert.equal(win.setBoundsCalls[0].y >= 40, true);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery does nothing when the window never left the primary display", () => {
|
||||
const win = createMockWindow({ x: 100, y: 100, width: 1200, height: 800 });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
lockSleepUnlockRestore({ win, screen, powerMonitor, afterLockBounds: win.bounds });
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
});
|
||||
|
||||
test("detach removes all listeners and stops recovery", () => {
|
||||
const secondaryBounds = { x: 2100, y: 120, width: 1400, height: 900 };
|
||||
const win = createMockWindow({ ...secondaryBounds });
|
||||
const screen = createMockScreen();
|
||||
const powerMonitor = createMockPowerMonitor();
|
||||
|
||||
const detach = attachDisplayRecovery({ win, screen, powerMonitor });
|
||||
detach();
|
||||
lockSleepUnlockRestore({ win, screen, powerMonitor });
|
||||
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery stays disabled outside Windows", () => {
|
||||
const win = createMockWindow({ x: 2100, y: 120, width: 1400, height: 900 });
|
||||
const screen = createMockScreen();
|
||||
const detach = attachDisplayRecoveryForPlatform({ win, screen, platform: "darwin" });
|
||||
screen.emit("display-removed", {}, SECONDARY);
|
||||
screen.emit("display-added", {}, SECONDARY);
|
||||
detach();
|
||||
assert.equal(win.setBoundsCalls.length, 0);
|
||||
});
|
||||
|
||||
test("attachDisplayRecovery tolerates a missing screen module", () => {
|
||||
const detach = attachDisplayRecoveryForPlatform({ win: {}, screen: null, platform: "win32" });
|
||||
assert.equal(typeof detach, "function");
|
||||
detach();
|
||||
});
|
||||
300
electron/bridges/windowManager/externalWindows.cjs
Normal file
300
electron/bridges/windowManager/externalWindows.cjs
Normal file
@@ -0,0 +1,300 @@
|
||||
/* eslint-disable no-undef */
|
||||
function createExternalWindowApi(ctx) {
|
||||
with (ctx) {
|
||||
const fallbackBrowserWindows = new Set();
|
||||
|
||||
function resolveAppIconFromOptions(options = {}) {
|
||||
const { appIcon, getAppIcon } = options;
|
||||
if (typeof getAppIcon === "function") {
|
||||
try {
|
||||
const resolved = getAppIcon();
|
||||
if (resolved) return resolved;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return appIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in a minimal in-app BrowserWindow. Used as a fallback when the
|
||||
* host OS cannot open the URL with the system browser (e.g. Tiny11 / Windows
|
||||
* with no default browser configured — error 0x483). The window is
|
||||
* intentionally stripped down:
|
||||
* - no preload script (remote content must NEVER touch contextBridge)
|
||||
* - sandboxed + contextIsolated renderer
|
||||
* - a separate persisted session partition so cookies and storage do not
|
||||
* leak into the main app session
|
||||
*/
|
||||
function openFallbackBrowser(url, options = {}) {
|
||||
const { backgroundColor, appIcon, getAppIcon } = options;
|
||||
const icon = resolveAppIconFromOptions(options);
|
||||
const electron = require("electron");
|
||||
const { BrowserWindow, screen } = electron;
|
||||
|
||||
// Size and center relative to the main window when possible.
|
||||
let bounds = { width: 1100, height: 740 };
|
||||
try {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const mainBounds = mainWindow.getBounds();
|
||||
const display = screen.getDisplayMatching(mainBounds);
|
||||
const area = display.workArea;
|
||||
const w = Math.min(1200, Math.round(area.width * 0.85));
|
||||
const h = Math.min(800, Math.round(area.height * 0.85));
|
||||
bounds = {
|
||||
width: w,
|
||||
height: h,
|
||||
x: Math.round(area.x + (area.width - w) / 2),
|
||||
y: Math.round(area.y + (area.height - h) / 2),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default bounds.
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...bounds,
|
||||
title: url,
|
||||
backgroundColor: backgroundColor || THEME_COLORS[currentTheme]?.background,
|
||||
icon,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
spellcheck: false,
|
||||
webSecurity: true,
|
||||
// Isolated session so users' browsing does not mix with main app state.
|
||||
partition: "persist:netcatty-fallback-browser",
|
||||
},
|
||||
});
|
||||
|
||||
fallbackBrowserWindows.add(win);
|
||||
win.on("closed", () => {
|
||||
fallbackBrowserWindows.delete(win);
|
||||
});
|
||||
|
||||
// Reflect the loaded page title in the window bar; fall back to the URL.
|
||||
try {
|
||||
win.webContents.on("page-title-updated", (_event, title) => {
|
||||
try {
|
||||
win.setTitle(title || url);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Popups inside the fallback browser: open them in another fallback window
|
||||
// rather than looping back through shell.openExternal (which is what
|
||||
// failed in the first place). These popups are fire-and-forget, so we
|
||||
// explicitly catch the `loaded` rejection to avoid unhandledRejection.
|
||||
try {
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
const targetUrl = details?.url;
|
||||
if (targetUrl && typeof targetUrl === "string" && /^https?:/i.test(targetUrl)) {
|
||||
try {
|
||||
const popup = openFallbackBrowser(targetUrl, { backgroundColor, appIcon, getAppIcon });
|
||||
popup.loaded.catch((err) => {
|
||||
console.warn("[windowManager] fallback popup loadURL failed:", err?.message || err);
|
||||
});
|
||||
} catch (popupErr) {
|
||||
console.warn("[windowManager] fallback popup open failed:", popupErr?.message || popupErr);
|
||||
}
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Minimal keyboard navigation: Alt+← / Alt+→ / Ctrl/Cmd+R.
|
||||
try {
|
||||
win.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
try {
|
||||
const history = win.webContents.navigationHistory;
|
||||
if (input.alt && input.key === "ArrowLeft" && history?.canGoBack?.()) {
|
||||
history.goBack();
|
||||
} else if (input.alt && input.key === "ArrowRight" && history?.canGoForward?.()) {
|
||||
history.goForward();
|
||||
} else if ((input.control || input.meta) && typeof input.key === "string" && input.key.toLowerCase() === "r") {
|
||||
win.webContents.reload();
|
||||
}
|
||||
} catch {
|
||||
// ignore navigation errors
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
try {
|
||||
win.show();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Return the window together with its initial-load Promise. Callers that
|
||||
// care about whether the page actually loaded can await `loaded`; fire-
|
||||
// and-forget callers must still catch the rejection themselves to avoid
|
||||
// turning it into an unhandledRejection.
|
||||
const loaded = win.loadURL(url);
|
||||
|
||||
return { window: win, loaded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to open a URL with the OS default browser via shell.openExternal; if
|
||||
* that fails (e.g. no default browser configured), fall back to the in-app
|
||||
* BrowserWindow. Resolves on success (either via system browser, or when
|
||||
* the in-app fallback window finishes its initial load). Throws on total
|
||||
* failure so callers that rely on rejection semantics (e.g. OAuth flows
|
||||
* waiting on a Promise.race) still abort cleanly when no browser path is
|
||||
* available.
|
||||
*/
|
||||
async function tryOpenExternalWithFallback(shell, url, options = {}) {
|
||||
if (!url || typeof url !== "string" || !/^https?:/i.test(url)) {
|
||||
throw new Error("openExternal: invalid URL");
|
||||
}
|
||||
try {
|
||||
await shell?.openExternal?.(url);
|
||||
return;
|
||||
} catch (err) {
|
||||
const message = err?.message || String(err);
|
||||
console.warn("[windowManager] shell.openExternal failed, using in-app fallback:", message);
|
||||
|
||||
let fallback;
|
||||
try {
|
||||
fallback = openFallbackBrowser(url, options);
|
||||
} catch (createErr) {
|
||||
console.warn("[windowManager] fallback browser creation failed:", createErr?.message || createErr);
|
||||
throw err instanceof Error ? err : new Error(message);
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for the fallback window's initial load. If the URL is
|
||||
// unreachable or malformed, loadURL rejects — surface that as a real
|
||||
// failure so callers (e.g. OAuth flows) can cancel early instead of
|
||||
// waiting for a downstream timeout.
|
||||
await fallback.loaded;
|
||||
return;
|
||||
} catch (loadErr) {
|
||||
console.warn("[windowManager] fallback browser loadURL failed:", loadErr?.message || loadErr);
|
||||
try {
|
||||
if (fallback.window && !fallback.window.isDestroyed()) {
|
||||
fallback.window.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createExternalOnlyWindowOpenHandler(shell, options = {}) {
|
||||
return (details) => {
|
||||
const targetUrl = details?.url;
|
||||
if (targetUrl && typeof targetUrl === "string" && /^https?:/i.test(targetUrl)) {
|
||||
// Run async fallback path without blocking the window-open decision.
|
||||
tryOpenExternalWithFallback(shell, targetUrl, options).catch((err) => {
|
||||
console.warn("[windowManager] tryOpenExternalWithFallback threw:", err?.message || err);
|
||||
});
|
||||
}
|
||||
return { action: "deny" };
|
||||
};
|
||||
}
|
||||
|
||||
function createAppWindowOpenHandler(shell, { backgroundColor, appIcon, getAppIcon }) {
|
||||
const allowedPopupHosts = new Set([
|
||||
// OAuth (PKCE loopback)
|
||||
"accounts.google.com",
|
||||
"login.microsoftonline.com",
|
||||
"login.live.com",
|
||||
]);
|
||||
|
||||
const isAllowedInAppPopupUrl = (rawUrl) => {
|
||||
try {
|
||||
const u = new URL(String(rawUrl));
|
||||
if (u.protocol === "https:") {
|
||||
return allowedPopupHosts.has(u.hostname);
|
||||
}
|
||||
if (u.protocol === "http:") {
|
||||
// Allow ONLY the loopback OAuth callback page, and only while an
|
||||
// OAuth flow is actively prepared — the acceptable port matches
|
||||
// whatever oauthBridge just bound for this session.
|
||||
const isLoopback =
|
||||
u.hostname === "127.0.0.1" || u.hostname === "localhost";
|
||||
if (!isLoopback || u.pathname !== "/oauth/callback") return false;
|
||||
const activePort = oauthBridge.getActiveOAuthPort?.();
|
||||
return activePort != null && u.port === String(activePort);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (details) => {
|
||||
const targetUrl = details?.url;
|
||||
const currentIcon = resolveAppIconFromOptions({ appIcon, getAppIcon });
|
||||
if (!targetUrl || typeof targetUrl !== "string" || !/^https?:/i.test(targetUrl)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
// Default: open in system browser to reduce remote-content attack surface.
|
||||
if (!isAllowedInAppPopupUrl(targetUrl)) {
|
||||
// Try system browser first, fall back to an in-app BrowserWindow when
|
||||
// the OS has no handler for the URL (see tryOpenExternalWithFallback).
|
||||
tryOpenExternalWithFallback(shell, targetUrl, {
|
||||
backgroundColor,
|
||||
appIcon: currentIcon,
|
||||
getAppIcon,
|
||||
}).catch((err) => {
|
||||
console.warn("[windowManager] tryOpenExternalWithFallback threw:", err?.message || err);
|
||||
});
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
const size = parseWindowOpenFeatures(details?.features);
|
||||
return {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
width: size.width || OAUTH_DEFAULT_WIDTH,
|
||||
height: size.height || OAUTH_DEFAULT_HEIGHT,
|
||||
minWidth: 420,
|
||||
minHeight: 560,
|
||||
backgroundColor,
|
||||
icon: currentIcon,
|
||||
autoHideMenuBar: true,
|
||||
menuBarVisible: false,
|
||||
title: "Netcatty Authorization",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// Sandboxed because this window renders remote content and does not need a preload bridge.
|
||||
sandbox: true,
|
||||
spellcheck: false,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
openFallbackBrowser,
|
||||
tryOpenExternalWithFallback,
|
||||
createExternalOnlyWindowOpenHandler,
|
||||
createAppWindowOpenHandler,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createExternalWindowApi };
|
||||
588
electron/bridges/windowManager/mainWindow.cjs
Normal file
588
electron/bridges/windowManager/mainWindow.cjs
Normal file
@@ -0,0 +1,588 @@
|
||||
/* 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,
|
||||
};
|
||||
347
electron/bridges/windowManager/settingsWindow.cjs
Normal file
347
electron/bridges/windowManager/settingsWindow.cjs
Normal file
@@ -0,0 +1,347 @@
|
||||
/* eslint-disable no-undef */
|
||||
const {
|
||||
windowsFramelessContentChromeOptions,
|
||||
} = require("./windowsWindowChrome.cjs");
|
||||
|
||||
function createSettingsWindowApi(ctx) {
|
||||
// The extracted window helpers intentionally share the parent window-manager state.
|
||||
with (ctx) {
|
||||
function restoreWindowInputFocus(win, options = {}) {
|
||||
if (!win || win.isDestroyed()) return false;
|
||||
const shouldShow = options.show === true;
|
||||
const platform = options.platform || process.platform;
|
||||
|
||||
if (shouldShow) {
|
||||
try {
|
||||
win.show();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
try {
|
||||
win.setAlwaysOnTop(true);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
win.focus();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
try {
|
||||
win.setAlwaysOnTop(false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
win.focus();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.focus();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function showAndFocusWindow(win) {
|
||||
restoreWindowInputFocus(win, { show: true });
|
||||
}
|
||||
|
||||
function isLiveWindow(win) {
|
||||
return Boolean(win && typeof win.isDestroyed === "function" && !win.isDestroyed());
|
||||
}
|
||||
|
||||
function resolveSettingsWindowBounds(
|
||||
electronModule,
|
||||
{ sourceWindow, settingsWidth, settingsHeight } = {},
|
||||
) {
|
||||
const { screen } = electronModule || {};
|
||||
if (!screen || !isLiveWindow(sourceWindow)) return {};
|
||||
|
||||
try {
|
||||
const sourceBounds = sourceWindow.getBounds();
|
||||
const display = screen.getDisplayMatching(sourceBounds);
|
||||
const { x: dx, y: dy, width: dw, height: dh } = display.workArea;
|
||||
return {
|
||||
x: Math.round(dx + (dw - settingsWidth) / 2),
|
||||
y: Math.round(dy + (dh - settingsHeight) / 2),
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function centerSettingsWindowOnSourceDisplay(win, electronModule, sourceWindow) {
|
||||
if (!isLiveWindow(win)) return;
|
||||
let bounds = { width: 980, height: 720 };
|
||||
try {
|
||||
bounds = win.getBounds();
|
||||
} catch {
|
||||
// keep defaults
|
||||
}
|
||||
const nextPosition = resolveSettingsWindowBounds(electronModule, {
|
||||
sourceWindow,
|
||||
settingsWidth: bounds.width,
|
||||
settingsHeight: bounds.height,
|
||||
});
|
||||
if (nextPosition.x === undefined || nextPosition.y === undefined) return;
|
||||
try {
|
||||
win.setPosition(nextPosition.x, nextPosition.y);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir, sourceWindow } = options;
|
||||
|
||||
// If settings window already exists, show and focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
centerSettingsWindowOnSourceDisplay(settingsWindow, electronModule, sourceWindow || mainWindow);
|
||||
showAndFocusWindow(settingsWindow);
|
||||
return settingsWindow;
|
||||
}
|
||||
|
||||
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 themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
|
||||
|
||||
// Center the settings window on the same display as the main window
|
||||
const settingsWidth = 980;
|
||||
const settingsHeight = 720;
|
||||
const { x: settingsX, y: settingsY } = resolveSettingsWindowBounds(electronModule, {
|
||||
sourceWindow: sourceWindow || mainWindow,
|
||||
settingsWidth,
|
||||
settingsHeight,
|
||||
});
|
||||
|
||||
const windowsChrome = !isMac ? windowsFramelessContentChromeOptions() : {};
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: "netcatty Settings",
|
||||
width: settingsWidth,
|
||||
height: settingsHeight,
|
||||
...(settingsX !== undefined && settingsY !== undefined ? { x: settingsX, y: settingsY } : {}),
|
||||
minWidth: 820,
|
||||
minHeight: 600,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
fullscreenable: !isMac,
|
||||
// NOTE: Do NOT set parent - on macOS this causes rendering issues when dragging
|
||||
// the window to a different screen (the window becomes invisible while still
|
||||
// appearing in "Show All Windows" in the Dock). On Windows it can cause the
|
||||
// main window to close when the settings window is closed.
|
||||
modal: false,
|
||||
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,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
settingsWindow = win;
|
||||
|
||||
// Open external links in system browser by default, and allow only known OAuth hosts in-app.
|
||||
try {
|
||||
win.webContents?.setWindowOpenHandler?.(
|
||||
createAppWindowOpenHandler(shell, {
|
||||
backgroundColor,
|
||||
appIcon,
|
||||
getAppIcon: () => resolveLiveAppIcon(appIcon),
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Never allow chained popups from remote content windows spawned from settings.
|
||||
win.webContents?.on?.("did-create-window", (childWindow) => {
|
||||
try {
|
||||
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Same navigation hardening as the main window (settings has preload access too).
|
||||
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 {
|
||||
return allowedOrigins.has(new URL(String(targetUrl)).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const blockUntrustedNavigation = (event, targetUrl) => {
|
||||
if (isAllowedTopLevelUrl(targetUrl)) return;
|
||||
try {
|
||||
event.preventDefault();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
debugLog("Blocked navigation to untrusted origin (settings)", { targetUrl });
|
||||
};
|
||||
win.webContents.on("will-navigate", blockUntrustedNavigation);
|
||||
win.webContents.on("will-redirect", blockUntrustedNavigation);
|
||||
|
||||
if (isMac) {
|
||||
try {
|
||||
win.setWindowButtonVisibility(true);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
win.on("leave-full-screen", () => {
|
||||
safeSend("netcatty:window:fullscreen-changed", false);
|
||||
});
|
||||
|
||||
// Ensure native background matches frontend background, even before first paint.
|
||||
try {
|
||||
win.setBackgroundColor(backgroundColor);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
applyWindowOpacityToWindow(win);
|
||||
|
||||
// Hide instead of close so the window can be reused instantly.
|
||||
// When the app is quitting, allow normal close/destroy.
|
||||
win.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
win.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up reference when actually destroyed
|
||||
win.on('closed', () => {
|
||||
settingsWindow = null;
|
||||
});
|
||||
|
||||
// Prevent HTML <title> from overriding the window title
|
||||
win.on('page-title-updated', (e) => { e.preventDefault(); });
|
||||
|
||||
// Load the settings page
|
||||
const settingsPath = '/#/settings';
|
||||
|
||||
if (isDev) {
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
await win.loadURL(`${baseUrl}${settingsPath}`);
|
||||
if (showOnLoad) { showAndFocusWindow(win); }
|
||||
return win;
|
||||
} catch (e) {
|
||||
console.warn("Dev server not reachable for settings window", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html#/settings");
|
||||
if (showOnLoad) { showAndFocusWindow(win); }
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the settings window (used when the app is quitting).
|
||||
*/
|
||||
function closeSettingsWindow() {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
try {
|
||||
settingsWindow.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
settingsWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the settings window without destroying it (used when main window hides to tray).
|
||||
*/
|
||||
function hideSettingsWindow() {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
try {
|
||||
settingsWindow.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm the settings window in the background so that opening it later is instant.
|
||||
* The window is created hidden and fully loaded; `openSettingsWindow` will simply show it.
|
||||
*/
|
||||
async function prewarmSettingsWindow(electronModule, options) {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) return;
|
||||
try {
|
||||
await openSettingsWindow(electronModule, options, { showOnLoad: false });
|
||||
} catch (err) {
|
||||
debugLog("Failed to pre-warm settings window", { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
restoreWindowInputFocus,
|
||||
showAndFocusWindow,
|
||||
isLiveWindow,
|
||||
resolveSettingsWindowBounds,
|
||||
centerSettingsWindowOnSourceDisplay,
|
||||
openSettingsWindow,
|
||||
closeSettingsWindow,
|
||||
hideSettingsWindow,
|
||||
prewarmSettingsWindow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createSettingsWindowApi };
|
||||
321
electron/bridges/windowManager/terminalPopupWindow.cjs
Normal file
321
electron/bridges/windowManager/terminalPopupWindow.cjs
Normal file
@@ -0,0 +1,321 @@
|
||||
/* 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 };
|
||||
284
electron/bridges/windowManager/terminalPopupWindow.test.cjs
Normal file
284
electron/bridges/windowManager/terminalPopupWindow.test.cjs
Normal file
@@ -0,0 +1,284 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { createAppContentWindowClosedHandler } = require("../../appWindowLifecycle.cjs");
|
||||
const { createTerminalPopupWindowApi } = require("./terminalPopupWindow.cjs");
|
||||
const { markAttachPopupClosePrepared } = require("../terminalAttachRestore.cjs");
|
||||
|
||||
test("terminal popups participate in the last app-content-window lifecycle", async () => {
|
||||
const appContentWindows = new Set();
|
||||
let quitCalls = 0;
|
||||
const lifecycleHandler = createAppContentWindowClosedHandler({
|
||||
app: { quit() { quitCalls += 1; } },
|
||||
platform: "win32",
|
||||
windowManager: {
|
||||
getAppContentWindows: () => [...appContentWindows],
|
||||
getIsQuitting: () => false,
|
||||
},
|
||||
});
|
||||
let popupWindow;
|
||||
|
||||
class BrowserWindowStub {
|
||||
constructor() {
|
||||
popupWindow = this;
|
||||
this.handlers = new Map();
|
||||
this.webContents = {
|
||||
id: 42,
|
||||
on() {},
|
||||
send() {},
|
||||
setWindowOpenHandler() {},
|
||||
};
|
||||
}
|
||||
|
||||
on(channel, handler) { this.handlers.set(channel, handler); }
|
||||
isDestroyed() { return false; }
|
||||
isVisible() { return true; }
|
||||
loadURL() { return Promise.resolve(); }
|
||||
setBackgroundColor() {}
|
||||
}
|
||||
|
||||
const api = createTerminalPopupWindowApi({
|
||||
mainWindow: null,
|
||||
currentTheme: "light",
|
||||
V8_CACHE_OPTIONS: "bypassHeatCheck",
|
||||
resolveFrontendBackgroundColor() { return "#fff"; },
|
||||
resolveSettingsWindowBounds() { return { x: 10, y: 20 }; },
|
||||
createExternalOnlyWindowOpenHandler() { return {}; },
|
||||
applyWindowOpacityToWindow() {},
|
||||
getDevRendererBaseUrl(url) { return url; },
|
||||
showAndFocusWindow() {},
|
||||
registerAppContentWindow(win) { appContentWindows.add(win); },
|
||||
unregisterAppContentWindow(win) { appContentWindows.delete(win); },
|
||||
notifyAppContentWindowClosed() { lifecycleHandler(); },
|
||||
});
|
||||
|
||||
const result = await api.openTerminalPopupWindow(
|
||||
{
|
||||
BrowserWindow: BrowserWindowStub,
|
||||
nativeTheme: { shouldUseDarkColors: false },
|
||||
shell: {},
|
||||
},
|
||||
{
|
||||
preload: "/tmp/preload.cjs",
|
||||
isDev: false,
|
||||
appIcon: null,
|
||||
isMac: false,
|
||||
electronDir: __dirname,
|
||||
},
|
||||
{ popupId: "popup-1", title: "Terminal" },
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { success: true, popupId: "popup-1" });
|
||||
assert.deepEqual([...appContentWindows], [popupWindow]);
|
||||
await assert.rejects(api.openTerminalPopupWindow(
|
||||
{ BrowserWindow: BrowserWindowStub, nativeTheme: {}, shell: {} },
|
||||
{
|
||||
preload: "/tmp/preload.cjs",
|
||||
isDev: false,
|
||||
appIcon: null,
|
||||
isMac: false,
|
||||
electronDir: __dirname,
|
||||
},
|
||||
{ popupId: "popup-1" },
|
||||
), /already active/);
|
||||
assert.equal(appContentWindows.size, 1);
|
||||
assert.equal(lifecycleHandler(), false);
|
||||
assert.equal(quitCalls, 0);
|
||||
|
||||
popupWindow.handlers.get("closed")?.();
|
||||
assert.deepEqual([...appContentWindows], []);
|
||||
assert.equal(quitCalls, 1);
|
||||
});
|
||||
|
||||
test("a terminal popup that fails to load releases app-content lifecycle state", async () => {
|
||||
const appContentWindows = new Set();
|
||||
let closeNotifications = 0;
|
||||
|
||||
class FailingBrowserWindowStub {
|
||||
constructor() {
|
||||
this.destroyed = false;
|
||||
this.handlers = new Map();
|
||||
this.webContents = {
|
||||
id: 43,
|
||||
on() {},
|
||||
send() {},
|
||||
setWindowOpenHandler() {},
|
||||
};
|
||||
}
|
||||
|
||||
on(channel, handler) { this.handlers.set(channel, handler); }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
loadURL() { return Promise.reject(new Error("renderer unavailable")); }
|
||||
setBackgroundColor() {}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.handlers.get("closed")?.();
|
||||
}
|
||||
}
|
||||
|
||||
const api = createTerminalPopupWindowApi({
|
||||
mainWindow: null,
|
||||
currentTheme: "light",
|
||||
V8_CACHE_OPTIONS: "bypassHeatCheck",
|
||||
resolveFrontendBackgroundColor() { return "#fff"; },
|
||||
resolveSettingsWindowBounds() { return {}; },
|
||||
createExternalOnlyWindowOpenHandler() { return {}; },
|
||||
applyWindowOpacityToWindow() {},
|
||||
getDevRendererBaseUrl(url) { return url; },
|
||||
showAndFocusWindow() {},
|
||||
registerAppContentWindow(win) { appContentWindows.add(win); },
|
||||
unregisterAppContentWindow(win) { appContentWindows.delete(win); },
|
||||
notifyAppContentWindowClosed() { closeNotifications += 1; },
|
||||
});
|
||||
|
||||
await assert.rejects(api.openTerminalPopupWindow(
|
||||
{
|
||||
BrowserWindow: FailingBrowserWindowStub,
|
||||
nativeTheme: { shouldUseDarkColors: false },
|
||||
shell: {},
|
||||
},
|
||||
{
|
||||
preload: "/tmp/preload.cjs",
|
||||
isDev: false,
|
||||
appIcon: null,
|
||||
isMac: false,
|
||||
electronDir: __dirname,
|
||||
},
|
||||
{ popupId: "failed-popup" },
|
||||
), /renderer unavailable/);
|
||||
|
||||
assert.deepEqual([...appContentWindows], []);
|
||||
assert.equal(closeNotifications, 1);
|
||||
});
|
||||
|
||||
test("popup close failures cannot leave a ghost app-content window", async () => {
|
||||
const appContentWindows = new Set();
|
||||
let closeNotifications = 0;
|
||||
let popupWindow;
|
||||
|
||||
class CloseFailingBrowserWindowStub {
|
||||
constructor() {
|
||||
popupWindow = this;
|
||||
this.handlers = new Map();
|
||||
this.webContents = {
|
||||
id: 44,
|
||||
on() {},
|
||||
send() {},
|
||||
setWindowOpenHandler() {},
|
||||
};
|
||||
}
|
||||
|
||||
on(channel, handler) { this.handlers.set(channel, handler); }
|
||||
isDestroyed() { return false; }
|
||||
loadURL() { return Promise.resolve(); }
|
||||
setBackgroundColor() {}
|
||||
close() { throw new Error("close failed"); }
|
||||
destroy() { throw new Error("destroy failed"); }
|
||||
}
|
||||
|
||||
const api = createTerminalPopupWindowApi({
|
||||
mainWindow: null,
|
||||
currentTheme: "light",
|
||||
V8_CACHE_OPTIONS: "bypassHeatCheck",
|
||||
resolveFrontendBackgroundColor() { return "#fff"; },
|
||||
resolveSettingsWindowBounds() { return {}; },
|
||||
createExternalOnlyWindowOpenHandler() { return {}; },
|
||||
applyWindowOpacityToWindow() {},
|
||||
getDevRendererBaseUrl(url) { return url; },
|
||||
showAndFocusWindow() {},
|
||||
registerAppContentWindow(win) { appContentWindows.add(win); },
|
||||
unregisterAppContentWindow(win) { appContentWindows.delete(win); },
|
||||
notifyAppContentWindowClosed() { closeNotifications += 1; },
|
||||
});
|
||||
|
||||
await api.openTerminalPopupWindow(
|
||||
{ BrowserWindow: CloseFailingBrowserWindowStub, nativeTheme: {}, shell: {} },
|
||||
{
|
||||
preload: "/tmp/preload.cjs",
|
||||
isDev: false,
|
||||
appIcon: null,
|
||||
isMac: false,
|
||||
electronDir: __dirname,
|
||||
},
|
||||
{ popupId: "close-failure" },
|
||||
);
|
||||
assert.deepEqual([...appContentWindows], [popupWindow]);
|
||||
assert.doesNotThrow(() => api.closeTerminalPopupWindow("close-failure"));
|
||||
assert.deepEqual([...appContentWindows], []);
|
||||
assert.equal(closeNotifications, 1);
|
||||
});
|
||||
|
||||
test("attach popup waits for renderer handoff before closing", async () => {
|
||||
const sent = [];
|
||||
let popupWindow;
|
||||
class AttachBrowserWindowStub {
|
||||
constructor() {
|
||||
popupWindow = this;
|
||||
this.destroyed = false;
|
||||
this.handlers = new Map();
|
||||
this.webContents = {
|
||||
id: 45,
|
||||
on() {},
|
||||
send(channel, payload) { sent.push({ channel, payload }); },
|
||||
setWindowOpenHandler() {},
|
||||
};
|
||||
}
|
||||
on(channel, handler) { this.handlers.set(channel, handler); }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isVisible() { return true; }
|
||||
loadURL() { return Promise.resolve(); }
|
||||
setBackgroundColor() {}
|
||||
close() {
|
||||
let prevented = false;
|
||||
this.handlers.get("close")?.({ preventDefault() { prevented = true; } });
|
||||
if (!prevented) {
|
||||
this.destroyed = true;
|
||||
this.handlers.get("closed")?.();
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.handlers.get("closed")?.();
|
||||
}
|
||||
}
|
||||
const api = createTerminalPopupWindowApi({
|
||||
mainWindow: null,
|
||||
currentTheme: "light",
|
||||
V8_CACHE_OPTIONS: "bypassHeatCheck",
|
||||
resolveFrontendBackgroundColor() { return "#fff"; },
|
||||
resolveSettingsWindowBounds() { return {}; },
|
||||
createExternalOnlyWindowOpenHandler() { return {}; },
|
||||
applyWindowOpacityToWindow() {},
|
||||
getDevRendererBaseUrl(url) { return url; },
|
||||
showAndFocusWindow() {},
|
||||
registerAppContentWindow() {},
|
||||
unregisterAppContentWindow() {},
|
||||
notifyAppContentWindowClosed() {},
|
||||
});
|
||||
|
||||
await api.openTerminalPopupWindow(
|
||||
{ BrowserWindow: AttachBrowserWindowStub, nativeTheme: {}, shell: {} },
|
||||
{ preload: "/tmp/preload.cjs", isDev: false, appIcon: null, isMac: false, electronDir: __dirname },
|
||||
{
|
||||
popupId: "attach-popup",
|
||||
title: "Attach",
|
||||
attachSessionId: "session-1",
|
||||
sourceSession: { id: "session-1" },
|
||||
},
|
||||
);
|
||||
const config = sent.find((entry) => entry.channel === "netcatty:window:terminalPopupConfig")?.payload;
|
||||
assert.equal(typeof config.attachAuthorization, "string");
|
||||
|
||||
api.closeTerminalPopupWindow("attach-popup");
|
||||
assert.equal(popupWindow.destroyed, false);
|
||||
assert.deepEqual(sent.at(-1), {
|
||||
channel: "netcatty:terminal-popup:prepare-close",
|
||||
payload: { sessionId: "session-1", authorization: config.attachAuthorization },
|
||||
});
|
||||
|
||||
assert.equal(markAttachPopupClosePrepared(config.attachAuthorization, "session-1", 45), true);
|
||||
api.closeTerminalPopupWindow("attach-popup");
|
||||
assert.equal(popupWindow.destroyed, true);
|
||||
});
|
||||
56
electron/bridges/windowManager/windowsWindowChrome.cjs
Normal file
56
electron/bridges/windowManager/windowsWindowChrome.cjs
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Windows frameless window chrome helpers.
|
||||
*
|
||||
* Background (#2505):
|
||||
* - Frameless Win11 windows need an explicit `roundedCorners: true` so DWM
|
||||
* applies the system round clip (Electron 34+).
|
||||
* - Transparent tray popups that keep the default opaque white backdrop + CSS
|
||||
* `border-radius` produce square tips under a rounded panel.
|
||||
*
|
||||
* Approach:
|
||||
* - App content windows (resizable): solid host + native `roundedCorners`.
|
||||
* Do NOT set `transparent: true` here - Electron documents transparent
|
||||
* windows as not resizable, and `resizable: true` can break them
|
||||
* (https://www.electronjs.org/docs/latest/tutorial/custom-window-styles).
|
||||
* - Tray / CSS-shaped popovers (`resizable: false`): transparent host + clear
|
||||
* backdrop + `roundedCorners: false` so only the CSS radius defines the
|
||||
* silhouette (Electron #46468: Win11 otherwise forces OS rounding on
|
||||
* transparent windows).
|
||||
*/
|
||||
|
||||
const CLEAR_BACKGROUND = "#00000000";
|
||||
|
||||
function isWindowsPlatform(platform = process.platform) {
|
||||
return platform === "win32";
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for full-bleed app windows (main / settings / terminal popup).
|
||||
* Safe to spread on non-Windows - returns an empty object.
|
||||
*/
|
||||
function windowsFramelessContentChromeOptions(platform = process.platform) {
|
||||
if (!isWindowsPlatform(platform)) return {};
|
||||
return {
|
||||
roundedCorners: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for CSS-rounded overlay windows (tray panel).
|
||||
* Always apply when creating those windows; callers already opt into transparency.
|
||||
*/
|
||||
function windowsCssRoundedOverlayChromeOptions(platform = process.platform) {
|
||||
return {
|
||||
transparent: true,
|
||||
backgroundColor: CLEAR_BACKGROUND,
|
||||
// Overlay shape comes from CSS; keep OS rounding off on Win11.
|
||||
...(isWindowsPlatform(platform) ? { roundedCorners: false } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CLEAR_BACKGROUND,
|
||||
isWindowsPlatform,
|
||||
windowsFramelessContentChromeOptions,
|
||||
windowsCssRoundedOverlayChromeOptions,
|
||||
};
|
||||
104
electron/bridges/windowManager/windowsWindowChrome.test.cjs
Normal file
104
electron/bridges/windowManager/windowsWindowChrome.test.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const { readFileSync } = require("node:fs");
|
||||
|
||||
const {
|
||||
CLEAR_BACKGROUND,
|
||||
isWindowsPlatform,
|
||||
windowsFramelessContentChromeOptions,
|
||||
windowsCssRoundedOverlayChromeOptions,
|
||||
} = require("./windowsWindowChrome.cjs");
|
||||
|
||||
test("CLEAR_BACKGROUND is fully transparent ARGB", () => {
|
||||
assert.equal(CLEAR_BACKGROUND, "#00000000");
|
||||
});
|
||||
|
||||
test("isWindowsPlatform detects win32 only", () => {
|
||||
assert.equal(isWindowsPlatform("win32"), true);
|
||||
assert.equal(isWindowsPlatform("darwin"), false);
|
||||
assert.equal(isWindowsPlatform("linux"), false);
|
||||
});
|
||||
|
||||
test("content chrome is a no-op outside Windows", () => {
|
||||
assert.deepEqual(windowsFramelessContentChromeOptions("darwin"), {});
|
||||
assert.deepEqual(windowsFramelessContentChromeOptions("linux"), {});
|
||||
});
|
||||
|
||||
test("content chrome enables native rounding without transparency on Windows", () => {
|
||||
assert.deepEqual(windowsFramelessContentChromeOptions("win32"), {
|
||||
roundedCorners: true,
|
||||
});
|
||||
assert.equal(
|
||||
Object.hasOwn(windowsFramelessContentChromeOptions("win32"), "transparent"),
|
||||
false,
|
||||
"resizable app windows must not use transparent hosts",
|
||||
);
|
||||
});
|
||||
|
||||
test("CSS overlay chrome clears the opaque backdrop on every platform", () => {
|
||||
assert.deepEqual(windowsCssRoundedOverlayChromeOptions("darwin"), {
|
||||
transparent: true,
|
||||
backgroundColor: CLEAR_BACKGROUND,
|
||||
});
|
||||
assert.deepEqual(windowsCssRoundedOverlayChromeOptions("linux"), {
|
||||
transparent: true,
|
||||
backgroundColor: CLEAR_BACKGROUND,
|
||||
});
|
||||
});
|
||||
|
||||
test("CSS overlay chrome disables OS rounding on Windows", () => {
|
||||
assert.deepEqual(windowsCssRoundedOverlayChromeOptions("win32"), {
|
||||
transparent: true,
|
||||
backgroundColor: CLEAR_BACKGROUND,
|
||||
roundedCorners: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("main/settings/tray call sites wire Windows chrome helpers", () => {
|
||||
const here = __dirname;
|
||||
const main = readFileSync(path.join(here, "mainWindow.cjs"), "utf8");
|
||||
const settings = readFileSync(path.join(here, "settingsWindow.cjs"), "utf8");
|
||||
const popup = readFileSync(path.join(here, "terminalPopupWindow.cjs"), "utf8");
|
||||
const tray = readFileSync(path.join(here, "../globalShortcutBridge.cjs"), "utf8");
|
||||
const css = readFileSync(path.join(here, "../../../index.css"), "utf8");
|
||||
const html = readFileSync(path.join(here, "../../../index.html"), "utf8");
|
||||
const helper = readFileSync(path.join(here, "windowsWindowChrome.cjs"), "utf8");
|
||||
|
||||
for (const [label, source] of [
|
||||
["mainWindow", main],
|
||||
["settingsWindow", settings],
|
||||
["terminalPopupWindow", popup],
|
||||
]) {
|
||||
assert.match(source, /require\("\.\/windowsWindowChrome\.cjs"\)/, `${label} must require chrome helpers`);
|
||||
assert.match(source, /windowsFramelessContentChromeOptions/, `${label} must use content chrome helper`);
|
||||
assert.doesNotMatch(source, /resolveFramelessHostBackgroundColor/, `${label} should not clear host backdrop`);
|
||||
const requireIndex = source.indexOf('require("./windowsWindowChrome.cjs")');
|
||||
const withIndex = source.indexOf("with (ctx)");
|
||||
assert.ok(
|
||||
requireIndex !== -1 && withIndex !== -1 && requireIndex < withIndex,
|
||||
`${label}: require chrome helpers before with(ctx) so injected require cannot remount the path`,
|
||||
);
|
||||
}
|
||||
assert.match(tray, /windowsCssRoundedOverlayChromeOptions/);
|
||||
assert.match(tray, /#2505/);
|
||||
assert.match(css, /html\.tray-window/);
|
||||
assert.match(css, /html\.tray-window \.splash-screen/);
|
||||
assert.match(html, /tray-window/);
|
||||
assert.match(html, /removeTraySplash|splash\.remove\(\)/);
|
||||
assert.match(tray, /trayPanelShowWhenReady|trayPanelReady/);
|
||||
assert.match(tray, /isOpenOrPending/);
|
||||
assert.match(helper, /square tips under a rounded panel/);
|
||||
assert.match(helper, /not resizable/);
|
||||
assert.doesNotMatch(helper, /[^\x00-\x7F]/, "helper comments must stay ASCII-only");
|
||||
const contentHelperMatch = helper.match(
|
||||
/function windowsFramelessContentChromeOptions\([\s\S]*?\n\}/,
|
||||
);
|
||||
assert.ok(contentHelperMatch, "content chrome helper must exist");
|
||||
assert.match(contentHelperMatch[0], /roundedCorners:\s*true/);
|
||||
assert.doesNotMatch(
|
||||
contentHelperMatch[0],
|
||||
/transparent/,
|
||||
"content chrome must stay opaque/resizable",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user