Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/main/api/browser-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,22 @@ export class BrowserWindow extends EventEmitter {
return this.#native.getBounds();
}

/** Move the window's top-left corner to `(x, y)`. */
setPosition(x: number, y: number): void {
this.#native.setPosition(x, y);
}

/** The window's `[x, y]` screen position in pixels. */
getPosition(): [number, number] {
const bounds = this.#native.getBounds();
return [bounds.x, bounds.y];
}

/** Resize and reposition the window in one call (`{ x, y, width, height }`). */
setBounds(bounds: Rect): void {
this.#native.setBounds(bounds);
}

/** The window's `[width, height]` in pixels. */
getSize(): [number, number] {
const bounds = this.#native.getBounds();
Expand Down
17 changes: 16 additions & 1 deletion src/main/platform/linux/linux-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
Rect,
WindowEventType,
} from '../native';
import { windowControlsScript } from '../window-controls';
import { ExecResultChannel } from './eval-js';
import { loadGtkFFI } from './gtk-ffi';
import type { NativeMenuItemSpec } from '../macos/cocoa-menu';
Expand Down Expand Up @@ -71,7 +72,11 @@ class LinuxWebContents implements NativeWebContents {
preloadSource: generatePreloadBootstrap(),
isolatedSetupSource: generateIsolatedChannelSetup(channelId),
isolatedHostSource: generateIsolatedHostSource(channelId),
pageWorldSource: generatePageWorldStub(channelId),
// Page world: the cross-world stub + the custom-title-bar script. The page world
// stays free of any `__bunmaska` handle (context isolation), so it only mirrors
// `--app-region`; the window-op controls + native GTK handler are a follow-up on
// the isolated-world bridge.
pageWorldSource: `${generatePageWorldStub(channelId)}\n${windowControlsScript()}`,
...(userPreloadSource !== undefined ? { userPreloadSource } : {}),
onMessage: (json: string) => {
for (const callback of this.#rendererEnvelopeCallbacks) {
Expand Down Expand Up @@ -519,6 +524,16 @@ class LinuxWindow implements NativeWindow {
gtk.symbols.gtk_window_set_default_size(this.#window, width, height);
}

setPosition(_x: number, _y: number): void {
// GTK4 removed programmatic positioning; the compositor places the window
// (Wayland forbids clients moving themselves). No-op by design, like center().
}

setBounds(bounds: Rect): void {
// Only the size is honourable on GTK4; position is compositor-controlled.
this.setSize(bounds.width, bounds.height);
}

setResizable(resizable: boolean): void {
const gtk = loadGtkFFI();
gtk.symbols.gtk_window_set_resizable(this.#window, resizable ? GTK_TRUE : GTK_FALSE);
Expand Down
21 changes: 21 additions & 0 deletions src/main/platform/macos/cocoa-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
} from '../native';
import { buildExecWrapper } from '../../ipc/exec-wrapper';
import { DOM_READY_HANDLER_NAME, generateDomReadyScript } from '../dom-ready';
import { windowControlsScript } from '../window-controls';
import { makeOneShotBlock } from './cocoa-block';
import { getContentWorld, pageWorld } from './cocoa-content-world';
import { nsString, nsStringToString } from './cocoa-foundation';
Expand Down Expand Up @@ -567,6 +568,21 @@ class MacOSWindow implements NativeWindow {
this.#bounds = { ...this.#bounds, width, height };
}

setPosition(x: number, y: number): void {
// `setFrameOrigin:` shares the 2-double NSPoint ABI msgSendSize uses (avoiding
// the NSRect-by-value hazard of `setFrame:`). NOTE: macOS screen coordinates are
// bottom-left origin, so the top-left mapping (a screen-height flip) is a
// documented follow-up needing on-device verification. The tracked bounds are
// updated so getBounds/getPosition round-trip with what was set.
msgSendSize(this.#window, cocoa().selectors.get('setFrameOrigin:'), x, y);
this.#bounds = { ...this.#bounds, x, y };
}

setBounds(bounds: Rect): void {
this.setPosition(bounds.x, bounds.y);
this.setSize(bounds.width, bounds.height);
}

getBounds(): Rect {
return this.#bounds;
}
Expand Down Expand Up @@ -946,6 +962,11 @@ class MacOSApplication implements NativeApplication {
// Page world: the cross-world stub that materialises contextBridge surfaces,
// and the dom-ready notifier.
addUserScript(generatePageWorldStub(channelId), pageWorld());
// Custom (frameless) title bars: the page-world script only mirrors `--app-region`
// onto `-webkit-app-region`, which WKWebView drags natively. The window-op controls
// (`window.__bunmaska.window`) belong on the isolated-world bridge (a follow-up),
// never the page world — a `__bunmaska` handle there would defeat context isolation.
addUserScript(windowControlsScript(), pageWorld());
addUserScript(generateDomReadyScript(), pageWorld());

const webview = msgSendInitWithFrameConfig(
Expand Down
4 changes: 4 additions & 0 deletions src/main/platform/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ export interface NativeWindow {
getTitle(): string;
setSize(width: number, height: number): void;
getBounds(): Rect;
/** Move the window's top-left corner to `(x, y)` (best-effort on macOS/Wayland). */
setPosition(x: number, y: number): void;
/** Set the window's position AND size in one call (best-effort on macOS/Wayland). */
setBounds(bounds: Rect): void;
/** Enable or disable user resizing of the window. */
setResizable(resizable: boolean): void;
/** Set the window's opacity in `[0, 1]` (`1` = fully opaque). */
Expand Down
82 changes: 82 additions & 0 deletions src/main/platform/window-controls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* The built-in page-world script for custom (frameless) title bars — Bunmaska's
* cross-platform answer to Electron's `-webkit-app-region`. Every platform backend
* injects it into the page world. It does up to three things:
*
* 1. (Native-op-channel platforms only — see `nativeOpChannel`.) Exposes
* `window.__bunmaska.window` controls (minimize / maximize / close / …) that
* post `{ op }` to the native `bunmaskaWindow` message handler, plus a
* left-mousedown drag over a `--app-region: drag` region. This is GATED: on a
* platform with a real isolated world (macOS/Linux) the page world must NOT
* carry a `__bunmaska` handle — that would defeat context isolation — so the
* controls belong on the isolated-world bridge (a follow-up), not here. Only
* Windows, whose bridge already lives in the page world, opts in.
* 2. MIRRORS `--app-region` onto the native `-webkit-app-region`, which macOS
* WKWebView honors for window dragging out of the box. Custom properties
* inherit, so `--app-region: drag` on a bar + `--app-region: no-drag` on its
* buttons gives exactly Electron's app-region cascade. On engines that ignore
* `-webkit-app-region` (WinCairo, WebKitGTK) the mirror is a harmless no-op and
* the drag goes through the native window-op handler (1) instead.
*
* The mirror re-runs on DOM mutations (debounced to one pass per frame). It only
* observes structural changes, not the `style` attribute it writes, so it can't loop.
*/
export const WINDOW_HANDLER_NAME = 'bunmaskaWindow';

/**
* Build the page-world title-bar script. Pass `nativeOpChannel: true` ONLY where the
* page world IS the bridge world (Windows, which has no separate isolated world), so
* `window.__bunmaska.window` extends the real bridge and the JS drag fallback can post
* to the `bunmaskaWindow` handler. Leave it false on isolated-world platforms
* (macOS/Linux) to keep the page world free of any `__bunmaska` handle — there only the
* `--app-region` mirror runs, and macOS drags natively off it.
*/
export function windowControlsScript(options: { nativeOpChannel?: boolean } = {}): string {
const ops = options.nativeOpChannel
? ` var post = function(op){
try { window.webkit.messageHandlers.${WINDOW_HANDLER_NAME}.postMessage(JSON.stringify({ op: op })); } catch (e) {}
};
var b = (window.__bunmaska = window.__bunmaska || {});
b.window = {
minimize: function(){ post('minimize'); },
maximize: function(){ post('maximize'); },
unmaximize: function(){ post('unmaximize'); },
toggleMaximize: function(){ post('toggleMaximize'); },
close: function(){ post('close'); },
startDrag: function(){ post('drag'); }
};
document.addEventListener('mousedown', function(e){
if (e.button !== 0) return;
var n = e.target;
var el = n && n.nodeType === 1 ? n : (n && n.parentElement);
if (!el) return;
if (getComputedStyle(el).getPropertyValue('--app-region').trim() === 'drag') {
e.preventDefault();
post('drag');
}
}, true);
`
: '';
return `(function(){
${ops} var mirror = function(){
try {
var els = document.querySelectorAll('*');
for (var i = 0; i < els.length; i++) {
var v = getComputedStyle(els[i]).getPropertyValue('--app-region').trim();
if (v === 'drag' || v === 'no-drag') els[i].style.setProperty('-webkit-app-region', v);
}
} catch (e) {}
};
var pending = false;
var schedule = function(){
if (pending) return;
pending = true;
requestAnimationFrame(function(){ pending = false; mirror(); });
};
if (document.readyState !== 'loading') schedule();
document.addEventListener('DOMContentLoaded', schedule);
try {
new MutationObserver(schedule).observe(document.documentElement, { childList: true, subtree: true });
} catch (e) {}
})();`;
}
24 changes: 24 additions & 0 deletions src/main/platform/windows/windows-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,30 @@ class WindowsWindow implements NativeWindow {
);
}

setPosition(x: number, y: number): void {
loadUser32().symbols.SetWindowPos(
this.#hwnd(),
0n,
x,
y,
0,
0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE,
);
}

setBounds(bounds: Rect): void {
loadUser32().symbols.SetWindowPos(
this.#hwnd(),
0n,
bounds.x,
bounds.y,
bounds.width,
bounds.height,
SWP_NOZORDER | SWP_NOACTIVATE,
);
}

getBounds(): Rect {
const rect = new Uint8Array(RECT_SIZE);
loadUser32().symbols.GetWindowRect(this.#hwnd(), ptr(rect));
Expand Down
42 changes: 4 additions & 38 deletions src/main/platform/windows/windows-web-contents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { generatePreloadBootstrap } from '../../../renderer/preload-bootstrap';
import { buildExecWrapper } from '../../ipc/exec-wrapper';
import { DOM_READY_HANDLER_NAME, generateDomReadyScript } from '../dom-ready';
import type { NativeNavigationEvent, NativeWebContents } from '../native';
import { WINDOW_HANDLER_NAME, windowControlsScript } from '../window-controls';
import { WindowsWebView } from './windows-webkit-view';

/**
Expand All @@ -32,47 +33,10 @@ import { WindowsWebView } from './windows-webkit-view';
const HANDLER_NAME = 'bunmaska';
/** Page-world handler name `executeJavaScript` posts its result to. */
const EXEC_HANDLER_NAME = 'bunmaskaExec';
/** Page-world handler name the built-in title-bar script posts window ops to. */
const WINDOW_HANDLER_NAME = 'bunmaskaWindow';

/** Reject a pending `executeJavaScript` after this long (ms). */
const EXEC_TIMEOUT_MS = 30_000;

/**
* Built-in page-world script for custom (frameless) title bars — Bunmaska's answer
* to Electron's `-webkit-app-region`, which WinCairo WebKit does not parse. It:
* - exposes `window.__bunmaska.window` controls (minimize/maximize/close/…), and
* - auto-starts a native window drag on a left-button mousedown over a region
* whose CSS custom property `--app-region` resolves to `drag`. Custom properties
* inherit, so set `--app-region: drag` on the bar and `--app-region: no-drag`
* on its buttons — exactly the cascade Electron's app-region gives you.
* Each action posts `{ op }` to the `bunmaskaWindow` handler, routed to the window.
*/
const WINDOW_CONTROLS_SCRIPT = `(function(){
var post = function(op){
try { window.webkit.messageHandlers.${WINDOW_HANDLER_NAME}.postMessage(JSON.stringify({ op: op })); } catch (e) {}
};
var b = (window.__bunmaska = window.__bunmaska || {});
b.window = {
minimize: function(){ post('minimize'); },
maximize: function(){ post('maximize'); },
unmaximize: function(){ post('unmaximize'); },
toggleMaximize: function(){ post('toggleMaximize'); },
close: function(){ post('close'); },
startDrag: function(){ post('drag'); }
};
document.addEventListener('mousedown', function(e){
if (e.button !== 0) return;
var n = e.target;
var el = n && n.nodeType === 1 ? n : (n && n.parentElement);
if (!el) return;
if (getComputedStyle(el).getPropertyValue('--app-region').trim() === 'drag') {
e.preventDefault();
post('drag');
}
}, true);
})();`;

const log = createLogger('windows-web-contents');

/** A pending `executeJavaScript` awaiting its page-world result message. */
Expand Down Expand Up @@ -168,7 +132,9 @@ export class WindowsWebContents implements NativeWebContents {
generateIsolatedHostSource(channelId),
...(preloadScript !== undefined ? [preloadScript] : []),
generatePageWorldStub(channelId),
WINDOW_CONTROLS_SCRIPT,
// Windows has no separate isolated world, so the page world IS the bridge
// world: it's correct (and necessary) to expose the window-op controls here.
windowControlsScript({ nativeOpChannel: true }),
generateDomReadyScript(),
];
this.#webView = WindowsWebView.create({
Expand Down
35 changes: 35 additions & 0 deletions tests/integration/windows/fixtures/window-bounds-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Probe: `setPosition` / `setBounds` actually move + size the native window on
* Windows, and `getBounds` reads the change back (round-trip through Win32
* SetWindowPos / GetWindowRect). Prints `BOUNDS_OK <json>` on success. Requires
* BUNMASKA_WEBKIT_PATH (constructing a window spins up the engine).
*/
import { app, BrowserWindow } from '../../../../src/main';

const finish = (line: string, code: number): never => {
process.stdout.write(`${line}\n`);
process.exit(code);
};
setTimeout(() => finish('BOUNDS_FAIL timeout', 1), 25000);

app.whenReady().then(() => {
const win = new BrowserWindow({ width: 400, height: 300, show: false });

win.setBounds({ x: 150, y: 120, width: 520, height: 380 });
const b1 = win.getBounds();

win.setPosition(60, 70);
const b2 = win.getBounds();

const boundsOk = b1.x === 150 && b1.y === 120 && b1.width === 520 && b1.height === 380;
// setPosition keeps the size from setBounds and only moves the top-left.
const positionOk = b2.x === 60 && b2.y === 70 && b2.width === 520 && b2.height === 380;

finish(
`BOUNDS_OK setBounds=${JSON.stringify(b1)} boundsOk=${boundsOk} ` +
`setPosition=${JSON.stringify(b2)} positionOk=${positionOk}`,
boundsOk && positionOk ? 0 : 1,
);
});

app.on('window-all-closed', () => app.quit());
27 changes: 27 additions & 0 deletions tests/integration/windows/window-bounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test';
import { currentPlatform } from '../../../src/common/platform';
import { resolveWindowsEngineDir } from '../../../src/main/platform/windows/webkit2-ffi';

/**
* Windows + engine only. `setBounds` / `setPosition` move + size the real native
* window (Win32 `SetWindowPos`) and `getBounds` reads it back (`GetWindowRect`).
* Spawned in a subprocess like the other engine probes.
*/
const hasEngine = currentPlatform() === 'windows' && resolveWindowsEngineDir() !== undefined;

describe.skipIf(!hasEngine)('Windows BrowserWindow setBounds/setPosition', () => {
test('round-trip through SetWindowPos/GetWindowRect', async () => {
const fixture = `${import.meta.dir}/fixtures/window-bounds-probe.ts`;
const proc = Bun.spawn([process.execPath, 'run', fixture], {
env: { ...process.env },
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const code = await proc.exited;
expect(stdout).toContain('BOUNDS_OK');
expect(stdout).toContain('boundsOk=true');
expect(stdout).toContain('positionOk=true');
expect(code).toBe(0);
}, 40000);
});
21 changes: 21 additions & 0 deletions tests/unit/main/api/browser-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ const makeFakeWindow = (options: NativeWindowOptions): FakeWindow => {
setSize: (w, h) => {
bounds = { ...bounds, width: w, height: h };
},
setPosition: (x, y) => {
bounds = { ...bounds, x, y };
},
setBounds: (b) => {
bounds = { ...b };
},
getBounds: () => bounds,
setResizable: (r) => {
appliedResizable = r;
Expand Down Expand Up @@ -427,6 +433,21 @@ describe('BrowserWindow lifecycle', () => {
expect(win.getBounds()).toEqual({ x: 0, y: 0, width: 300, height: 200 });
});

test('setPosition moves the window and getPosition reads it back', () => {
const win = new BrowserWindow();
win.setPosition(120, 80);
expect(win.getPosition()).toEqual([120, 80]);
expect(win.getBounds()).toMatchObject({ x: 120, y: 80 });
});

test('setBounds sets position and size together', () => {
const win = new BrowserWindow();
win.setBounds({ x: 50, y: 60, width: 800, height: 600 });
expect(win.getBounds()).toEqual({ x: 50, y: 60, width: 800, height: 600 });
expect(win.getPosition()).toEqual([50, 60]);
expect(win.getSize()).toEqual([800, 600]);
});

test('close emits closed, removes from registry, and marks destroyed', () => {
const win = new BrowserWindow();
let closedEvents = 0;
Expand Down
Loading
Loading