diff --git a/src/main/api/browser-window.ts b/src/main/api/browser-window.ts index 87ec3e4..a72c1a1 100644 --- a/src/main/api/browser-window.ts +++ b/src/main/api/browser-window.ts @@ -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(); diff --git a/src/main/platform/linux/linux-backend.ts b/src/main/platform/linux/linux-backend.ts index afd4528..4721102 100644 --- a/src/main/platform/linux/linux-backend.ts +++ b/src/main/platform/linux/linux-backend.ts @@ -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'; @@ -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) { @@ -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); diff --git a/src/main/platform/macos/cocoa-backend.ts b/src/main/platform/macos/cocoa-backend.ts index 032739e..2d26cda 100644 --- a/src/main/platform/macos/cocoa-backend.ts +++ b/src/main/platform/macos/cocoa-backend.ts @@ -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'; @@ -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; } @@ -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( diff --git a/src/main/platform/native.ts b/src/main/platform/native.ts index 70cba9f..56623bc 100644 --- a/src/main/platform/native.ts +++ b/src/main/platform/native.ts @@ -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). */ diff --git a/src/main/platform/window-controls.ts b/src/main/platform/window-controls.ts new file mode 100644 index 0000000..5a80726 --- /dev/null +++ b/src/main/platform/window-controls.ts @@ -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) {} +})();`; +} diff --git a/src/main/platform/windows/windows-backend.ts b/src/main/platform/windows/windows-backend.ts index 0451cf7..0b1fbd8 100644 --- a/src/main/platform/windows/windows-backend.ts +++ b/src/main/platform/windows/windows-backend.ts @@ -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)); diff --git a/src/main/platform/windows/windows-web-contents.ts b/src/main/platform/windows/windows-web-contents.ts index 2c621d0..9236419 100644 --- a/src/main/platform/windows/windows-web-contents.ts +++ b/src/main/platform/windows/windows-web-contents.ts @@ -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'; /** @@ -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. */ @@ -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({ diff --git a/tests/integration/windows/fixtures/window-bounds-probe.ts b/tests/integration/windows/fixtures/window-bounds-probe.ts new file mode 100644 index 0000000..a1b6527 --- /dev/null +++ b/tests/integration/windows/fixtures/window-bounds-probe.ts @@ -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 ` 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()); diff --git a/tests/integration/windows/window-bounds.test.ts b/tests/integration/windows/window-bounds.test.ts new file mode 100644 index 0000000..54423ef --- /dev/null +++ b/tests/integration/windows/window-bounds.test.ts @@ -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); +}); diff --git a/tests/unit/main/api/browser-window.test.ts b/tests/unit/main/api/browser-window.test.ts index d0b3781..5f0fee4 100644 --- a/tests/unit/main/api/browser-window.test.ts +++ b/tests/unit/main/api/browser-window.test.ts @@ -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; @@ -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; diff --git a/tests/unit/main/platform/window-controls.test.ts b/tests/unit/main/platform/window-controls.test.ts new file mode 100644 index 0000000..beaf5ac --- /dev/null +++ b/tests/unit/main/platform/window-controls.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; +import { + WINDOW_HANDLER_NAME, + windowControlsScript, +} from '../../../../src/main/platform/window-controls'; + +/** + * Regression guard for the page-world title-bar script. On platforms with a real + * isolated world (macOS/Linux) the page world must NEVER carry a `__bunmaska` + * handle — that defeats context isolation (and trips the isolation e2e tests). The + * control global is opt-in via `nativeOpChannel`, which only the page-world-bridge + * platform (Windows) sets. This unit test runs on every OS, so the leak is caught + * on Windows CI too, not only on the macOS/Linux e2e runners. + */ +describe('windowControlsScript', () => { + test('the default (isolated-world platforms) never leaks a __bunmaska handle', () => { + const script = windowControlsScript(); + expect(script).not.toContain('__bunmaska'); + expect(script).not.toContain(WINDOW_HANDLER_NAME); + // It still does the cross-platform job: mirror --app-region -> -webkit-app-region, + // which is what makes macOS drag natively. + expect(script).toContain('--app-region'); + expect(script).toContain('-webkit-app-region'); + }); + + test('nativeOpChannel exposes window.__bunmaska.window controls + the op handler', () => { + const script = windowControlsScript({ nativeOpChannel: true }); + expect(script).toContain('window.__bunmaska'); + expect(script).toContain('b.window'); + expect(script).toContain(WINDOW_HANDLER_NAME); + for (const op of ['minimize', 'maximize', 'unmaximize', 'toggleMaximize', 'close']) { + expect(script).toContain(`post('${op}')`); + } + // still mirrors --app-region, and wires the left-mousedown drag fallback. + expect(script).toContain('-webkit-app-region'); + expect(script).toContain("addEventListener('mousedown'"); + }); + + test('explicit nativeOpChannel:false is identical to the default', () => { + expect(windowControlsScript({ nativeOpChannel: false })).toBe(windowControlsScript()); + }); +}); diff --git a/website/src/content/docs/concepts/frameless-windows.md b/website/src/content/docs/concepts/frameless-windows.md new file mode 100644 index 0000000..81c467e --- /dev/null +++ b/website/src/content/docs/concepts/frameless-windows.md @@ -0,0 +1,76 @@ +--- +title: Frameless Windows & Custom Title Bars +description: Drop the OS title bar and draw your own - a draggable region and window controls, the Electron way, with one cross-platform CSS convention. +order: 4 +--- + +Want your own title bar instead of the OS one? Open a **frameless** window and draw the bar in HTML. Bunmaska gives you a draggable region and built-in window controls without any per-app IPC wiring. + +## A frameless window + +```ts +import { app, BrowserWindow } from 'bunmaska'; + +app.whenReady().then(() => { + const win = new BrowserWindow({ + width: 320, + height: 480, + frame: false, // <- no OS title bar; you draw your own + webPreferences: { preload: '...' }, + }); + win.loadFile('index.html'); +}); +``` + +`frame: false` removes the native title bar and borders, giving you the full client area to render into. + +## Make a region draggable: `--app-region` + +Electron uses the `-webkit-app-region: drag` CSS property to mark a draggable region. Most of the system WebKits Bunmaska drives **don't parse** that Chromium-flavoured property, so Bunmaska standardises on a CSS **custom property** instead: + +```css +.titlebar { --app-region: drag; } /* drag the window from here */ +.titlebar button { --app-region: no-drag; } /* ...but not from the buttons */ +``` + +Custom properties inherit, so set `--app-region: drag` on the bar and `--app-region: no-drag` on the controls inside it — exactly the cascade you'd get from Electron's app-region. A left-button press anywhere in a `drag` region starts a native window move (edge-snap, shake-to-minimise and all). + +> Under the hood Bunmaska also mirrors `--app-region` onto the native `-webkit-app-region`, so on macOS — where WKWebView honours it directly — dragging is fully native. + +## Window controls + +A built-in API is injected into every page — no `ipcMain` handler to write: + +```js +window.__bunmaska.window.minimize(); +window.__bunmaska.window.toggleMaximize(); // or maximize() / unmaximize() +window.__bunmaska.window.close(); +window.__bunmaska.window.startDrag(); // manual drag, if you don't use --app-region +``` + +A minimal custom title bar: + +```html +
+ My App + + + +
+ +``` + +## Platform support + +We don't lie in tables — this one is still filling in: + +| | Frameless (`frame: false`) | Drag (`--app-region`) | `window.__bunmaska.window` controls | +| --- | :---: | :---: | :---: | +| **Windows** | ✅ | ✅ | ✅ | +| **macOS** | ✅ | ✅ (native `-webkit-app-region`) | ◐ coming | +| **Linux** | ✅ | ◐ coming | ◐ coming | + +The `--app-region` CSS convention and the `window.__bunmaska.window` API are stable; the macOS/Linux control handlers and Linux drag are wired next. Track them on the [roadmap](/roadmap), and check the [parity matrix](/docs/migrating/parity) for the rest. diff --git a/website/src/content/docs/migrating/parity.md b/website/src/content/docs/migrating/parity.md index 2ad500c..f4da583 100644 --- a/website/src/content/docs/migrating/parity.md +++ b/website/src/content/docs/migrating/parity.md @@ -15,7 +15,7 @@ Support is not uniform across platforms, and we won't pretend it is. The table b | Module | macOS | Linux | Windows | Notes | | --- | :---: | :---: | :---: | --- | | `app` | ✅ | ✅ | ✅ | Lifecycle, paths, single-instance, locale. Dock/badge/about-panel are macOS-only and no-op elsewhere (as in Electron). | -| `BrowserWindow` | ✅ | ✅ | ◐ | Windows: `setMinimumSize` is a no-op (needs `WM_GETMINMAXINFO`); everything else wired. | +| `BrowserWindow` | ✅ | ◐ | ◐ | `setPosition`/`setBounds` move + size the window on Windows; macOS placement is best-effort (bottom-left origin) and Linux leaves placement to the compositor (GTK4), so `setPosition` is size-only there. Windows `setMinimumSize` is a no-op (needs `WM_GETMINMAXINFO`). | | `webContents` (core) | ✅ | ✅ | ✅ | load/navigation/`executeJavaScript`/zoom/`insertCSS`/IPC `send` everywhere. `setWindowOpenHandler({action:'allow'})` is unimplemented on all three (`window.open` is blocked by default). | | `webContents.printToPDF` | ✅ | ✕ | ⚙️ | macOS via `createPDFWithConfiguration`. Linux: not yet wired. Windows: engine-blocked (no PDF sink in the WinCairo C API). | | `webContents.capturePage` | ✅ | ✕ | ⚙️ | macOS via snapshot. Linux: planned (`webkit_web_view_get_snapshot`). Windows: engine-blocked (no UI-process snapshot). |