Skip to content

Commit 92f14ff

Browse files
committed
feat: add BrowserWindow restore, isFocused, fullscreen, alwaysOnTop, and destroy on both platforms
1 parent 1df3806 commit 92f14ff

7 files changed

Lines changed: 189 additions & 0 deletions

File tree

src/main/api/browser-window.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,26 @@ export class BrowserWindow extends EventEmitter {
240240
return this.#native.isMinimized();
241241
}
242242

243+
restore(): void {
244+
this.#native.restore();
245+
}
246+
247+
isFocused(): boolean {
248+
return this.#native.isFocused();
249+
}
250+
251+
setFullScreen(flag: boolean): void {
252+
this.#native.setFullScreen(flag);
253+
}
254+
255+
isFullScreen(): boolean {
256+
return this.#native.isFullScreen();
257+
}
258+
259+
setAlwaysOnTop(flag: boolean): void {
260+
this.#native.setAlwaysOnTop(flag);
261+
}
262+
243263
isDestroyed(): boolean {
244264
return this.#destroyed;
245265
}
@@ -248,6 +268,11 @@ export class BrowserWindow extends EventEmitter {
248268
this.#native.close();
249269
}
250270

271+
/** Force-close the window without consulting `close` listeners. */
272+
destroy(): void {
273+
this.#native.destroy();
274+
}
275+
251276
/** All open windows, in creation order. */
252277
static getAllWindows(): BrowserWindow[] {
253278
return [...registry.values()];

src/main/platform/linux/gtk-ffi.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ export const GTK_FFI_SYMBOLS = {
9292
args: [],
9393
returns: FFIType.pointer,
9494
},
95+
gtk_window_fullscreen: {
96+
args: [FFIType.pointer],
97+
returns: FFIType.void,
98+
},
99+
gtk_window_unfullscreen: {
100+
args: [FFIType.pointer],
101+
returns: FFIType.void,
102+
},
103+
gtk_window_is_fullscreen: {
104+
args: [FFIType.pointer],
105+
returns: FFIType.i32,
106+
},
95107
} as const;
96108

