Skip to content

Commit b8ab220

Browse files
CtrlAltDaletariknz
andauthored
fix: keep the settings window reachable when displays change (#655)
Co-authored-by: Tarik Alani <tarik.nzl@gmail.com>
1 parent e3dd77a commit b8ab220

5 files changed

Lines changed: 526 additions & 3 deletions

File tree

src/app/overlayManager.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import { Notification } from 'electron';
1010
import { readData, writeData } from './storage/storage';
1111
import { getDashboard } from './storage/dashboards';
1212
import { getChromiumFlags, parseCustomSwitches } from './storage/chromiumFlags';
13-
import { trackSettingsWindowMovement } from './trackWindowMovement';
13+
import {
14+
markCorrectedBounds,
15+
trackSettingsWindowMovement,
16+
} from './trackWindowMovement';
17+
import { sanitizeWindowBounds } from './windowBounds';
1418
import logger from './logger';
1519
import { createRendererPerfArguments } from './perfRendererArguments';
1620

@@ -911,7 +915,13 @@ export class OverlayManager {
911915
// Reveal the window once its content is ready, unless it should start
912916
// minimized to the system tray (the "Start minimized" general setting).
913917
browserWindow.once('ready-to-show', () => {
914-
if (browserWindow.isDestroyed() || startHidden) return;
918+
if (browserWindow.isDestroyed()) return;
919+
920+
// Runs before the startHidden check: a window that starts in the tray is
921+
// shown later, and would otherwise be revealed somewhere unreachable.
922+
ensureWindowOnScreen(browserWindow);
923+
924+
if (startHidden) return;
915925
browserWindow.show();
916926
browserWindow.focus();
917927
});
@@ -957,6 +967,61 @@ export class OverlayManager {
957967
}
958968
}
959969

970+
/**
971+
* Correct a window that has ended up where no display covers it.
972+
*
973+
* Validating the saved bounds on the way in only guards the restore path. This
974+
* checks where the window actually landed, so it also catches a window placed
975+
* off-screen by something other than a stale saved position — Electron's own
976+
* default placement, or a display set that was not fully enumerated when the
977+
* window was created. #539 was reported on a freshly installed Windows with no
978+
* saved bounds at all, which the restore-path check cannot explain, so the
979+
* guard is deliberately cause-agnostic: it asks only whether the window can be
980+
* reached, never why it could not be.
981+
*/
982+
function ensureWindowOnScreen(browserWindow: BrowserWindow): void {
983+
const actual = browserWindow.getBounds();
984+
const corrected = sanitizeWindowBounds(
985+
actual,
986+
screen.getAllDisplays().map((display) => display.workArea),
987+
screen.getPrimaryDisplay().workArea
988+
);
989+
990+
if (!corrected) return;
991+
if (corrected.x === actual.x && corrected.y === actual.y) return;
992+
993+
logger.warn(
994+
`[OverlayManager] Settings window opened off-screen at x=${actual.x}, ` +
995+
`y=${actual.y}; moved to x=${corrected.x}, y=${corrected.y}`
996+
);
997+
998+
// Flagged before the move so the saved position survives it. Rescuing a
999+
// window must not overwrite where the user put it, or reconnecting the
1000+
// monitor would no longer bring it back.
1001+
markCorrectedBounds(browserWindow, corrected);
1002+
browserWindow.setBounds(corrected);
1003+
}
1004+
9601005
function loadWindowBounds(): Electron.Rectangle | undefined {
961-
return readData<Electron.Rectangle>('settingsWindowBounds');
1006+
const saved = readData<Electron.Rectangle>('settingsWindowBounds');
1007+
if (!saved) return undefined;
1008+
1009+
// Saved bounds outlive the display arrangement that produced them, so they
1010+
// are validated against the displays connected right now — otherwise a
1011+
// window saved on a monitor that has since been unplugged or rearranged is
1012+
// restored somewhere unreachable.
1013+
const bounds = sanitizeWindowBounds(
1014+
saved,
1015+
screen.getAllDisplays().map((display) => display.workArea),
1016+
screen.getPrimaryDisplay().workArea
1017+
);
1018+
1019+
if (bounds && (bounds.x !== saved.x || bounds.y !== saved.y)) {
1020+
logger.info(
1021+
`[OverlayManager] Saved settings window bounds were off-screen ` +
1022+
`(x=${saved.x}, y=${saved.y}); moved to x=${bounds.x}, y=${bounds.y}`
1023+
);
1024+
}
1025+
1026+
return bounds;
9621027
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { BrowserWindow } from 'electron';
3+
import {
4+
markCorrectedBounds,
5+
trackSettingsWindowMovement,
6+
} from './trackWindowMovement';
7+
import { writeData } from './storage/storage';
8+
9+
vi.mock('./storage/storage', () => ({ writeData: vi.fn() }));
10+
11+
const DEBOUNCE_MS = 200;
12+
13+
/**
14+
* Minimal stand-in for the parts of BrowserWindow this module uses: it records
15+
* the handlers registered for 'moved' and 'resized' so a test can raise them,
16+
* and returns whatever bounds the test last set.
17+
*/
18+
function fakeWindow(initial: Electron.Rectangle) {
19+
const handlers: Record<string, (() => void)[]> = {};
20+
let bounds = initial;
21+
22+
const win = {
23+
on: (event: string, handler: () => void) => {
24+
(handlers[event] ??= []).push(handler);
25+
return win;
26+
},
27+
getBounds: () => bounds,
28+
};
29+
30+
return {
31+
win: win as unknown as BrowserWindow,
32+
setBounds: (next: Electron.Rectangle) => {
33+
bounds = next;
34+
},
35+
emit: (event: string) => (handlers[event] ?? []).forEach((h) => h()),
36+
};
37+
}
38+
39+
const ON_SCREEN = { x: 100, y: 100, width: 800, height: 700 };
40+
const OFF_SCREEN = { x: -20000, y: 250, width: 800, height: 700 };
41+
const RESCUED = { x: 622, y: 200, width: 800, height: 700 };
42+
43+
describe('trackSettingsWindowMovement', () => {
44+
beforeEach(() => {
45+
vi.useFakeTimers();
46+
vi.mocked(writeData).mockClear();
47+
});
48+
49+
afterEach(() => {
50+
vi.useRealTimers();
51+
});
52+
53+
it('saves bounds after the user moves the window', () => {
54+
const { win, setBounds, emit } = fakeWindow(ON_SCREEN);
55+
trackSettingsWindowMovement(win);
56+
57+
setBounds({ ...ON_SCREEN, x: 300 });
58+
emit('moved');
59+
vi.advanceTimersByTime(DEBOUNCE_MS);
60+
61+
expect(writeData).toHaveBeenCalledWith('settingsWindowBounds', {
62+
...ON_SCREEN,
63+
x: 300,
64+
});
65+
});
66+
67+
it('saves bounds after the user resizes the window', () => {
68+
const { win, setBounds, emit } = fakeWindow(ON_SCREEN);
69+
trackSettingsWindowMovement(win);
70+
71+
setBounds({ ...ON_SCREEN, width: 900 });
72+
emit('resized');
73+
vi.advanceTimersByTime(DEBOUNCE_MS);
74+
75+
expect(writeData).toHaveBeenCalledOnce();
76+
});
77+
78+
it('debounces a burst of events into one save', () => {
79+
const { win, setBounds, emit } = fakeWindow(ON_SCREEN);
80+
trackSettingsWindowMovement(win);
81+
82+
setBounds({ ...ON_SCREEN, x: 200 });
83+
emit('moved');
84+
emit('moved');
85+
emit('moved');
86+
vi.advanceTimersByTime(DEBOUNCE_MS);
87+
88+
expect(writeData).toHaveBeenCalledOnce();
89+
});
90+
91+
it('does not persist a position the app corrected to', () => {
92+
// The window was saved off-screen, so startup rescues it. That rescue must
93+
// not overwrite the saved position, or reconnecting the monitor would no
94+
// longer bring the window back to where the user had put it.
95+
const { win, setBounds, emit } = fakeWindow(OFF_SCREEN);
96+
trackSettingsWindowMovement(win);
97+
98+
markCorrectedBounds(win, RESCUED);
99+
setBounds(RESCUED);
100+
// Raised explicitly. Electron does not currently emit these for a
101+
// programmatic setBounds, so the saved position survives today by accident;
102+
// this asserts it survives even if that changes.
103+
emit('moved');
104+
emit('resized');
105+
vi.advanceTimersByTime(DEBOUNCE_MS);
106+
107+
expect(writeData).not.toHaveBeenCalled();
108+
});
109+
110+
it('resumes saving once the user moves the window themselves', () => {
111+
const { win, setBounds, emit } = fakeWindow(OFF_SCREEN);
112+
trackSettingsWindowMovement(win);
113+
114+
markCorrectedBounds(win, RESCUED);
115+
setBounds(RESCUED);
116+
emit('moved');
117+
vi.advanceTimersByTime(DEBOUNCE_MS);
118+
expect(writeData).not.toHaveBeenCalled();
119+
120+
// The user drags it somewhere of their own choosing.
121+
const chosen = { ...RESCUED, x: 900, y: 400 };
122+
setBounds(chosen);
123+
emit('moved');
124+
vi.advanceTimersByTime(DEBOUNCE_MS);
125+
126+
expect(writeData).toHaveBeenCalledWith('settingsWindowBounds', chosen);
127+
});
128+
129+
it('only suppresses the window that was corrected', () => {
130+
const a = fakeWindow(ON_SCREEN);
131+
const b = fakeWindow(ON_SCREEN);
132+
trackSettingsWindowMovement(a.win);
133+
trackSettingsWindowMovement(b.win);
134+
135+
markCorrectedBounds(a.win, RESCUED);
136+
a.setBounds(RESCUED);
137+
b.setBounds(RESCUED);
138+
139+
a.emit('moved');
140+
b.emit('moved');
141+
vi.advanceTimersByTime(DEBOUNCE_MS);
142+
143+
expect(writeData).toHaveBeenCalledOnce();
144+
expect(writeData).toHaveBeenCalledWith('settingsWindowBounds', RESCUED);
145+
});
146+
});

src/app/trackWindowMovement.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,38 @@ import { writeData } from './storage/storage';
33

44
const DEBOUNCE_MS = 200;
55

6+
/**
7+
* Bounds the app itself moved a window to, as opposed to the user dragging it.
8+
*
9+
* A window rescued from off-screen is repositioned with `setBounds`. If that
10+
* were persisted it would overwrite the position the user actually chose, and
11+
* reconnecting the monitor would no longer bring the window back — turning a
12+
* temporary rescue into a permanent move.
13+
*
14+
* A programmatic `setBounds` does not currently raise `moved` or `resized`, so
15+
* nothing is persisted anyway. That is Electron's present behaviour rather than
16+
* a guarantee, and nothing else pins it. Recording the corrected rectangle and
17+
* declining to save it makes the outcome deliberate instead of incidental.
18+
*
19+
* A WeakMap so a destroyed window takes its entry with it.
20+
*/
21+
const correctedBounds = new WeakMap<BrowserWindow, Electron.Rectangle>();
22+
23+
const sameRect = (a: Electron.Rectangle, b: Electron.Rectangle): boolean =>
24+
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
25+
26+
/**
27+
* Record that this window was moved by the app rather than by the user, so the
28+
* resulting position is not written back to disk. Call it alongside the
29+
* `setBounds` that performs the correction.
30+
*/
31+
export const markCorrectedBounds = (
32+
browserWindow: BrowserWindow,
33+
bounds: Electron.Rectangle
34+
): void => {
35+
correctedBounds.set(browserWindow, { ...bounds });
36+
};
37+
638
/**
739
* Track settings window position and size changes
840
*/
@@ -22,5 +54,14 @@ export const trackSettingsWindowMovement = (browserWindow: BrowserWindow) => {
2254

2355
function saveSettingsWindowBounds(browserWindow: BrowserWindow): void {
2456
const bounds = browserWindow.getBounds();
57+
58+
// Compared by value rather than suppressed with a flag and a timer: there is
59+
// no assumption about when, or whether, the event arrives after setBounds.
60+
// A user who drags the window to exactly the corrected position is not saved
61+
// either, which costs nothing — that position is on-screen and the next
62+
// launch would place the window there regardless.
63+
const corrected = correctedBounds.get(browserWindow);
64+
if (corrected && sameRect(bounds, corrected)) return;
65+
2566
writeData('settingsWindowBounds', bounds);
2667
}

0 commit comments

Comments
 (0)