[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:
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,
|
||||
};
|
||||
Reference in New Issue
Block a user