97109
const cache: { ffi: ReturnType<typeof dlopen<typeof GTK_FFI_SYMBOLS>> | undefined } = {

src/main/platform/linux/linux-backend.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,35 @@ class LinuxWindow implements NativeWindow {
471471
return this.#minimized;
472472
}
473473

474+
restore(): void {
475+
const gtk = loadGtkFFI();
476+
gtk.symbols.gtk_window_unminimize(this.#window);
477+
this.#minimized = false;
478+
}
479+
480+
isFocused(): boolean {
481+
const gtk = loadGtkFFI();
482+
return gtk.symbols.gtk_window_is_active(this.#window) !== 0;
483+
}
484+
485+
setFullScreen(flag: boolean): void {
486+
const gtk = loadGtkFFI();
487+
if (flag) {
488+
gtk.symbols.gtk_window_fullscreen(this.#window);
489+
} else {
490+
gtk.symbols.gtk_window_unfullscreen(this.#window);
491+
}
492+
}
493+
494+
isFullScreen(): boolean {
495+
const gtk = loadGtkFFI();
496+
return gtk.symbols.gtk_window_is_fullscreen(this.#window) !== 0;
497+
}
498+
499+
setAlwaysOnTop(_flag: boolean): void {
500+
// GTK4 dropped keep-above; no portable client API. No-op (best-effort).
501+
}
502+
474503
close(): void {
475504
if (this.#closed) {
476505
return;
@@ -488,6 +517,15 @@ class LinuxWindow implements NativeWindow {
488517
gtk.symbols.gtk_window_destroy(this.#window);
489518
}
490519

520+
destroy(): void {
521+
if (this.#closed) {
522+
return;
523+
}
524+
// Force-close: run teardown then destroy WITHOUT consulting the veto.
525+
this.#handleClosed();
526+
loadGtkFFI().symbols.gtk_window_destroy(this.#window);
527+
}
528+
491529
onClosed(callback: () => void): void {
492530
this.#closedCallbacks.push(callback);
493531
}

src/main/platform/macos/cocoa-backend.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
msgSendPtr4,
3131
msgSendPtrI64U8Ptr,
3232
msgSendPtrPtr,
33+
msgSendReturnsI64,
3334
msgSendReturnsU8,
3435
msgSendSize,
3536
msgSendU8,
@@ -58,6 +59,10 @@ const log = createLogger('macos-backend');
5859

5960
const NS_BACKING_STORE_BUFFERED = 2n;
6061
const NS_ACTIVATION_POLICY_REGULAR = 0n;
62+
/** `NSWindowStyleMaskFullScreen` (1 << 14). */
63+
const NS_FULLSCREEN_STYLE_MASK = 16384n;
64+
/** `NSFloatingWindowLevel` — above normal windows. */
65+
const NS_FLOATING_WINDOW_LEVEL = 3n;
6166
const WK_INJECTION_TIME_AT_DOCUMENT_START = 0n;
6267
const SCRIPT_MESSAGE_HANDLER_NAME = 'sambar';
6368
/** Page-world handler name `executeJavaScript` posts its result to (D022). */
@@ -443,6 +448,33 @@ class MacOSWindow implements NativeWindow {
443448
return msgSendReturnsU8(this.#window, cocoa().selectors.get('isMiniaturized')) === 1;
444449
}
445450

451+
restore(): void {
452+
msgSendPtr(this.#window, cocoa().selectors.get('deminiaturize:'), 0n);
453+
}
454+
455+
isFocused(): boolean {
456+
return msgSendReturnsU8(this.#window, cocoa().selectors.get('isKeyWindow')) === 1;
457+
}
458+
459+
isFullScreen(): boolean {
460+
const styleMask = msgSendReturnsI64(this.#window, cocoa().selectors.get('styleMask'));
461+
return (styleMask & NS_FULLSCREEN_STYLE_MASK) !== 0n;
462+
}
463+
464+
setFullScreen(flag: boolean): void {
465+
if (flag !== this.isFullScreen()) {
466+
msgSendPtr(this.#window, cocoa().selectors.get('toggleFullScreen:'), 0n);
467+
}
468+
}
469+
470+
setAlwaysOnTop(flag: boolean): void {
471+
msgSendI64(
472+
this.#window,
473+
cocoa().selectors.get('setLevel:'),
474+
flag ? NS_FLOATING_WINDOW_LEVEL : 0n,
475+
);
476+
}
477+
446478
close(): void {
447479
if (this.#tornDown) {
448480
return;
@@ -455,6 +487,15 @@ class MacOSWindow implements NativeWindow {
455487
msgSendPtr(this.#window, cocoa().selectors.get('performClose:'), 0n);
456488
}
457489

490+
destroy(): void {
491+
if (this.#tornDown) {
492+
return;
493+
}
494+
// `-close` (not `-performClose:`) fires windowWillClose: (teardown + closed)
495+
// WITHOUT windowShouldClose:, so it bypasses the preventable veto.
496+
cocoa().msgSend(this.#window, cocoa().selectors.get('close'));
497+
}
498+
458499
onClosed(callback: () => void): void {
459500
this.#onClosed = callback;
460501
}

src/main/platform/native.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,19 @@ export interface NativeWindow {
130130
unmaximize(): void;
131131
isMaximized(): boolean;
132132
isMinimized(): boolean;
133+
/** Restore a minimized window. */
134+
restore(): void;
135+
/** Whether the window currently has keyboard focus. */
136+
isFocused(): boolean;
137+
/** Enter or leave fullscreen. */
138+
setFullScreen(flag: boolean): void;
139+
isFullScreen(): boolean;
140+
/** Keep the window above others (macOS; best-effort/no-op elsewhere). */
141+
setAlwaysOnTop(flag: boolean): void;
133142
/** Close and destroy the window. Idempotent. */
134143
close(): void;
144+
/** Force-close the window, bypassing the preventable `close` listener. */
145+
destroy(): void;
135146
/** Register a callback fired once when the window is closed. */
136147
onClosed(callback: () => void): void;
137148
/**

tests/unit/main/api/browser-window.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const makeFakeWindow = (options: NativeWindowOptions): FakeWindow => {
4141
let bounds: Rect = { x: 0, y: 0, width: options.width, height: options.height };
4242
let maximized = false;
4343
let minimized = false;
44+
let fullscreen = false;
45+
let focused = false;
4446
let onClosed: (() => void) | undefined;
4547
let onClose: (() => boolean) | undefined;
4648
let teardowns = 0;
@@ -88,6 +90,7 @@ const makeFakeWindow = (options: NativeWindowOptions): FakeWindow => {
8890
isVisible: () => visible,
8991
focus: () => {
9092
visible = true;
93+
focused = true;
9194
},
9295
minimize: () => {
9396
minimized = true;
@@ -100,6 +103,15 @@ const makeFakeWindow = (options: NativeWindowOptions): FakeWindow => {
100103
},
101104
isMaximized: () => maximized,
102105
isMinimized: () => minimized,
106+
restore: () => {
107+
minimized = false;
108+
},
109+
isFocused: () => focused,
110+
setFullScreen: (flag) => {
111+
fullscreen = flag;
112+
},
113+
isFullScreen: () => fullscreen,
114+
setAlwaysOnTop: () => undefined,
103115
close: () => {
104116
// A real backend routes programmatic close through the same delegate path:
105117
// consult the veto, and only on a non-veto run teardown + fire closed.
@@ -109,6 +121,10 @@ const makeFakeWindow = (options: NativeWindowOptions): FakeWindow => {
109121
teardown();
110122
onClosed?.();
111123
},
124+
destroy: () => {
125+
teardown();
126+
onClosed?.();
127+
},
112128
onClosed: (cb) => {
113129
onClosed = cb;
114130
},
@@ -470,3 +486,42 @@ describe('App-level window events', () => {
470486
expect(appExitCodes()).toEqual([]);
471487
});
472488
});
489+
490+
describe('BrowserWindow window controls', () => {
491+
test('restore clears the minimized state', () => {
492+
const win = new BrowserWindow();
493+
win.minimize();
494+
expect(win.isMinimized()).toBe(true);
495+
win.restore();
496+
expect(win.isMinimized()).toBe(false);
497+
});
498+
499+
test('isFocused reflects the native state', () => {
500+
const win = new BrowserWindow();
501+
expect(win.isFocused()).toBe(false);
502+
win.focus();
503+
expect(win.isFocused()).toBe(true);
504+
});
505+
506+
test('setFullScreen toggles isFullScreen', () => {
507+
const win = new BrowserWindow();
508+
expect(win.isFullScreen()).toBe(false);
509+
win.setFullScreen(true);
510+
expect(win.isFullScreen()).toBe(true);
511+
win.setFullScreen(false);
512+
expect(win.isFullScreen()).toBe(false);
513+
});
514+
515+
test('setAlwaysOnTop does not throw', () => {
516+
const win = new BrowserWindow();
517+
expect(() => win.setAlwaysOnTop(true)).not.toThrow();
518+
});
519+
520+
test('destroy force-closes even when a close listener vetoes', () => {
521+
const win = new BrowserWindow();
522+
win.on('close', (event) => event.preventDefault());
523+
win.destroy();
524+
expect(win.isDestroyed()).toBe(true);
525+
expect(BrowserWindow.fromId(win.id)).toBeUndefined();
526+
});
527+
});

tests/unit/main/platform/linux/gtk-ffi.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ describe('GTK_FFI_SYMBOLS (shape-only ABI assertions)', () => {
6363
expect(GTK_FFI_SYMBOLS.gtk_about_dialog_new.returns).toBe(FFIType.pointer);
6464
});
6565

66+
it('declares fullscreen/unfullscreen as [ptr] -> void and is_fullscreen as [ptr] -> i32', () => {
67+
expect(GTK_FFI_SYMBOLS.gtk_window_fullscreen.args).toEqual([FFIType.pointer]);
68+
expect(GTK_FFI_SYMBOLS.gtk_window_fullscreen.returns).toBe(FFIType.void);
69+
expect(GTK_FFI_SYMBOLS.gtk_window_unfullscreen.returns).toBe(FFIType.void);
70+
expect(GTK_FFI_SYMBOLS.gtk_window_is_fullscreen.returns).toBe(FFIType.i32);
71+
});
72+
6673
it('reads the title as a nullable pointer (not cstring) so 0 can be guarded', () => {
6774
expect(GTK_FFI_SYMBOLS.gtk_window_get_title.args).toEqual([FFIType.pointer]);
6875
expect(GTK_FFI_SYMBOLS.gtk_window_get_title.returns).toBe(FFIType.pointer);

0 commit comments

Comments
 (0)