also looking at this
feat: add Linux and Windows named-window routing
#325
29 files
+2250
-200
| @@ -51,7 +51,7 @@ jobs: | ||
| 51 | 51 | if: runner.os == 'Linux' |
| 52 | 52 | run: | |
| 53 | 53 | sudo apt-get update |
| 54 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev | |
| 54 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb xauth | |
| 55 | 55 | |
| 56 | 56 | - name: First-party Zig dependencies |
| 57 | 57 | uses: ./.github/actions/first-party-zig-deps |
| @@ -79,6 +79,66 @@ jobs: | ||
| 79 | 79 | ls -lh packages/zig/zig-out/bin/ |
| 80 | 80 | size packages/zig/zig-out/bin/craft |
| 81 | 81 | |
| 82 | # The host matrix never analyzes windows.zig. Exercise the same target | |
| 83 | # as release.yml here so multi-window WebView2 changes cannot wait until | |
| 84 | # a tag to reveal a cross-compilation failure. | |
| 85 | - name: Cross-build Windows CLI | |
| 86 | if: runner.os == 'Linux' | |
| 87 | working-directory: packages/zig | |
| 88 | run: | | |
| 89 | VERSION="$(bun -p "require('../../package.json').version")" | |
| 90 | zig build -Doptimize=ReleaseSafe -Dversion="$VERSION" -Dtarget=x86_64-windows | |
| 91 | test -f zig-out/bin/craft.exe | |
| 92 | ||
| 93 | - name: Smoke Linux multi-window bridge | |
| 94 | if: runner.os == 'Linux' | |
| 95 | run: bun scripts/verify-desktop-multi-window.ts packages/zig/zig-out/bin/craft | |
| 96 | ||
| 97 | windows-multi-window: | |
| 98 | runs-on: windows-2025 | |
| 99 | timeout-minutes: 20 | |
| 100 | ||
| 101 | steps: | |
| 102 | - uses: actions/checkout@v6 | |
| 103 | ||
| 104 | - name: Setup Pantry (provides Bun and Zig) | |
| 105 | uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64 | |
| 106 | ||
| 107 | - name: First-party Zig dependencies | |
| 108 | uses: ./.github/actions/first-party-zig-deps | |
| 109 | ||
| 110 | - name: Build Windows CLI | |
| 111 | shell: pwsh | |
| 112 | run: | | |
| 113 | # The Pantry .bin shim can print zig version but cannot locate Zig's | |
| 114 | # lib directory during a build. Run the locked package binary itself. | |
| 115 | $package = Join-Path $env:GITHUB_WORKSPACE 'pantry/ziglang-org/0.17.0-dev.1963_e00c6c439' | |
| 116 | $env:CRAFT_ZIG = '' | |
| 117 | foreach ($candidate in @((Join-Path $package 'zig.exe'), (Join-Path $package 'bin/zig.exe'))) { | |
| 118 | if (Test-Path -LiteralPath $candidate) { $env:CRAFT_ZIG = $candidate; break } | |
| 119 | } | |
| 120 | if (-not $env:CRAFT_ZIG) { | |
| 121 | Get-ChildItem (Join-Path $env:GITHUB_WORKSPACE 'pantry') -Filter zig.exe -Recurse -File | | |
| 122 | Select-Object -ExpandProperty FullName | |
| 123 | throw "Pinned Zig executable missing from $package" | |
| 124 | } | |
| 125 | bun run build:core | |
| 126 | ||
| 127 | - name: Stage pinned WebView2 loader | |
| 128 | shell: pwsh | |
| 129 | run: | | |
| 130 | $version = '1.0.4191.47' | |
| 131 | $expected = 'f492bbf547d0da329553b6727435b677579b1e9f91cc9e4a1ad029366d5f23d0' | |
| 132 | $archive = Join-Path $env:RUNNER_TEMP 'microsoft.web.webview2.zip' | |
| 133 | $package = Join-Path $env:RUNNER_TEMP 'microsoft.web.webview2' | |
| 134 | Invoke-WebRequest -Uri "https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/$version/microsoft.web.webview2.$version.nupkg" -OutFile $archive | |
| 135 | if ((Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected) { throw 'WebView2 NuGet package digest mismatch' } | |
| 136 | Expand-Archive -LiteralPath $archive -DestinationPath $package | |
| 137 | Copy-Item (Join-Path $package 'build/native/x64/WebView2Loader.dll') 'packages/zig/zig-out/bin/WebView2Loader.dll' | |
| 138 | ||
| 139 | - name: Smoke Windows multi-window bridge | |
| 140 | run: bun scripts/verify-desktop-multi-window.ts packages/zig/zig-out/bin/craft.exe | |
| 141 | ||
| 82 | 142 | typescript-sdk: |
| 83 | 143 | runs-on: ubuntu-latest |
| 84 | 144 | |
| @@ -375,7 +435,7 @@ jobs: | ||
| 375 | 435 | run: bun run verify |
| 376 | 436 | |
| 377 | 437 | publish-commit: |
| 378 | needs: [zig-core, typescript-sdk, ios-builder, ios-native-templates, android-builder, create-craft, ts-maps, lint, integration] | |
| 438 | needs: [zig-core, windows-multi-window, typescript-sdk, ios-builder, ios-native-templates, android-builder, create-craft, ts-maps, lint, integration] | |
| 379 | 439 | runs-on: ubuntu-latest |
| 380 | 440 | |
| 381 | 441 | steps: |
| @@ -179,6 +179,23 @@ jobs: | ||
| 179 | 179 | rm -rf zig-out/bin |
| 180 | 180 | zig build -Doptimize=ReleaseSafe -Dversion="$VERSION" |
| 181 | 181 | |
| 182 | # The Windows binary dynamically loads WebView2Loader.dll. The runtime | |
| 183 | # alone does not provide the app-local loader, so stage the x64 DLL beside | |
| 184 | # craft.exe before Pantry zips zig-out/cross/windows-x64/. Pin and verify | |
| 185 | # the official NuGet package instead of silently shipping a moving DLL. | |
| 186 | - name: Stage Windows WebView2 loader | |
| 187 | if: runner.os == 'Linux' | |
| 188 | working-directory: packages/zig | |
| 189 | run: | | |
| 190 | set -euo pipefail | |
| 191 | VERSION=1.0.4191.47 | |
| 192 | EXPECTED=f492bbf547d0da329553b6727435b677579b1e9f91cc9e4a1ad029366d5f23d0 | |
| 193 | ARCHIVE="$RUNNER_TEMP/microsoft.web.webview2.$VERSION.nupkg" | |
| 194 | curl -fsSL --retry 3 "https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/$VERSION/microsoft.web.webview2.$VERSION.nupkg" -o "$ARCHIVE" | |
| 195 | echo "$EXPECTED $ARCHIVE" | sha256sum -c - | |
| 196 | unzip -p "$ARCHIVE" build/native/x64/WebView2Loader.dll > zig-out/cross/windows-x64/WebView2Loader.dll | |
| 197 | test -s zig-out/cross/windows-x64/WebView2Loader.dll | |
| 198 | ||
| 182 | 199 | # ── macOS Code Signing & Notarization ────────────────────────── |
| 183 | 200 | # The signing credentials live encrypted in `.env.production`, not as |
| 184 | 201 | # seven repository secrets. |
| @@ -155,7 +155,11 @@ Currently implemented foundations (experimental): | ||
| 155 | 155 | - �� Notifications, tray, clipboard, and dialogs require further integration testing |
| 156 | 156 | - ✅ Hot reload via WebSocket |
| 157 | 157 | - ✅ Developer tools (WebKit Inspector) |
| 158 | - �� Multi-window, bridge parity, and advanced window behavior are incomplete | |
| 158 | - ✅ Named runtime windows, addressed controls, and scoped window events (GUI smoke in CI) | |
| 159 | - �� Other bridge parity and advanced window behavior remain incomplete | |
| 160 | ||
| 161 | The Xvfb smoke covers named create, addressed replies, close, and reopen; it | |
| 162 | does not establish full desktop bridge parity for a particular application. | |
| 159 | 163 | |
| 160 | 164 | ### Known Limitations |
| 161 | 165 | |
| @@ -247,11 +251,15 @@ Currently implemented foundations (experimental): | ||
| 247 | 251 | - �� Notifications, tray, clipboard, and dialogs require further integration testing |
| 248 | 252 | - ✅ Hot reload via WebSocket |
| 249 | 253 | - ✅ Developer tools (Edge DevTools) |
| 250 | - �� Multi-window, bridge parity, and advanced window behavior are incomplete | |
| 254 | - ✅ Named runtime windows, addressed controls, and scoped window events (WebView2 GUI smoke in CI) | |
| 255 | - �� Other bridge parity and advanced window behavior remain incomplete | |
| 256 | ||
| 257 | The native WebView2 smoke also exercises concurrent child creation and cleanup; | |
| 258 | it does not establish full desktop bridge parity for a particular application. | |
| 251 | 259 | |
| 252 | 260 | ### Known Limitations |
| 253 | 261 | |
| 254 | - **WebView2 Required**: End users must have WebView2 Runtime installed | |
| 262 | - **WebView2 Required**: End users must have WebView2 Runtime installed; release archives include the app-local loader DLL | |
| 255 | 263 | - **Transparency**: Requires DWM (Desktop Window Manager) enabled |
| 256 | 264 | - **System Tray**: Icon must be .ico format |
| 257 | 265 | - **UWP Apps**: Limited support for UWP features |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # Multi-window ownership |
| 2 | 2 | |
| 3 | 3 | This document records Craft's current multi-window ownership contract. It is |
| 4 | both a map of the implemented macOS behavior and a boundary around the choices | |
| 4 | both a map of the implemented desktop behavior and a boundary around the choices | |
| 5 | 5 | that are still open. A process-global Zig object is not automatically a |
| 6 | 6 | single-window bug: the important question is whether it is a shared service or |
| 7 | 7 | whether it stores a window-specific target. |
| @@ -10,17 +10,35 @@ whether it stores a window-specific target. | ||
| 10 | 10 | |
| 11 | 11 | | Area | macOS | Linux | Windows | |
| 12 | 12 | | --- | --- | --- | --- | |
| 13 | | Runtime creation from `createWindow()` | Implemented | Not wired to the SDK handle contract | Not wired to the SDK handle contract | | |
| 14 | | Stable named handles | Implemented | Not implemented | Not implemented | | |
| 15 | | Sender-authenticated local actions | Implemented | Not implemented | Not implemented | | |
| 16 | | Named cross-window actions | Implemented | Not implemented | Not implemented | | |
| 17 | | Per-window lifecycle events | Implemented | Not implemented | Not implemented | | |
| 18 | | Permanent destroy and cleanup | Implemented for named windows | Not implemented | Not implemented | | |
| 13 | | Runtime creation from `createWindow()` | Implemented | Implemented; GUI smoke in CI | Implemented; WebView2 GUI smoke in CI | | |
| 14 | | Stable named handles | Implemented | Implemented | Implemented | | |
| 15 | | Sender-authenticated local actions | Implemented | Implemented through WebKitGTK | Implemented through WebView2 | | |
| 16 | | Named cross-window actions | Implemented | Implemented for core controls | Implemented for core controls | | |
| 17 | | Per-window lifecycle events | Implemented | Implemented | Implemented | | |
| 18 | | Permanent destroy and cleanup | Implemented for named windows | Implemented on close/destroy | Implemented on close/destroy | | |
| 19 | 19 | | Modal and parent relationships | Unspecified | Unspecified | Unspecified | |
| 20 | 20 | |
| 21 | Issue #67 therefore remains open. Its macOS core is substantially implemented, | |
| 22 | but closing it would imply cross-platform and modal/parent semantics that Craft | |
| 23 | does not yet provide. | |
| 21 | The runtime-creation, stable-handle, reply-routing, and event-scoping contract | |
| 22 | from issue #67 is covered here. Modal/parent semantics remain separate future | |
| 23 | decisions. The Windows release archive stages an app-local WebView2 loader; | |
| 24 | users still need the Microsoft WebView2 Runtime. | |
| 25 | ||
| 26 | Linux and Windows keep their live native window/webview pairs in | |
| 27 | `desktop_window_registry.zig`. Page messages are authenticated by the sending | |
| 28 | WebKitGTK view or the per-WebView2 event subscription; names select a target | |
| 29 | only after that native source is known. | |
| 30 | Replies go back to the requesting view, and lifecycle events go to the changed | |
| 31 | window plus its named handle's creator. Linux must register its `GtkApplication` | |
| 32 | before constructing the first `GtkApplicationWindow`, because the CLI creates | |
| 33 | that window before entering `g_application_run()`. On Windows, the controller | |
| 34 | passed to the asynchronous WebView2 completion callback is borrowed. Each live | |
| 35 | window retains its own controller reference until native destruction; otherwise | |
| 36 | WebView2 can close before the page bridge registers or navigation begins. | |
| 37 | WebView2 also forbids entering a nested message loop from its message callback. | |
| 38 | Windows therefore queues page-requested child creation onto the UI message | |
| 39 | queue, preserving the authenticated owner and reply ID until that callback | |
| 40 | returns. Concurrent opens are drained serially and abandoned if their creator | |
| 41 | window is destroyed before dispatch. | |
| 24 | 42 | |
| 25 | 43 | ## Per-window state |
| 26 | 44 | |
| @@ -57,9 +75,10 @@ back to whichever window was created most recently: | ||
| 57 | 75 | - The local scroll monitor is installed once, but reads the event's `NSWindow`, |
| 58 | 76 | advances only that window's accumulator and emits only to its webview. |
| 59 | 77 | |
| 60 | `close()` preserves these resources and the SDK's native-event subscriptions | |
| 61 | so the same page can reopen with its DOM and JavaScript state intact, including | |
| 62 | when the primary window is reopened by the Dock rather than `createWindow()`. | |
| 78 | On macOS, `close()` preserves these resources and the SDK's native-event | |
| 79 | subscriptions so the same page can reopen with its DOM and JavaScript state | |
| 80 | intact, including when the primary window is reopened by the Dock rather than | |
| 81 | `createWindow()`. | |
| 63 | 82 | `destroy()` removes the named registry entry, Native UI graph, gesture and |
| 64 | 83 | material slots, recovery state, event ownership and retained AppKit objects |
| 65 | 84 | before releasing the window and detaching the SDK's DOM listeners. |
| @@ -118,13 +137,12 @@ The next multi-window milestone needs product decisions in addition to code: | ||
| 118 | 137 | application-level asynchronous source above. |
| 119 | 138 | 3. Decide whether Touch Bar should stay primary-window scoped or follow the |
| 120 | 139 | key window with separately owned item definitions and callbacks. |
| 121 | 4. Bring Linux and Windows runtime creation, stable handles, sender routing, | |
| 122 | lifecycle events and destroy semantics up to the macOS contract. Their | |
| 123 | platform backends can create native windows today, but the TypeScript bridge | |
| 124 | deliberately reports runtime creation as unsupported outside macOS. | |
| 125 | 5. Add platform-native integration coverage. Source conformance and pure state | |
| 126 | tests defend macOS invariants without requiring a GUI runner, but they do | |
| 127 | not substitute for real Windows WebView2 and Linux WebKitGTK lifecycle tests. | |
| 140 | 4. Finish advanced Linux/Windows window actions and clarify which are portable | |
| 141 | versus platform-specific, without conflating close semantics: macOS retains | |
| 142 | a closed page, while Linux and Windows release it. | |
| 143 | 5. Expand platform-native integration beyond the current named-window lifecycle | |
| 144 | smokes. CI runs Linux WebKitGTK under Xvfb and Windows WebView2 on a native | |
| 145 | runner, but advanced actions and packaged-app behavior still need coverage. | |
| 128 | 146 | |
| 129 | 147 | ## Review checklist |
| 130 | 148 | |
| @@ -134,6 +152,8 @@ For every new bridge or callback that touches a window, verify: | ||
| 134 | 152 | - An explicit window ID is resolved through the registry and unknown IDs fail. |
| 135 | 153 | - An asynchronous reply retains both its target and its requesting webview. |
| 136 | 154 | - A delayed control callback stores its owner on the control/delegate instance. |
| 137 | - Ordinary close preserves state; permanent destroy forgets it exactly once. | |
| 155 | - On macOS, ordinary close retains the page and permanent destroy forgets it | |
| 156 | exactly once. On Linux and Windows, close and destroy both release the native | |
| 157 | window/webview; a later open of the same name creates a fresh native page. | |
| 138 | 158 | - A process-global event sink is documented as primary, broadcast or |
| 139 | 159 | subscriber-owned rather than inheriting accidental last-window behavior. |
| @@ -29,8 +29,11 @@ await show(html, { | ||
| 29 | 29 | ### Runtime Window Creation |
| 30 | 30 | |
| 31 | 31 | The `craft-native` SDK returns a typed `Window` handle. Runtime-created |
| 32 | windows are currently implemented by the macOS host and must provide either | |
| 33 | `html` or `url` content. | |
| 32 | windows are available on macOS, Linux, and Windows and must provide either | |
| 33 | `html` or `url` content. The common create, close, title, geometry, and event | |
| 34 | routes are cross-platform; some advanced window controls remain macOS-only. | |
| 35 | On Linux, the window manager may ignore position requests, especially before | |
| 36 | the window is shown. | |
| 34 | 37 | |
| 35 | 38 | ```typescript |
| 36 | 39 | import { createWindow } from 'craft-native' |
| @@ -477,12 +480,15 @@ that created it sees the same transitions through the `inspector` handle. | ||
| 477 | 480 | Other open windows do not receive them. Call the unsubscribe function returned |
| 478 | 481 | by `on`, such as `stopWatching()` above, when the listener is no longer needed. |
| 479 | 482 | |
| 480 | Closing a window keeps its native page alive so the macOS reopen lifecycle can | |
| 481 | restore its DOM and JavaScript state. Call `destroy()` on a runtime-created | |
| 482 | handle to permanently release its native window, webview, recovery state and | |
| 483 | event subscriptions; opening the same ID afterwards creates a fresh native | |
| 484 | page behind the same stable SDK handle. The unnamed primary window cannot be | |
| 485 | force-destroyed through this API. | |
| 483 | On macOS, closing a window keeps its native page alive so reopening can | |
| 484 | restore its DOM and JavaScript state. On Linux and Windows, closing releases | |
| 485 | the native window and webview; reopening the same ID creates a fresh page | |
| 486 | behind the same stable SDK handle. `destroy()` releases a named window's native | |
| 487 | resources and detaches its SDK DOM listeners. A later `createWindow()` with the | |
| 488 | same ID reuses the typed handle and reattaches those listeners. The unnamed | |
| 489 | primary window cannot be force-destroyed through this API. CI exercises Linux | |
| 490 | create/close/reopen under Xvfb and the equivalent Windows WebView2 path, | |
| 491 | including concurrent child creation and stale-handle cleanup. | |
| 486 | 492 | Modal/parent semantics are not part of the runtime-created-window contract yet. |
| 487 | 493 | See [Multi-window ownership](../architecture/multi-window-ownership.md) for the |
| 488 | 494 | implemented routing guarantees, application-level event sinks and remaining |
| @@ -330,15 +330,16 @@ await settingsWindow.focus() | ||
| 330 | 330 | ``` |
| 331 | 331 | |
| 332 | 332 | Calling `createWindow` again with the same `id` brings the retained native |
| 333 | window forward and returns the same typed handle. Runtime creation currently | |
| 334 | uses the macOS host. `close()` retains that page for a later reopen, while | |
| 335 | `destroy()` permanently releases a runtime-created window; the unnamed primary | |
| 336 | window is not destroyable through this API. Modal and parent-window | |
| 337 | relationships are not supported. | |
| 333 | window forward and returns the same typed handle on macOS, Linux, and Windows. | |
| 334 | On macOS, `close()` retains the page for a later reopen. On Linux and Windows, | |
| 335 | closing releases the native page; reopening the same ID creates a fresh one | |
| 336 | behind the stable typed handle. `destroy()` permanently releases a | |
| 337 | runtime-created window; the unnamed primary window is not destroyable through | |
| 338 | this API. Modal and parent-window relationships are not supported. | |
| 338 | 339 | |
| 339 | 340 | The [multi-window ownership contract](architecture/multi-window-ownership.md) |
| 340 | 341 | tracks which state is window-scoped, which events intentionally target the |
| 341 | primary page and what remains before the feature is cross-platform. | |
| 342 | primary page, and which advanced behaviors remain outside the contract. | |
| 342 | 343 | |
| 343 | 344 | ### Window Lifecycle Events |
| 344 | 345 | |
| @@ -37,20 +37,21 @@ interface Harness { | ||
| 37 | 37 | * they can simply be passed in as parameters — no DOM, no jsdom, and nothing |
| 38 | 38 | * shared between tests. |
| 39 | 39 | */ |
| 40 | function loadBridge(): Harness { | |
| 40 | function loadBridge(platform: 'webkit' | 'webview2' = 'webkit'): Harness { | |
| 41 | 41 | const sent: Envelope[] = [] |
| 42 | 42 | const errors: unknown[][] = [] |
| 43 | 43 | const win: any = { |
| 44 | webkit: { | |
| 45 | messageHandlers: { | |
| 46 | craft: { postMessage: (m: Envelope) => { sent.push(m) } }, | |
| 47 | }, | |
| 48 | }, | |
| 49 | 44 | addEventListener: () => {}, |
| 50 | 45 | removeEventListener: () => {}, |
| 51 | 46 | // The script fires `craft:ready` on load. |
| 52 | 47 | dispatchEvent: () => true, |
| 53 | 48 | } |
| 49 | if (platform === 'webkit') { | |
| 50 | win.webkit = { messageHandlers: { craft: { postMessage: (m: Envelope) => { sent.push(m) } } } } | |
| 51 | } | |
| 52 | else { | |
| 53 | win.chrome = { webview: { postMessage: (m: Envelope) => { sent.push(m) } } } | |
| 54 | } | |
| 54 | 55 | const doc: any = { readyState: 'complete', addEventListener: () => {} } |
| 55 | 56 | const console_: any = { ...console, error: (...a: unknown[]) => { errors.push(a) }, warn: () => {} } |
| 56 | 57 | |
| @@ -84,6 +85,24 @@ function settled(p: Promise<unknown>): Promise<unknown> { | ||
| 84 | 85 | } |
| 85 | 86 | |
| 86 | 87 | describe('bridge reply correlation', () => { |
| 88 | it('uses WebView2 when WebKit is absent and preserves the reply id', async () => { | |
| 89 | const h = loadBridge('webview2') | |
| 90 | const pending = h.craft.tags.get('/windows') | |
| 91 | expect(h.sent).toHaveLength(1) | |
| 92 | expect(h.sent[0]).toMatchObject({ t: 'tags', a: 'get' }) | |
| 93 | expect(h.sent[0].i).toBeGreaterThan(0) | |
| 94 | h.reply('get', { tags: ['native'] }, h.sent[0].i!) | |
| 95 | expect(await pending).toEqual(['native']) | |
| 96 | ||
| 97 | // A page may expose a partial `webkit` object without Craft's handler. | |
| 98 | // It must still use the authenticated WebView2 transport. | |
| 99 | h.win.webkit = { messageHandlers: {} } | |
| 100 | const next = h.craft.tags.get('/windows-next') | |
| 101 | expect(h.sent).toHaveLength(2) | |
| 102 | h.reply('get', { tags: ['again'] }, h.sent[1].i!) | |
| 103 | expect(await next).toEqual(['again']) | |
| 104 | }) | |
| 105 | ||
| 87 | 106 | it('gives each caller its own answer when two bridges share an action name', async () => { |
| 88 | 107 | // `get` is served by both keychain and tags. Before request ids, both |
| 89 | 108 | // callers queued under the string "get" and were matched by arrival order, |
| @@ -140,6 +140,24 @@ describe('typed window-handle routing', () => { | ||
| 140 | 140 | expect(created.id).toBe(id) |
| 141 | 141 | }) |
| 142 | 142 | |
| 143 | it('uses the injected window envelope in WebView2 as well as WebKit', async () => { | |
| 144 | const host = globalThis.window as unknown as { | |
| 145 | webkit?: unknown | |
| 146 | chrome?: { webview: { postMessage: () => void } } | |
| 147 | } | |
| 148 | Reflect.deleteProperty(host, 'webkit') | |
| 149 | host.chrome = { webview: { postMessage: () => {} } } | |
| 150 | const id = fixtureId('webview2') | |
| 151 | const created = await windowManager.create({ id, html: '<h1>Windows</h1>' }) | |
| 152 | await created.setTitle('Windows') | |
| 153 | callResult = id | |
| 154 | ||
| 155 | expect(open).toHaveBeenCalledWith({ id, html: '<h1>Windows</h1>' }) | |
| 156 | expect(call).toHaveBeenCalledWith('setTitle', { title: 'Windows' }, id) | |
| 157 | expect(await windowManager.getFocused()).toBe(created) | |
| 158 | expect(call).toHaveBeenCalledWith('getFocused', undefined, 'main') | |
| 159 | }) | |
| 160 | ||
| 143 | 161 | it('refuses the local main alias before opening a child window', async () => { |
| 144 | 162 | await expect(windowManager.create({ id: 'main', html: '<h1>Orphan</h1>' })) |
| 145 | 163 | .rejects.toThrow('reserved for the current window') |
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { getBridge } from '../bridge/core.js' |
| 8 | import { isWebKitHost } from '../bridge/webkit-pending.js' | |
| 8 | import { isDesktopWebViewHost } from '../bridge/webkit-pending.js' | |
| 9 | 9 | |
| 10 | 10 | interface InjectedWindowBridge { |
| 11 | 11 | _call<T>(action: string, data: Record<string, any> | undefined, windowId: string): Promise<T> |
| @@ -740,7 +740,7 @@ export class Window { | ||
| 740 | 740 | // ========================================================================== |
| 741 | 741 | |
| 742 | 742 | private async _call<T = void>(action: string, data?: Record<string, any>): Promise<T> { |
| 743 | if (isWebKitHost()) { | |
| 743 | if (isDesktopWebViewHost()) { | |
| 744 | 744 | const injected = getInjectedWindowBridge() |
| 745 | 745 | if (!injected?._call) { |
| 746 | 746 | throw new Error('Craft window bridge is unavailable') |
| @@ -813,7 +813,7 @@ class WindowManager { | ||
| 813 | 813 | } |
| 814 | 814 | const existing = this._windows.get(id) |
| 815 | 815 | |
| 816 | if (isWebKitHost()) { | |
| 816 | if (isDesktopWebViewHost()) { | |
| 817 | 817 | const injected = getInjectedWindowBridge() |
| 818 | 818 | if (!injected?.open) { |
| 819 | 819 | throw new Error('Craft window bridge is unavailable') |
| @@ -844,7 +844,7 @@ class WindowManager { | ||
| 844 | 844 | * Get focused window |
| 845 | 845 | */ |
| 846 | 846 | async getFocused(): Promise<Window | null> { |
| 847 | if (isWebKitHost()) { | |
| 847 | if (isDesktopWebViewHost()) { | |
| 848 | 848 | const injected = getInjectedWindowBridge() |
| 849 | 849 | if (!injected?._call) { |
| 850 | 850 | throw new Error('Craft window bridge is unavailable') |
| @@ -73,6 +73,14 @@ export function isWebKitHost(): boolean { | ||
| 73 | 73 | return typeof window !== 'undefined' && !!window.webkit?.messageHandlers?.craft |
| 74 | 74 | } |
| 75 | 75 | |
| 76 | /** Desktop window handles use the injected Craft envelope on both WebKit and WebView2. */ | |
| 77 | export function isDesktopWebViewHost(): boolean { | |
| 78 | if (isWebKitHost()) return true | |
| 79 | if (typeof window === 'undefined') return false | |
| 80 | const host = window as Window & { chrome?: { webview?: { postMessage?: (message: unknown) => void } } } | |
| 81 | return typeof host.chrome?.webview?.postMessage === 'function' | |
| 82 | } | |
| 83 | ||
| 76 | 84 | /** |
| 77 | 85 | * Send a fire-and-forget message into the WKWebView host. Returns true if |
| 78 | 86 | * the message was delivered, false otherwise. |
| @@ -1247,6 +1247,62 @@ pub fn build(b: *std.Build) void { | ||
| 1247 | 1247 | }), |
| 1248 | 1248 | }); |
| 1249 | 1249 | |
| 1250 | const window_reply_target_tests = b.addTest(.{ | |
| 1251 | .root_module = b.createModule(.{ | |
| 1252 | .root_source_file = b.path("src/window_reply_target.zig"), | |
| 1253 | .target = target, | |
| 1254 | .optimize = optimize, | |
| 1255 | }), | |
| 1256 | }); | |
| 1257 | ||
| 1258 | const desktop_window_registry_tests = b.addTest(.{ | |
| 1259 | .root_module = b.createModule(.{ | |
| 1260 | .root_source_file = b.path("src/desktop_window_registry.zig"), | |
| 1261 | .target = target, | |
| 1262 | .optimize = optimize, | |
| 1263 | }), | |
| 1264 | }); | |
| 1265 | ||
| 1266 | const desktop_bridge_envelope_tests = b.addTest(.{ | |
| 1267 | .root_module = b.createModule(.{ | |
| 1268 | .root_source_file = b.path("src/desktop_bridge_envelope.zig"), | |
| 1269 | .target = target, | |
| 1270 | .optimize = optimize, | |
| 1271 | }), | |
| 1272 | }); | |
| 1273 | ||
| 1274 | const desktop_bridge_text_tests = b.addTest(.{ | |
| 1275 | .root_module = b.createModule(.{ | |
| 1276 | .root_source_file = b.path("src/desktop_bridge_text.zig"), | |
| 1277 | .target = target, | |
| 1278 | .optimize = optimize, | |
| 1279 | }), | |
| 1280 | }); | |
| 1281 | ||
| 1282 | const desktop_window_events_tests = b.addTest(.{ | |
| 1283 | .root_module = b.createModule(.{ | |
| 1284 | .root_source_file = b.path("src/desktop_window_events.zig"), | |
| 1285 | .target = target, | |
| 1286 | .optimize = optimize, | |
| 1287 | }), | |
| 1288 | }); | |
| 1289 | ||
| 1290 | const desktop_window_reads_tests = b.addTest(.{ | |
| 1291 | .root_module = b.createModule(.{ | |
| 1292 | .root_source_file = b.path("src/desktop_window_reads.zig"), | |
| 1293 | .target = target, | |
| 1294 | .optimize = optimize, | |
| 1295 | }), | |
| 1296 | }); | |
| 1297 | ||
| 1298 | const desktop_script_encoding_tests = b.addTest(.{ | |
| 1299 | .root_module = b.createModule(.{ | |
| 1300 | .root_source_file = b.path("src/desktop_script_encoding.zig"), | |
| 1301 | .target = target, | |
| 1302 | .optimize = optimize, | |
| 1303 | }), | |
| 1304 | }); | |
| 1305 | ||
| 1250 | 1306 | // The shell bridge's argv construction: what a `spawn` actually runs. |
| 1251 | 1307 | // Untested until now, which is how it shipped parsing an `args` array and |
| 1252 | 1308 | // throwing it away. |
| @@ -1700,6 +1756,13 @@ pub fn build(b: *std.Build) void { | ||
| 1700 | 1756 | const run_window_lifecycle_tests = b.addRunArtifact(window_lifecycle_tests); |
| 1701 | 1757 | const run_window_registry_tests = b.addRunArtifact(window_registry_tests); |
| 1702 | 1758 | const run_window_context_tests = b.addRunArtifact(window_context_tests); |
| 1759 | const run_window_reply_target_tests = b.addRunArtifact(window_reply_target_tests); | |
| 1760 | const run_desktop_window_registry_tests = b.addRunArtifact(desktop_window_registry_tests); | |
| 1761 | const run_desktop_bridge_envelope_tests = b.addRunArtifact(desktop_bridge_envelope_tests); | |
| 1762 | const run_desktop_bridge_text_tests = b.addRunArtifact(desktop_bridge_text_tests); | |
| 1763 | const run_desktop_window_events_tests = b.addRunArtifact(desktop_window_events_tests); | |
| 1764 | const run_desktop_window_reads_tests = b.addRunArtifact(desktop_window_reads_tests); | |
| 1765 | const run_desktop_script_encoding_tests = b.addRunArtifact(desktop_script_encoding_tests); | |
| 1703 | 1766 | const run_external_link_tests = b.addRunArtifact(external_link_tests); |
| 1704 | 1767 | const run_webview_recovery_tests = b.addRunArtifact(webview_recovery_tests); |
| 1705 | 1768 | const run_request_context_tests = b.addRunArtifact(request_context_tests); |
| @@ -1848,6 +1911,13 @@ pub fn build(b: *std.Build) void { | ||
| 1848 | 1911 | test_step.dependOn(&run_window_lifecycle_tests.step); |
| 1849 | 1912 | test_step.dependOn(&run_window_registry_tests.step); |
| 1850 | 1913 | test_step.dependOn(&run_window_context_tests.step); |
| 1914 | test_step.dependOn(&run_window_reply_target_tests.step); | |
| 1915 | test_step.dependOn(&run_desktop_window_registry_tests.step); | |
| 1916 | test_step.dependOn(&run_desktop_bridge_envelope_tests.step); | |
| 1917 | test_step.dependOn(&run_desktop_bridge_text_tests.step); | |
| 1918 | test_step.dependOn(&run_desktop_window_events_tests.step); | |
| 1919 | test_step.dependOn(&run_desktop_window_reads_tests.step); | |
| 1920 | test_step.dependOn(&run_desktop_script_encoding_tests.step); | |
| 1851 | 1921 | test_step.dependOn(&run_external_link_tests.step); |
| 1852 | 1922 | test_step.dependOn(&run_webview_recovery_tests.step); |
| 1853 | 1923 | test_step.dependOn(&run_request_context_tests.step); |
| @@ -2817,6 +2887,7 @@ fn linkPlatformLibraries(b: *std.Build, module: *std.Build.Module, target_os: st | ||
| 2817 | 2887 | .linux => { |
| 2818 | 2888 | module.linkSystemLibrary("gtk+-3.0", .{}); |
| 2819 | 2889 | module.linkSystemLibrary("webkit2gtk-4.1", .{}); |
| 2890 | module.linkSystemLibrary("javascriptcoregtk-4.1", .{}); | |
| 2820 | 2891 | }, |
| 2821 | 2892 | .windows => { |
| 2822 | 2893 | module.linkSystemLibrary("ole32", .{}); |
| @@ -0,0 +1,77 @@ | ||
| 1 | //! The JSON envelope posted by the injected desktop page bridge. | |
| 2 | //! | |
| 3 | //! The page supplies `t`, `a`, `d` and a correlation id, never a native | |
| 4 | //! window pointer. The platform callback supplies the actual sender handles. | |
| 5 | ||
| 6 | const std = ; | |
| 7 | const request_context = ; | |
| 8 | ||
| 9 | pub const max_message_bytes = 1024 * 1024; | |
| 10 | ||
| 11 | pub const Envelope = struct { | |
| 12 | parsed: std.json.Parsed(std.json.Value), | |
| 13 | kind: []const u8, | |
| 14 | action: []const u8, | |
| 15 | data: ?[]const u8, | |
| 16 | request_id: ?u64, | |
| 17 | ||
| 18 | pub fn deinit(self: *Envelope) void { | |
| 19 | self.parsed.deinit(); | |
| 20 | } | |
| 21 | }; | |
| 22 | ||
| 23 | pub fn parse(allocator: std.mem.Allocator, source: []const u8) !Envelope { | |
| 24 | if (source.len > max_message_bytes) return error.MessageTooLarge; | |
| 25 | var parsed = try std.json.parseFromSlice(std.json.Value, allocator, source, .{}); | |
| 26 | errdefer parsed.deinit(); | |
| 27 | if (parsed.value != .object) return error.InvalidBridgeMessage; | |
| 28 | ||
| 29 | const root = parsed.value.object; | |
| 30 | const kind_value = root.get("t") orelse return error.InvalidBridgeMessage; | |
| 31 | const action_value = root.get("a") orelse return error.InvalidBridgeMessage; | |
| 32 | if (kind_value != .string or action_value != .string) return error.InvalidBridgeMessage; | |
| 33 | if (kind_value.string.len == 0 or action_value.string.len == 0) return error.InvalidBridgeMessage; | |
| 34 | ||
| 35 | const data_value = root.get("d"); | |
| 36 | const data: ?[]const u8 = if (data_value) |value| switch (value) { | |
| 37 | .string => |text| if (text.len == 0) null else text, | |
| 38 | .null => null, | |
| 39 | else => return error.InvalidBridgeMessage, | |
| 40 | } else null; | |
| 41 | ||
| 42 | return .{ | |
| 43 | .parsed = parsed, | |
| 44 | .kind = kind_value.string, | |
| 45 | .action = action_value.string, | |
| 46 | .data = data, | |
| 47 | .request_id = request_context.fromEnvelope(root), | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | test "reads a correlated window request without trusting a sender field" { | |
| 52 | var envelope = try parse(std.testing.allocator, | |
| 53 | \\{"t":"window","a":"open","d":"{\"name\":\"settings\"}","i":41,"window":1234} | |
| 54 | ); | |
| 55 | defer envelope.deinit(); | |
| 56 | try std.testing.expectEqualStrings("window", envelope.kind); | |
| 57 | try std.testing.expectEqualStrings("open", envelope.action); | |
| 58 | try std.testing.expectEqualStrings("{\"name\":\"settings\"}", envelope.data.?); | |
| 59 | try std.testing.expectEqual(@as(?u64, 41), envelope.request_id); | |
| 60 | } | |
| 61 | ||
| 62 | test "rejects malformed and oversize messages" { | |
| 63 | try std.testing.expectError(error.InvalidBridgeMessage, parse(std.testing.allocator, "[]")); | |
| 64 | try std.testing.expectError(error.InvalidBridgeMessage, parse(std.testing.allocator, "{\"t\":\"window\"}")); | |
| 65 | try std.testing.expectError(error.InvalidBridgeMessage, parse(std.testing.allocator, "{\"t\":\"window\",\"a\":\"open\",\"d\":{}}")); | |
| 66 | try std.testing.expectError(error.InvalidBridgeMessage, parse(std.testing.allocator, "{\"t\":\"window\",\"a\":\"\"}")); | |
| 67 | const large = try std.testing.allocator.alloc(u8, max_message_bytes + 1); | |
| 68 | defer std.testing.allocator.free(large); | |
| 69 | try std.testing.expectError(error.MessageTooLarge, parse(std.testing.allocator, large)); | |
| 70 | } | |
| 71 | ||
| 72 | test "an id-less fire-and-forget action does not inherit a pending reply" { | |
| 73 | var envelope = try parse(std.testing.allocator, "{\"t\":\"window\",\"a\":\"close\",\"d\":\"\"}"); | |
| 74 | defer envelope.deinit(); | |
| 75 | try std.testing.expect(envelope.data == null); | |
| 76 | try std.testing.expect(envelope.request_id == null); | |
| 77 | } | |
| @@ -0,0 +1,25 @@ | ||
| 1 | //! Decode WebView2's UTF-16 message with the same byte limit as the JSON | |
| 2 | //! envelope parser. This stays platform-neutral so CI can test the boundary. | |
| 3 | ||
| 4 | const std = ; | |
| 5 | const desktop_bridge_envelope = ; | |
| 6 | ||
| 7 | pub fn fromUtf16(allocator: std.mem.Allocator, wide: []const u16) ![:0]u8 { | |
| 8 | if (wide.len > desktop_bridge_envelope.max_message_bytes) return error.MessageTooLarge; | |
| 9 | const utf8 = try std.unicode.utf16LeToUtf8AllocZ(allocator, wide); | |
| 10 | errdefer allocator.free(utf8); | |
| 11 | if (utf8.len > desktop_bridge_envelope.max_message_bytes) return error.MessageTooLarge; | |
| 12 | return utf8; | |
| 13 | } | |
| 14 | ||
| 15 | test "converts WebView2 JSON with non-ASCII window names" { | |
| 16 | const message = try fromUtf16(std.testing.allocator, &.{ '{', '"', 'a', '"', ':', '"', 0x00E9, '"', '}' }); | |
| 17 | defer std.testing.allocator.free(message); | |
| 18 | try std.testing.expectEqualStrings("{\"a\":\"é\"}", message); | |
| 19 | } | |
| 20 | ||
| 21 | test "rejects an oversized UTF-16 message before decoding" { | |
| 22 | const wide = try std.testing.allocator.alloc(u16, desktop_bridge_envelope.max_message_bytes + 1); | |
| 23 | defer std.testing.allocator.free(wide); | |
| 24 | try std.testing.expectError(error.MessageTooLarge, fromUtf16(std.testing.allocator, wide)); | |
| 25 | } | |
| @@ -0,0 +1,55 @@ | ||
| 1 | //! UTF-16 storage for WebView2 document-start scripts. | |
| 2 | //! | |
| 3 | //! The embedded Craft bridge is larger than the old 16 KiB stack buffer. | |
| 4 | //! Keep the backing allocation alive until WebView2 confirms installation, | |
| 5 | //! and preserve its original length so the allocator can free it correctly. | |
| 6 | ||
| 7 | const std = ; | |
| 8 | ||
| 9 | pub const WideScript = struct { | |
| 10 | storage: []u16, | |
| 11 | length: usize, | |
| 12 | ||
| 13 | pub fn ptr(self: *const WideScript) [*:0]const u16 { | |
| 14 | return @ptrCast(self.storage.ptr); | |
| 15 | } | |
| 16 | ||
| 17 | pub fn deinit(self: *WideScript, allocator: std.mem.Allocator) void { | |
| 18 | allocator.free(self.storage); | |
| 19 | } | |
| 20 | }; | |
| 21 | ||
| 22 | pub fn encode(allocator: std.mem.Allocator, source: []const u8) !WideScript { | |
| 23 | if (std.mem.indexOfScalar(u8, source, 0) != null) return error.InvalidScript; | |
| 24 | // Each UTF-16 code unit needs at least one UTF-8 byte, so source.len is | |
| 25 | // a safe upper bound for code units, plus the terminating zero. | |
| 26 | const storage = try allocator.alloc(u16, source.len + 1); | |
| 27 | errdefer allocator.free(storage); | |
| 28 | const length = try std.unicode.utf8ToUtf16Le(storage[0..source.len], source); | |
| 29 | storage[length] = 0; | |
| 30 | return .{ .storage = storage, .length = length }; | |
| 31 | } | |
| 32 | ||
| 33 | test "a document-start script larger than the former stack limit is retained" { | |
| 34 | const allocator = std.testing.allocator; | |
| 35 | const script = try allocator.alloc(u8, 20_000); | |
| 36 | defer allocator.free(script); | |
| 37 | @memset(script, 'a'); | |
| 38 | ||
| 39 | var wide = try encode(allocator, script); | |
| 40 | defer wide.deinit(allocator); | |
| 41 | try std.testing.expectEqual(@as(usize, 20_000), wide.length); | |
| 42 | try std.testing.expectEqual(@as(u16, 0), wide.ptr()[wide.length]); | |
| 43 | try std.testing.expectEqual(@as(u16, 'a'), wide.ptr()[19_999]); | |
| 44 | } | |
| 45 | ||
| 46 | test "UTF-8 characters count UTF-16 code units rather than bytes" { | |
| 47 | var wide = try encode(std.testing.allocator, "Aé😀"); | |
| 48 | defer wide.deinit(std.testing.allocator); | |
| 49 | try std.testing.expectEqual(@as(usize, 4), wide.length); | |
| 50 | try std.testing.expectEqual(@as(u16, 0), wide.ptr()[4]); | |
| 51 | } | |
| 52 | ||
| 53 | test "an embedded NUL cannot silently truncate the installed script" { | |
| 54 | try std.testing.expectError(error.InvalidScript, encode(std.testing.allocator, "one\x00two")); | |
| 55 | } | |
| @@ -0,0 +1,38 @@ | ||
| 1 | //! JS delivery for a native desktop window transition. Platform code chooses | |
| 2 | //! the actual live WebView; this module only formats the event payload. | |
| 3 | ||
| 4 | const std = ; | |
| 5 | const bridge_error = ; | |
| 6 | ||
| 7 | pub fn format( | |
| 8 | allocator: std.mem.Allocator, | |
| 9 | event_name: []const u8, | |
| 10 | detail_json: []const u8, | |
| 11 | window_name: ?[]const u8, | |
| 12 | ) ![]u8 { | |
| 13 | var script: std.ArrayListUnmanaged(u8) = .empty; | |
| 14 | errdefer script.deinit(allocator); | |
| 15 | try script.appendSlice(allocator, "if(window.__craftDeliverWindowEvent)window.__craftDeliverWindowEvent(\""); | |
| 16 | try bridge_error.appendJsonEscaped(allocator, &script, event_name); | |
| 17 | try script.appendSlice(allocator, "\","); | |
| 18 | try script.appendSlice(allocator, if (detail_json.len == 0) "{}" else detail_json); | |
| 19 | if (window_name) |name| { | |
| 20 | try script.appendSlice(allocator, ",\""); | |
| 21 | try bridge_error.appendJsonEscaped(allocator, &script, name); | |
| 22 | try script.append(allocator, '"'); | |
| 23 | } | |
| 24 | try script.appendSlice(allocator, ");"); | |
| 25 | return script.toOwnedSlice(allocator); | |
| 26 | } | |
| 27 | ||
| 28 | test "a local transition addresses only the page's main handle" { | |
| 29 | const script = try format(std.testing.allocator, "focus", "", null); | |
| 30 | defer std.testing.allocator.free(script); | |
| 31 | try std.testing.expectEqualStrings("if(window.__craftDeliverWindowEvent)window.__craftDeliverWindowEvent(\"focus\",{});", script); | |
| 32 | } | |
| 33 | ||
| 34 | test "a creator receives the named child event with escaped id" { | |
| 35 | const script = try format(std.testing.allocator, "resize", "{\"width\":640}", "set\"tings"); | |
| 36 | defer std.testing.allocator.free(script); | |
| 37 | try std.testing.expectEqualStrings("if(window.__craftDeliverWindowEvent)window.__craftDeliverWindowEvent(\"resize\",{\"width\":640},\"set\\\"tings\");", script); | |
| 38 | } | |
| @@ -0,0 +1,70 @@ | ||
| 1 | //! Results for read-only desktop window actions. The native caller supplies | |
| 2 | //! live OS values; this module preserves the SDK's JSON reply shapes. | |
| 3 | ||
| 4 | const std = ; | |
| 5 | const bridge_error = ; | |
| 6 | const desktop_window_registry = ; | |
| 7 | ||
| 8 | pub fn string(allocator: std.mem.Allocator, value: ?[]const u8) ![]u8 { | |
| 9 | const text = value orelse return allocator.dupe(u8, "null"); | |
| 10 | var json: std.ArrayListUnmanaged(u8) = .empty; | |
| 11 | errdefer json.deinit(allocator); | |
| 12 | try json.append(allocator, '"'); | |
| 13 | try bridge_error.appendJsonEscaped(allocator, &json, text); | |
| 14 | try json.append(allocator, '"'); | |
| 15 | return json.toOwnedSlice(allocator); | |
| 16 | } | |
| 17 | ||
| 18 | pub fn geometry(allocator: std.mem.Allocator, action: []const u8, bounds: desktop_window_registry.Geometry) ![]u8 { | |
| 19 | if (std.mem.eql(u8, action, "getSize")) | |
| 20 | return std.fmt.allocPrint(allocator, "", .{ bounds.width, bounds.height }); | |
| 21 | if (std.mem.eql(u8, action, "getPosition")) | |
| 22 | return std.fmt.allocPrint(allocator, "", .{ bounds.x, bounds.y }); | |
| 23 | if (std.mem.eql(u8, action, "getBounds")) | |
| 24 | return std.fmt.allocPrint(allocator, "", .{ bounds.x, bounds.y, bounds.width, bounds.height }); | |
| 25 | return error.UnknownAction; | |
| 26 | } | |
| 27 | ||
| 28 | /// Never return a name for an OS window that has already been destroyed or | |
| 29 | /// belongs to another process. Every page calls its own window `main`. | |
| 30 | pub fn focusedName( | |
| 31 | live: *const desktop_window_registry.Registry, | |
| 32 | sender: usize, | |
| 33 | focused: usize, | |
| 34 | named: ?[]const u8, | |
| 35 | ) ?[]const u8 { | |
| 36 | if (live.byWindow(focused) == null) return null; | |
| 37 | if (sender == focused) return "main"; | |
| 38 | return named; | |
| 39 | } | |
| 40 | ||
| 41 | test "read results preserve SDK shapes and escape names" { | |
| 42 | const allocator = std.testing.allocator; | |
| 43 | const name = try string(allocator, "set\"tings"); | |
| 44 | defer allocator.free(name); | |
| 45 | try std.testing.expectEqualStrings("\"set\\\"tings\"", name); | |
| 46 | const absent = try string(allocator, null); | |
| 47 | defer allocator.free(absent); | |
| 48 | try std.testing.expectEqualStrings("null", absent); | |
| 49 | const bounds: desktop_window_registry.Geometry = .{ .x = -20, .y = 30, .width = 800, .height = 600 }; | |
| 50 | const size = try geometry(allocator, "getSize", bounds); | |
| 51 | defer allocator.free(size); | |
| 52 | try std.testing.expectEqualStrings("{\"width\":800,\"height\":600}", size); | |
| 53 | const position = try geometry(allocator, "getPosition", bounds); | |
| 54 | defer allocator.free(position); | |
| 55 | try std.testing.expectEqualStrings("{\"x\":-20,\"y\":30}", position); | |
| 56 | const full = try geometry(allocator, "getBounds", bounds); | |
| 57 | defer allocator.free(full); | |
| 58 | try std.testing.expectEqualStrings("{\"x\":-20,\"y\":30,\"width\":800,\"height\":600}", full); | |
| 59 | } | |
| 60 | ||
| 61 | test "focused name is scoped to a live window and the sender alias" { | |
| 62 | var live: desktop_window_registry.Registry = .{}; | |
| 63 | _ = live.remember(0x1000, 0x1001); | |
| 64 | _ = live.remember(0x2000, 0x2001); | |
| 65 | try std.testing.expectEqualStrings("main", focusedName(&live, 0x1000, 0x1000, null).?); | |
| 66 | try std.testing.expectEqualStrings("settings", focusedName(&live, 0x1000, 0x2000, "settings").?); | |
| 67 | try std.testing.expect(focusedName(&live, 0x1000, 0x3000, "other") == null); | |
| 68 | _ = live.forgetWindow(0x2000); | |
| 69 | try std.testing.expect(focusedName(&live, 0x1000, 0x2000, "settings") == null); | |
| 70 | } | |
| @@ -0,0 +1,294 @@ | ||
| 1 | //! Platform-neutral live desktop window handles. | |
| 2 | //! | |
| 3 | //! A native close can arrive without going through `Window.close` (the titlebar | |
| 4 | //! close button, for example). The platform callback must forget that window | |
| 5 | //! before any later bridge reply or resize selects its destroyed webview. | |
| 6 | ||
| 7 | const std = ; | |
| 8 | ||
| 9 | pub const capacity = 32; | |
| 10 | pub const Geometry = struct { | |
| 11 | x: i32, | |
| 12 | y: i32, | |
| 13 | width: u32, | |
| 14 | height: u32, | |
| 15 | }; | |
| 16 | ||
| 17 | pub const GeometryChange = struct { | |
| 18 | moved: bool, | |
| 19 | resized: bool, | |
| 20 | }; | |
| 21 | ||
| 22 | pub const StateChange = struct { | |
| 23 | minimized: ?bool, | |
| 24 | fullscreen: ?bool, | |
| 25 | }; | |
| 26 | ||
| 27 | pub const Entry = struct { | |
| 28 | id: u32, | |
| 29 | window: usize, | |
| 30 | webview: usize, | |
| 31 | /// Platform-specific control object (WebView2 controller on Windows). | |
| 32 | context: usize = 0, | |
| 33 | /// WebView2 subscription to remove before releasing a closed webview. | |
| 34 | message_token: ?i64 = null, | |
| 35 | geometry: ?Geometry = null, | |
| 36 | minimized: bool = false, | |
| 37 | fullscreen: bool = false, | |
| 38 | }; | |
| 39 | ||
| 40 | pub const Registry = struct { | |
| 41 | entries: [capacity]?Entry = @splat(null), | |
| 42 | next_id: u32 = 1, | |
| 43 | ||
| 44 | pub fn remember(self: *Registry, window: usize, webview: usize) ?u32 { | |
| 45 | return self.rememberWithContext(window, webview, 0); | |
| 46 | } | |
| 47 | ||
| 48 | pub fn rememberWithContext(self: *Registry, window: usize, webview: usize, context: usize) ?u32 { | |
| 49 | if (window == 0 or webview == 0 or self.byWindow(window) != null or self.byWebview(webview) != null or self.next_id == 0) return null; | |
| 50 | for (&self.entries) |*slot| { | |
| 51 | if (slot.* == null) { | |
| 52 | const id = self.next_id; | |
| 53 | // Exhaust rather than reuse an id that a caller may still hold. | |
| 54 | self.next_id = if (id == std.math.maxInt(u32)) 0 else id + 1; | |
| 55 | slot.* = .{ .id = id, .window = window, .webview = webview, .context = context }; | |
| 56 | return id; | |
| 57 | } | |
| 58 | } | |
| 59 | return null; | |
| 60 | } | |
| 61 | ||
| 62 | pub fn byId(self: *const Registry, id: u32) ?Entry { | |
| 63 | for (self.entries) |slot| { | |
| 64 | if (slot) |entry| { | |
| 65 | if (entry.id == id) return entry; | |
| 66 | } | |
| 67 | } | |
| 68 | return null; | |
| 69 | } | |
| 70 | ||
| 71 | pub fn byWindow(self: *const Registry, window: usize) ?Entry { | |
| 72 | if (window == 0) return null; | |
| 73 | for (self.entries) |slot| { | |
| 74 | if (slot) |entry| { | |
| 75 | if (entry.window == window) return entry; | |
| 76 | } | |
| 77 | } | |
| 78 | return null; | |
| 79 | } | |
| 80 | ||
| 81 | pub fn byWebview(self: *const Registry, webview: usize) ?Entry { | |
| 82 | if (webview == 0) return null; | |
| 83 | for (self.entries) |slot| { | |
| 84 | if (slot) |entry| { | |
| 85 | if (entry.webview == webview) return entry; | |
| 86 | } | |
| 87 | } | |
| 88 | return null; | |
| 89 | } | |
| 90 | ||
| 91 | pub fn setMessageToken(self: *Registry, window: usize, token: i64) bool { | |
| 92 | if (window == 0) return false; | |
| 93 | for (&self.entries) |*slot| { | |
| 94 | if (slot.*) |*entry| { | |
| 95 | if (entry.window == window) { | |
| 96 | entry.message_token = token; | |
| 97 | return true; | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | return false; | |
| 102 | } | |
| 103 | ||
| 104 | pub fn observeGeometry(self: *Registry, window: usize, next: Geometry) ?GeometryChange { | |
| 105 | if (window == 0) return null; | |
| 106 | for (&self.entries) |*slot| { | |
| 107 | if (slot.*) |*entry| { | |
| 108 | if (entry.window == window) { | |
| 109 | const previous = entry.geometry; | |
| 110 | entry.geometry = next; | |
| 111 | if (previous) |old| return .{ | |
| 112 | .moved = old.x != next.x or old.y != next.y, | |
| 113 | .resized = old.width != next.width or old.height != next.height, | |
| 114 | }; | |
| 115 | return .{ .moved = false, .resized = false }; | |
| 116 | } | |
| 117 | } | |
| 118 | } | |
| 119 | return null; | |
| 120 | } | |
| 121 | ||
| 122 | pub fn observeState(self: *Registry, window: usize, minimized: bool, fullscreen: bool) ?StateChange { | |
| 123 | if (window == 0) return null; | |
| 124 | for (&self.entries) |*slot| { | |
| 125 | if (slot.*) |*entry| { | |
| 126 | if (entry.window == window) { | |
| 127 | const change: StateChange = .{ | |
| 128 | .minimized = if (entry.minimized != minimized) minimized else null, | |
| 129 | .fullscreen = if (entry.fullscreen != fullscreen) fullscreen else null, | |
| 130 | }; | |
| 131 | entry.minimized = minimized; | |
| 132 | entry.fullscreen = fullscreen; | |
| 133 | return change; | |
| 134 | } | |
| 135 | } | |
| 136 | } | |
| 137 | return null; | |
| 138 | } | |
| 139 | ||
| 140 | pub fn latest(self: *const Registry) ?Entry { | |
| 141 | var result: ?Entry = null; | |
| 142 | for (self.entries) |slot| { | |
| 143 | if (slot) |entry| { | |
| 144 | if (result == null or entry.id > result.?.id) result = entry; | |
| 145 | } | |
| 146 | } | |
| 147 | return result; | |
| 148 | } | |
| 149 | ||
| 150 | pub fn forgetWindow(self: *Registry, window: usize) ?Entry { | |
| 151 | if (window == 0) return null; | |
| 152 | for (&self.entries) |*slot| { | |
| 153 | if (slot.*) |entry| { | |
| 154 | if (entry.window == window) { | |
| 155 | slot.* = null; | |
| 156 | return entry; | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | return null; | |
| 161 | } | |
| 162 | ||
| 163 | pub fn count(self: *const Registry) usize { | |
| 164 | var total: usize = 0; | |
| 165 | for (self.entries) |slot| { | |
| 166 | if (slot != null) total += 1; | |
| 167 | } | |
| 168 | return total; | |
| 169 | } | |
| 170 | ||
| 171 | pub fn liveWebviews(self: *const Registry, out: *[capacity]usize) []const usize { | |
| 172 | var total: usize = 0; | |
| 173 | for (self.entries) |slot| { | |
| 174 | if (slot) |entry| { | |
| 175 | out[total] = entry.webview; | |
| 176 | total += 1; | |
| 177 | } | |
| 178 | } | |
| 179 | return out[0..total]; | |
| 180 | } | |
| 181 | }; | |
| 182 | ||
| 183 | test "closing one window retains the other and reopen gets a new identity" { | |
| 184 | var registry: Registry = .{}; | |
| 185 | const first = registry.remember(0x1000, 0x1001).?; | |
| 186 | const second = registry.remember(0x2000, 0x2001).?; | |
| 187 | try std.testing.expectEqual(@as(usize, 2), registry.count()); | |
| 188 | try std.testing.expectEqual(first, registry.forgetWindow(0x1000).?.id); | |
| 189 | try std.testing.expectEqual(@as(usize, 1), registry.count()); | |
| 190 | try std.testing.expectEqual(second, registry.latest().?.id); | |
| 191 | try std.testing.expect(registry.byId(first) == null); | |
| 192 | ||
| 193 | const reopened = registry.remember(0x3000, 0x3001).?; | |
| 194 | try std.testing.expect(reopened != first and reopened != second); | |
| 195 | try std.testing.expectEqual(reopened, registry.latest().?.id); | |
| 196 | try std.testing.expectEqual(@as(usize, 2), registry.count()); | |
| 197 | } | |
| 198 | ||
| 199 | test "invalid and duplicate native handles never occupy a slot" { | |
| 200 | var registry: Registry = .{}; | |
| 201 | try std.testing.expect(registry.remember(0, 0x1001) == null); | |
| 202 | try std.testing.expect(registry.remember(0x1000, 0) == null); | |
| 203 | try std.testing.expect(registry.remember(0x1000, 0x1001) != null); | |
| 204 | try std.testing.expect(registry.remember(0x1000, 0x2001) == null); | |
| 205 | try std.testing.expect(registry.remember(0x2000, 0x1001) == null); | |
| 206 | try std.testing.expectEqual(@as(usize, 1), registry.count()); | |
| 207 | try std.testing.expect(registry.forgetWindow(0x2000) == null); | |
| 208 | try std.testing.expectEqual(@as(usize, 1), registry.count()); | |
| 209 | } | |
| 210 | ||
| 211 | test "a full registry rejects creation without displacing a live window" { | |
| 212 | var registry: Registry = .{}; | |
| 213 | for (0..capacity) |i| { | |
| 214 | try std.testing.expect(registry.remember(i + 1, i + 100) != null); | |
| 215 | } | |
| 216 | try std.testing.expect(registry.remember(0x9000, 0x9001) == null); | |
| 217 | try std.testing.expectEqual(@as(usize, capacity), registry.count()); | |
| 218 | try std.testing.expect(registry.byWindow(1) != null); | |
| 219 | } | |
| 220 | ||
| 221 | test "reply candidates contain only live webviews" { | |
| 222 | var registry: Registry = .{}; | |
| 223 | _ = registry.remember(0x1000, 0x1001); | |
| 224 | _ = registry.remember(0x2000, 0x2001); | |
| 225 | _ = registry.forgetWindow(0x1000); | |
| 226 | var handles: [capacity]usize = undefined; | |
| 227 | try std.testing.expectEqualSlices(usize, &.{0x2001}, registry.liveWebviews(&handles)); | |
| 228 | _ = registry.forgetWindow(0x2000); | |
| 229 | try std.testing.expectEqual(@as(usize, 0), registry.liveWebviews(&handles).len); | |
| 230 | } | |
| 231 | ||
| 232 | test "a web message subscription belongs only to its live window" { | |
| 233 | var registry: Registry = .{}; | |
| 234 | _ = registry.remember(0x1000, 0x1001); | |
| 235 | _ = registry.remember(0x2000, 0x2001); | |
| 236 | try std.testing.expect(registry.setMessageToken(0x1000, 42)); | |
| 237 | try std.testing.expect(!registry.setMessageToken(0x3000, 99)); | |
| 238 | try std.testing.expectEqual(@as(?i64, 42), registry.byWebview(0x1001).?.message_token); | |
| 239 | try std.testing.expect(registry.byWebview(0x2001).?.message_token == null); | |
| 240 | try std.testing.expectEqual(@as(?i64, 42), registry.forgetWindow(0x1000).?.message_token); | |
| 241 | try std.testing.expect(registry.byWebview(0x1001) == null); | |
| 242 | } | |
| 243 | ||
| 244 | test "geometry changes belong to one live window and reset on reopen" { | |
| 245 | var registry: Registry = .{}; | |
| 246 | _ = registry.remember(0x1000, 0x1001); | |
| 247 | _ = registry.remember(0x2000, 0x2001); | |
| 248 | const initial: Geometry = .{ .x = 10, .y = 20, .width = 800, .height = 600 }; | |
| 249 | const first = registry.observeGeometry(0x1000, initial).?; | |
| 250 | try std.testing.expect(!first.moved and !first.resized); | |
| 251 | const changed = registry.observeGeometry(0x1000, .{ .x = 30, .y = 20, .width = 900, .height = 600 }).?; | |
| 252 | try std.testing.expect(changed.moved and changed.resized); | |
| 253 | try std.testing.expect(registry.byWindow(0x2000).?.geometry == null); | |
| 254 | _ = registry.forgetWindow(0x1000); | |
| 255 | try std.testing.expect(registry.observeGeometry(0x1000, initial) == null); | |
| 256 | _ = registry.remember(0x1000, 0x3001); | |
| 257 | try std.testing.expect(registry.byWindow(0x1000).?.geometry == null); | |
| 258 | } | |
| 259 | ||
| 260 | test "minimize and fullscreen transitions do not leak across windows" { | |
| 261 | var registry: Registry = .{}; | |
| 262 | _ = registry.remember(0x1000, 0x1001); | |
| 263 | _ = registry.remember(0x2000, 0x2001); | |
| 264 | const minimized = registry.observeState(0x1000, true, false).?; | |
| 265 | try std.testing.expectEqual(@as(?bool, true), minimized.minimized); | |
| 266 | try std.testing.expect(minimized.fullscreen == null); | |
| 267 | const same = registry.observeState(0x1000, true, false).?; | |
| 268 | try std.testing.expect(same.minimized == null and same.fullscreen == null); | |
| 269 | const restored = registry.observeState(0x1000, false, true).?; | |
| 270 | try std.testing.expectEqual(@as(?bool, false), restored.minimized); | |
| 271 | try std.testing.expectEqual(@as(?bool, true), restored.fullscreen); | |
| 272 | try std.testing.expect(!registry.byWindow(0x2000).?.minimized); | |
| 273 | try std.testing.expect(!registry.byWindow(0x2000).?.fullscreen); | |
| 274 | } | |
| 275 | ||
| 276 | test "a window keeps its own platform control object" { | |
| 277 | var registry: Registry = .{}; | |
| 278 | const first = registry.rememberWithContext(0x1000, 0x1001, 0x1002).?; | |
| 279 | const second = registry.rememberWithContext(0x2000, 0x2001, 0x2002).?; | |
| 280 | try std.testing.expectEqual(@as(usize, 0x1002), registry.byId(first).?.context); | |
| 281 | try std.testing.expectEqual(@as(usize, 0x2002), registry.byId(second).?.context); | |
| 282 | _ = registry.forgetWindow(0x1000); | |
| 283 | try std.testing.expectEqual(@as(usize, 0x2002), registry.byWindow(0x2000).?.context); | |
| 284 | } | |
| 285 | ||
| 286 | test "a stale event subscription id cannot select a reopened window" { | |
| 287 | var registry: Registry = .{}; | |
| 288 | const old_id = registry.remember(0x1000, 0x1001).?; | |
| 289 | _ = registry.forgetWindow(0x1000); | |
| 290 | const reopened_id = registry.remember(0x1000, 0x2001).?; | |
| 291 | try std.testing.expect(old_id != reopened_id); | |
| 292 | try std.testing.expect(registry.byId(old_id) == null); | |
| 293 | try std.testing.expectEqual(@as(usize, 0x2001), registry.byId(reopened_id).?.webview); | |
| 294 | } | |
| @@ -8,8 +8,8 @@ | ||
| 8 | 8 | // 1. **Self-contained** — no imports, no transpiler, ES5-friendly |
| 9 | 9 | // (avoid arrow functions, const/let are fine in modern WebKit). |
| 10 | 10 | // 2. **Idempotent** — may run twice if the page reloads. |
| 11 | // 3. **Defensive** — `webkit.messageHandlers.craft` may not exist (e.g. | |
| 12 | // when served outside a Craft window). Don't throw at module load. | |
| 11 | // 3. **Defensive** — neither WebKit's `messageHandlers.craft` nor WebView2's | |
| 12 | // `chrome.webview` need exist (e.g. outside Craft). Don't throw at load. | |
| 13 | 13 | // |
| 14 | 14 | // The native side dispatches messages by `t` (type) and answers async ones |
| 15 | 15 | // via `window.__craftBridgeResult(action, payload, id)`, where `id` is the |
| @@ -171,7 +171,11 @@ | ||
| 171 | 171 | try { |
| 172 | 172 | const msg = { t: t, a: a, d: d || '' } |
| 173 | 173 | if (i) msg.i = i |
| 174 | window.webkit.messageHandlers.craft.postMessage(msg) | |
| 174 | const webkit = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft | |
| 175 | const webview2 = window.chrome && window.chrome.webview | |
| 176 | if (webkit && typeof webkit.postMessage === 'function') webkit.postMessage(msg) | |
| 177 | else if (webview2 && typeof webview2.postMessage === 'function') webview2.postMessage(msg) | |
| 178 | else throw new Error('native bridge unavailable') | |
| 175 | 179 | return true |
| 176 | 180 | } |
| 177 | 181 | catch (e) { |
| @@ -1,4 +1,14 @@ | ||
| 1 | 1 | const std = ; |
| 2 | const bridge_error = ; | |
| 3 | const desktop_bridge_envelope = ; | |
| 4 | const desktop_window_registry = ; | |
| 5 | const desktop_window_events = ; | |
| 6 | const desktop_window_reads = ; | |
| 7 | const json_utils = ; | |
| 8 | const request_context = ; | |
| 9 | const window_context = ; | |
| 10 | const window_registry = ; | |
| 11 | const window_reply_target = ; | |
| 2 | 12 | |
| 3 | 13 | // Linux implementation using GTK3 and WebKit2GTK 4.1. |
| 4 | 14 | // Requires: libgtk-3-dev, libwebkit2gtk-4.1-dev |
| @@ -6,10 +16,13 @@ const std = ; | ||
| 6 | 16 | // GTK and WebKit C bindings |
| 7 | 17 | pub extern "c" fn gtk_init(argc: ?*c_int, argv: ?*anyopaque) void; |
| 8 | 18 | pub extern "c" fn gtk_application_new(application_id: [*:0]const u8, flags: c_int) ?*anyopaque; |
| 19 | pub extern "c" fn g_application_register(app: *anyopaque, cancellable: ?*anyopaque, err: ?*?*anyopaque) c_int; | |
| 9 | 20 | pub extern "c" fn g_application_run(app: *anyopaque, argc: c_int, argv: [*c][*c]u8) c_int; |
| 21 | pub extern "c" fn g_object_unref(object: *anyopaque) void; | |
| 10 | 22 | pub extern "c" fn gtk_application_window_new(app: *anyopaque) *anyopaque; |
| 11 | 23 | pub extern "c" fn gtk_window_set_title(window: *anyopaque, title: [*:0]const u8) void; |
| 12 | 24 | pub extern "c" fn gtk_window_set_default_size(window: *anyopaque, width: c_int, height: c_int) void; |
| 25 | pub extern "c" fn gtk_window_resize(window: *anyopaque, width: c_int, height: c_int) void; | |
| 13 | 26 | pub extern "c" fn gtk_window_present(window: *anyopaque) void; |
| 14 | 27 | pub extern "c" fn gtk_window_close(window: *anyopaque) void; |
| 15 | 28 | pub extern "c" fn gtk_window_set_decorated(window: *anyopaque, decorated: c_int) void; |
| @@ -18,14 +31,21 @@ pub extern "c" fn gtk_window_fullscreen(window: *anyopaque) void; | ||
| 18 | 31 | pub extern "c" fn gtk_window_unfullscreen(window: *anyopaque) void; |
| 19 | 32 | pub extern "c" fn gtk_window_maximize(window: *anyopaque) void; |
| 20 | 33 | pub extern "c" fn gtk_window_unmaximize(window: *anyopaque) void; |
| 21 | pub extern "c" fn gtk_window_minimize(window: *anyopaque) void; | |
| 34 | pub extern "c" fn gtk_window_iconify(window: *anyopaque) void; | |
| 22 | 35 | pub extern "c" fn gtk_widget_hide(widget: *anyopaque) void; |
| 23 | 36 | pub extern "c" fn gtk_widget_show(widget: *anyopaque) void; |
| 24 | pub extern "c" fn gtk_window_set_position(window: *anyopaque, x: c_int, y: c_int) void; | |
| 37 | pub extern "c" fn gtk_window_move(window: *anyopaque, x: c_int, y: c_int) void; | |
| 38 | pub extern "c" fn gtk_window_get_position(window: *anyopaque, x: *c_int, y: *c_int) void; | |
| 39 | pub extern "c" fn gtk_window_get_size(window: *anyopaque, width: *c_int, height: *c_int) void; | |
| 40 | pub extern "c" fn gtk_window_get_title(window: *anyopaque) ?[*:0]const u8; | |
| 41 | pub extern "c" fn gtk_window_is_active(window: *anyopaque) c_int; | |
| 42 | pub extern "c" fn gtk_widget_get_window(widget: *anyopaque) ?*anyopaque; | |
| 43 | pub extern "c" fn gdk_window_get_state(window: *anyopaque) c_uint; | |
| 25 | 44 | |
| 26 | 45 | pub extern "c" fn webkit_web_view_new() *anyopaque; |
| 27 | 46 | pub extern "c" fn webkit_web_view_load_uri(webview: *anyopaque, uri: [*:0]const u8) void; |
| 28 | 47 | pub extern "c" fn webkit_web_view_load_html(webview: *anyopaque, html: [*:0]const u8, base_uri: [*:0]const u8) void; |
| 48 | pub extern "c" fn webkit_web_view_reload(webview: *anyopaque) void; | |
| 29 | 49 | pub extern "c" fn webkit_web_view_get_settings(webview: *anyopaque) *anyopaque; |
| 30 | 50 | pub extern "c" fn webkit_settings_set_enable_developer_extras(settings: *anyopaque, enabled: c_int) void; |
| 31 | 51 | pub extern "c" fn webkit_settings_set_enable_webgl(settings: *anyopaque, enabled: c_int) void; |
| @@ -76,6 +96,9 @@ pub extern "c" fn webkit_user_script_new( | ||
| 76 | 96 | ) *anyopaque; |
| 77 | 97 | pub extern "c" fn webkit_user_content_manager_add_script(manager: *anyopaque, script: *anyopaque) void; |
| 78 | 98 | pub extern "c" fn webkit_user_content_manager_remove_all_scripts(manager: *anyopaque) void; |
| 99 | pub extern "c" fn webkit_user_content_manager_register_script_message_handler(manager: *anyopaque, name: [*:0]const u8) c_int; | |
| 100 | pub extern "c" fn webkit_javascript_result_get_js_value(result: *anyopaque) *anyopaque; | |
| 101 | pub extern "c" fn jsc_value_to_json(value: *anyopaque, indent: c_uint) ?[*:0]u8; | |
| 79 | 102 | |
| 80 | 103 | pub extern "c" fn gtk_container_add(container: *anyopaque, widget: *anyopaque) void; |
| 81 | 104 | |
| @@ -198,55 +221,310 @@ pub const WindowEntry = struct { | ||
| 198 | 221 | webview: *anyopaque, |
| 199 | 222 | }; |
| 200 | 223 | |
| 201 | var window_registry: [32]?WindowEntry = std.mem.zeroes([32]?WindowEntry); | |
| 202 | var window_count: u32 = 0; | |
| 203 | var next_window_id: u32 = 1; | |
| 224 | var desktop_windows: desktop_window_registry.Registry = .{}; | |
| 204 | 225 | |
| 205 | 226 | pub fn getWindowById(id: u32) ?WindowEntry { |
| 206 | for (window_registry) |entry| { | |
| 207 | if (entry) |e| { | |
| 208 | if (e.id == id) return e; | |
| 209 | } | |
| 210 | } | |
| 211 | return null; | |
| 227 | const entry = desktop_windows.byId(id) orelse return null; | |
| 228 | return .{ .id = entry.id, .gtk_window = @ptrFromInt(entry.window), .webview = @ptrFromInt(entry.webview) }; | |
| 212 | 229 | } |
| 213 | 230 | |
| 214 | 231 | pub fn getWindowCount() u32 { |
| 215 | return window_count; | |
| 232 | return @intCast(desktop_windows.count()); | |
| 216 | 233 | } |
| 217 | 234 | |
| 218 | 235 | fn registerWindow(gtk_window: *anyopaque, webview: *anyopaque) ?u32 { |
| 219 | for (&window_registry) |*slot| { | |
| 220 | if (slot.* == null) { | |
| 221 | const id = next_window_id; | |
| 222 | next_window_id += 1; | |
| 223 | slot.* = WindowEntry{ | |
| 224 | .id = id, | |
| 225 | .gtk_window = gtk_window, | |
| 226 | .webview = webview, | |
| 227 | }; | |
| 228 | window_count += 1; | |
| 229 | return id; | |
| 236 | return desktop_windows.remember(@intFromPtr(gtk_window), @intFromPtr(webview)); | |
| 237 | } | |
| 238 | ||
| 239 | /// GTK emits destroy for titlebar closes as well as programmatic closes. | |
| 240 | /// Forget the handle at actual teardown, so neither path leaves a stale | |
| 241 | /// webview eligible for bridge replies. | |
| 242 | fn onWindowDestroyed(widget: *anyopaque, _: ?*anyopaque) callconv(.c) void { | |
| 243 | if (desktop_windows.byWindow(@intFromPtr(widget))) |entry| { | |
| 244 | deliverWindowEvent(entry, "close", ""); | |
| 245 | } | |
| 246 | const entry = desktop_windows.forgetWindow(@intFromPtr(widget)) orelse return; | |
| 247 | window_registry.forgetOwner(entry.webview); | |
| 248 | window_registry.forget(entry.window); | |
| 249 | } | |
| 250 | ||
| 251 | fn deliverToWebview(webview: usize, name: []const u8, detail_json: []const u8, window_name: ?[]const u8) void { | |
| 252 | const script = desktop_window_events.format(std.heap.c_allocator, name, detail_json, window_name) catch return; | |
| 253 | defer std.heap.c_allocator.free(script); | |
| 254 | const script_z = .dupeZ(std.heap.c_allocator, u8, script) catch return; | |
| 255 | defer std.heap.c_allocator.free(script_z); | |
| 256 | webkit_web_view_run_javascript(@ptrFromInt(webview), script_z, null, null, null); | |
| 257 | } | |
| 258 | ||
| 259 | fn deliverWindowEvent(entry: desktop_window_registry.Entry, name: []const u8, detail_json: []const u8) void { | |
| 260 | deliverToWebview(entry.webview, name, detail_json, null); | |
| 261 | const owner = window_registry.ownerWebViewOf(entry.window) orelse return; | |
| 262 | if (owner == entry.webview or desktop_windows.byWebview(owner) == null) return; | |
| 263 | const window_name = window_registry.nameOf(entry.window) orelse return; | |
| 264 | deliverToWebview(owner, name, detail_json, window_name); | |
| 265 | } | |
| 266 | ||
| 267 | fn onWindowFocusIn(widget: *anyopaque, _: *anyopaque, _: ?*anyopaque) callconv(.c) c_int { | |
| 268 | if (desktop_windows.byWindow(@intFromPtr(widget))) |entry| deliverWindowEvent(entry, "focus", ""); | |
| 269 | return 0; | |
| 270 | } | |
| 271 | ||
| 272 | fn onWindowFocusOut(widget: *anyopaque, _: *anyopaque, _: ?*anyopaque) callconv(.c) c_int { | |
| 273 | if (desktop_windows.byWindow(@intFromPtr(widget))) |entry| deliverWindowEvent(entry, "blur", ""); | |
| 274 | return 0; | |
| 275 | } | |
| 276 | ||
| 277 | fn onWindowConfigure(widget: *anyopaque, _: *anyopaque, _: ?*anyopaque) callconv(.c) c_int { | |
| 278 | const entry = desktop_windows.byWindow(@intFromPtr(widget)) orelse return 0; | |
| 279 | var x: c_int = 0; | |
| 280 | var y: c_int = 0; | |
| 281 | var width: c_int = 0; | |
| 282 | var height: c_int = 0; | |
| 283 | gtk_window_get_position(widget, &x, &y); | |
| 284 | gtk_window_get_size(widget, &width, &height); | |
| 285 | if (width < 0 or height < 0) return 0; | |
| 286 | const change = desktop_windows.observeGeometry(entry.window, .{ | |
| 287 | .x = x, | |
| 288 | .y = y, | |
| 289 | .width = @intCast(width), | |
| 290 | .height = @intCast(height), | |
| 291 | }) orelse return 0; | |
| 292 | var detail_buf: [96]u8 = undefined; | |
| 293 | if (change.moved) { | |
| 294 | const detail = std.fmt.bufPrint(&detail_buf, "", .{ x, y }) catch return 0; | |
| 295 | deliverWindowEvent(entry, "move", detail); | |
| 296 | } | |
| 297 | if (change.resized) { | |
| 298 | const detail = std.fmt.bufPrint(&detail_buf, "", .{ width, height }) catch return 0; | |
| 299 | deliverWindowEvent(entry, "resize", detail); | |
| 300 | } | |
| 301 | return 0; | |
| 302 | } | |
| 303 | ||
| 304 | fn onWindowState(widget: *anyopaque, _: *anyopaque, _: ?*anyopaque) callconv(.c) c_int { | |
| 305 | const entry = desktop_windows.byWindow(@intFromPtr(widget)) orelse return 0; | |
| 306 | const gdk_window = gtk_widget_get_window(widget) orelse return 0; | |
| 307 | const state = gdk_window_get_state(gdk_window); | |
| 308 | const change = desktop_windows.observeState(entry.window, (state & 2) != 0, (state & 16) != 0) orelse return 0; | |
| 309 | if (change.minimized) |minimized| deliverWindowEvent(entry, if (minimized) "minimize" else "restore", ""); | |
| 310 | if (change.fullscreen) |fullscreen| deliverWindowEvent(entry, if (fullscreen) "enter-fullscreen" else "leave-fullscreen", ""); | |
| 311 | return 0; | |
| 312 | } | |
| 313 | ||
| 314 | /// WebKit supplies the WebView via signal user data; a page-supplied window id | |
| 315 | /// is only a name to resolve after the native sender has been authenticated. | |
| 316 | fn onScriptMessage(_: *anyopaque, result: *anyopaque, user_data: ?*anyopaque) callconv(.c) void { | |
| 317 | const sender = user_data orelse return; | |
| 318 | const entry = desktop_windows.byWebview(@intFromPtr(sender)) orelse return; | |
| 319 | const value = webkit_javascript_result_get_js_value(result); | |
| 320 | const json_z = jsc_value_to_json(value, 0) orelse return; | |
| 321 | defer g_free(@ptrCast(json_z)); | |
| 322 | ||
| 323 | var envelope = desktop_bridge_envelope.parse(std.heap.c_allocator, std.mem.span(json_z)) catch return; | |
| 324 | defer envelope.deinit(); | |
| 325 | window_context.push(entry.window, entry.webview); | |
| 326 | defer window_context.pop(); | |
| 327 | request_context.push(envelope.request_id); | |
| 328 | defer request_context.pop(); | |
| 329 | ||
| 330 | if (!std.mem.eql(u8, envelope.kind, "window")) { | |
| 331 | bridge_error.sendErrorToJS(std.heap.c_allocator, envelope.action, error.PlatformNotSupported); | |
| 332 | return; | |
| 333 | } | |
| 334 | handleWindowAction(envelope.action, envelope.data) catch |err| { | |
| 335 | bridge_error.sendErrorToJS(std.heap.c_allocator, envelope.action, bridge_error.fromHandlerError(err)); | |
| 336 | }; | |
| 337 | } | |
| 338 | ||
| 339 | fn namedWindowResult(allocator: std.mem.Allocator, name: []const u8) ![]u8 { | |
| 340 | var json: std.ArrayListUnmanaged(u8) = .empty; | |
| 341 | errdefer json.deinit(allocator); | |
| 342 | try json.appendSlice(allocator, "{\"name\":\""); | |
| 343 | try bridge_error.appendJsonEscaped(allocator, &json, name); | |
| 344 | try json.appendSlice(allocator, "\"}"); | |
| 345 | return json.toOwnedSlice(allocator); | |
| 346 | } | |
| 347 | ||
| 348 | fn openNamedWindow(action: []const u8, data: ?[]const u8) !void { | |
| 349 | const allocator = std.heap.c_allocator; | |
| 350 | const json = data orelse return error.MissingData; | |
| 351 | const explicit_name = try json_utils.getStringDecoded(allocator, json, "name"); | |
| 352 | defer if (explicit_name) |name| allocator.free(name); | |
| 353 | const fallback_id = if (explicit_name == null) | |
| 354 | try json_utils.getStringDecoded(allocator, json, "id") | |
| 355 | else | |
| 356 | null; | |
| 357 | defer if (fallback_id) |name| allocator.free(name); | |
| 358 | const name = explicit_name orelse fallback_id orelse return error.InvalidParameter; | |
| 359 | if (name.len == 0 or name.len > window_registry.max_name or | |
| 360 | std.mem.eql(u8, name, "main") or std.mem.indexOfScalar(u8, name, 0) != null) | |
| 361 | return error.InvalidParameter; | |
| 362 | ||
| 363 | const url = try json_utils.getStringDecoded(allocator, json, "url"); | |
| 364 | defer if (url) |text| allocator.free(text); | |
| 365 | const html = try json_utils.getStringDecoded(allocator, json, "html"); | |
| 366 | defer if (html) |text| allocator.free(text); | |
| 367 | if (url == null and html == null) return error.InvalidParameter; | |
| 368 | const title = try json_utils.getStringDecoded(allocator, json, "title"); | |
| 369 | defer if (title) |text| allocator.free(text); | |
| 370 | for ([_]?[]const u8{ url, html, title }) |value| { | |
| 371 | if (value) |text| if (std.mem.indexOfScalar(u8, text, 0) != null) | |
| 372 | return error.InvalidParameter; | |
| 373 | } | |
| 374 | ||
| 375 | // Allocate the response before creating native state. A failed allocation | |
| 376 | // must not leave a live window behind a rejected creation Promise. | |
| 377 | const result = try namedWindowResult(allocator, name); | |
| 378 | defer allocator.free(result); | |
| 379 | const owner = window_context.currentWebView() orelse return error.WebViewHandleNotSet; | |
| 380 | if (window_registry.byName(name)) |existing| { | |
| 381 | if (desktop_windows.byWindow(existing) == null) return error.WindowHandleNotSet; | |
| 382 | if (!window_registry.rememberNamedOwned(existing, name, owner)) return error.InvalidParameter; | |
| 383 | gtk_window_present(@ptrFromInt(existing)); | |
| 384 | bridge_error.sendResultToJS(allocator, action, result); | |
| 385 | return; | |
| 386 | } | |
| 387 | ||
| 388 | const width = json_utils.getInt(u32, json, "width") orelse 800; | |
| 389 | const height = json_utils.getInt(u32, json, "height") orelse 600; | |
| 390 | if (width == 0 or height == 0 or width > @as(u32, std.math.maxInt(c_int)) or height > @as(u32, std.math.maxInt(c_int))) | |
| 391 | return error.InvalidParameter; | |
| 392 | var created = try Window.create(.{ | |
| 393 | .title = title orelse name, | |
| 394 | .width = width, | |
| 395 | .height = height, | |
| 396 | .x = json_utils.getInt(i32, json, "x"), | |
| 397 | .y = json_utils.getInt(i32, json, "y"), | |
| 398 | .resizable = json_utils.getBool(json, "resizable") orelse true, | |
| 399 | .frameless = json_utils.getBool(json, "frameless") orelse false, | |
| 400 | .fullscreen = json_utils.getBool(json, "fullscreen") orelse false, | |
| 401 | .dev_tools = json_utils.getBool(json, "devTools") orelse false, | |
| 402 | }); | |
| 403 | errdefer created.close(); | |
| 404 | if (url) |text| try created.loadURL(text) else if (html) |text| try created.loadHTML(text); | |
| 405 | if (!window_registry.rememberNamedOwned(@intFromPtr(created.gtk_window), name, owner)) | |
| 406 | return error.TooManyWindows; | |
| 407 | created.show(); | |
| 408 | bridge_error.sendResultToJS(allocator, action, result); | |
| 409 | } | |
| 410 | ||
| 411 | fn targetWindow(data: ?[]const u8) !desktop_window_registry.Entry { | |
| 412 | if (data) |json| { | |
| 413 | const name = try json_utils.getStringDecoded(std.heap.c_allocator, json, "windowId"); | |
| 414 | defer if (name) |text| std.heap.c_allocator.free(text); | |
| 415 | if (name) |text| { | |
| 416 | if (!std.mem.eql(u8, text, "main")) { | |
| 417 | const handle = window_registry.byName(text) orelse return error.NotFound; | |
| 418 | return desktop_windows.byWindow(handle) orelse error.NotFound; | |
| 419 | } | |
| 230 | 420 | } |
| 231 | 421 | } |
| 232 | return null; // registry full | |
| 422 | return desktop_windows.byWindow(window_context.current() orelse return error.WindowHandleNotSet) orelse error.WindowHandleNotSet; | |
| 233 | 423 | } |
| 234 | 424 | |
| 235 | fn unregisterWindow(id: u32) void { | |
| 236 | for (&window_registry) |*slot| { | |
| 237 | if (slot.*) |e| { | |
| 238 | if (e.id == id) { | |
| 239 | slot.* = null; | |
| 240 | window_count -= 1; | |
| 241 | break; | |
| 425 | fn windowGeometry(window: *anyopaque) !desktop_window_registry.Geometry { | |
| 426 | var x: c_int = 0; | |
| 427 | var y: c_int = 0; | |
| 428 | var width: c_int = 0; | |
| 429 | var height: c_int = 0; | |
| 430 | gtk_window_get_position(window, &x, &y); | |
| 431 | gtk_window_get_size(window, &width, &height); | |
| 432 | if (width < 0 or height < 0) return error.NativeCallFailed; | |
| 433 | return .{ .x = x, .y = y, .width = @intCast(width), .height = @intCast(height) }; | |
| 434 | } | |
| 435 | ||
| 436 | fn sendWindowRead(action: []const u8, data: ?[]const u8) !void { | |
| 437 | const allocator = std.heap.c_allocator; | |
| 438 | const entry = try targetWindow(data); | |
| 439 | const window: *anyopaque = @ptrFromInt(entry.window); | |
| 440 | const json = if (std.mem.eql(u8, action, "getTitle")) blk: { | |
| 441 | const title = gtk_window_get_title(window); | |
| 442 | break :blk try desktop_window_reads.string(allocator, if (title) |text| std.mem.span(text) else ""); | |
| 443 | } else if (std.mem.eql(u8, action, "getFocused")) blk: { | |
| 444 | var focused: usize = 0; | |
| 445 | for (desktop_windows.entries) |slot| { | |
| 446 | if (slot) |candidate| { | |
| 447 | if (gtk_window_is_active(@ptrFromInt(candidate.window)) != 0) { | |
| 448 | focused = candidate.window; | |
| 449 | break; | |
| 450 | } | |
| 242 | 451 | } |
| 243 | 452 | } |
| 453 | const name = desktop_window_reads.focusedName(&desktop_windows, entry.window, focused, window_registry.nameOf(focused)); | |
| 454 | break :blk try desktop_window_reads.string(allocator, name); | |
| 455 | } else try desktop_window_reads.geometry(allocator, action, try windowGeometry(window)); | |
| 456 | defer allocator.free(json); | |
| 457 | bridge_error.sendResultToJS(allocator, action, json); | |
| 458 | } | |
| 459 | ||
| 460 | fn handleWindowAction(action: []const u8, data: ?[]const u8) !void { | |
| 461 | if (std.mem.eql(u8, action, "open") or std.mem.eql(u8, action, "create")) | |
| 462 | return openNamedWindow(action, data); | |
| 463 | if (std.mem.eql(u8, action, "getTitle") or std.mem.eql(u8, action, "getSize") or | |
| 464 | std.mem.eql(u8, action, "getPosition") or std.mem.eql(u8, action, "getBounds") or | |
| 465 | std.mem.eql(u8, action, "getFocused")) | |
| 466 | return sendWindowRead(action, data); | |
| 467 | ||
| 468 | const entry = try targetWindow(data); | |
| 469 | const window: *anyopaque = @ptrFromInt(entry.window); | |
| 470 | const webview: *anyopaque = @ptrFromInt(entry.webview); | |
| 471 | if (std.mem.eql(u8, action, "show") or std.mem.eql(u8, action, "focus")) { | |
| 472 | gtk_window_present(window); | |
| 473 | } else if (std.mem.eql(u8, action, "hide")) { | |
| 474 | gtk_widget_hide(window); | |
| 475 | } else if (std.mem.eql(u8, action, "close") or std.mem.eql(u8, action, "destroy")) { | |
| 476 | gtk_window_close(window); | |
| 477 | } else if (std.mem.eql(u8, action, "minimize")) { | |
| 478 | gtk_window_iconify(window); | |
| 479 | } else if (std.mem.eql(u8, action, "maximize")) { | |
| 480 | gtk_window_maximize(window); | |
| 481 | } else if (std.mem.eql(u8, action, "unmaximize") or std.mem.eql(u8, action, "restore")) { | |
| 482 | gtk_window_unmaximize(window); | |
| 483 | } else if (std.mem.eql(u8, action, "reload")) { | |
| 484 | webkit_web_view_reload(webview); | |
| 485 | } else if (std.mem.eql(u8, action, "setTitle")) { | |
| 486 | const json = data orelse return error.MissingData; | |
| 487 | const title = (try json_utils.getStringDecoded(std.heap.c_allocator, json, "title")) orelse return error.InvalidParameter; | |
| 488 | defer std.heap.c_allocator.free(title); | |
| 489 | if (std.mem.indexOfScalar(u8, title, 0) != null) return error.InvalidParameter; | |
| 490 | const title_z = try .dupeZ(std.heap.c_allocator, u8, title); | |
| 491 | defer std.heap.c_allocator.free(title_z); | |
| 492 | gtk_window_set_title(window, title_z); | |
| 493 | } else if (std.mem.eql(u8, action, "setSize")) { | |
| 494 | const json = data orelse return error.MissingData; | |
| 495 | const width = json_utils.getInt(u32, json, "width") orelse return error.InvalidParameter; | |
| 496 | const height = json_utils.getInt(u32, json, "height") orelse return error.InvalidParameter; | |
| 497 | if (width == 0 or height == 0 or width > @as(u32, std.math.maxInt(c_int)) or height > @as(u32, std.math.maxInt(c_int))) | |
| 498 | return error.InvalidParameter; | |
| 499 | gtk_window_resize(window, @intCast(width), @intCast(height)); | |
| 500 | } else if (std.mem.eql(u8, action, "setPosition")) { | |
| 501 | const json = data orelse return error.MissingData; | |
| 502 | const x = json_utils.getInt(i32, json, "x") orelse return error.InvalidParameter; | |
| 503 | const y = json_utils.getInt(i32, json, "y") orelse return error.InvalidParameter; | |
| 504 | gtk_window_move(window, x, y); | |
| 505 | } else if (std.mem.eql(u8, action, "loadURL") or std.mem.eql(u8, action, "loadHTML")) { | |
| 506 | const json = data orelse return error.MissingData; | |
| 507 | const key: []const u8 = if (std.mem.eql(u8, action, "loadURL")) "url" else "html"; | |
| 508 | const value = (try json_utils.getStringDecoded(std.heap.c_allocator, json, key)) orelse return error.InvalidParameter; | |
| 509 | defer std.heap.c_allocator.free(value); | |
| 510 | if (std.mem.indexOfScalar(u8, value, 0) != null) return error.InvalidParameter; | |
| 511 | const value_z = try .dupeZ(std.heap.c_allocator, u8, value); | |
| 512 | defer std.heap.c_allocator.free(value_z); | |
| 513 | if (std.mem.eql(u8, action, "loadURL")) webkit_web_view_load_uri(webview, value_z) else webkit_web_view_load_html(webview, value_z, ""); | |
| 514 | } else { | |
| 515 | return error.PlatformNotSupported; | |
| 244 | 516 | } |
| 245 | 517 | } |
| 246 | 518 | |
| 247 | 519 | // Application state |
| 248 | 520 | var app_instance: ?*anyopaque = null; |
| 249 | var current_window: ?*anyopaque = null; | |
| 521 | ||
| 522 | fn onApplicationActivated(_: *anyopaque, _: ?*anyopaque) callconv(.c) void { | |
| 523 | // The CLI creates and shows its first window before g_application_run(). | |
| 524 | // Activation presents that already-owned window instead of constructing | |
| 525 | // another one. | |
| 526 | if (desktop_windows.latest()) |entry| gtk_window_present(@ptrFromInt(entry.window)); | |
| 527 | } | |
| 250 | 528 | |
| 251 | 529 | pub const WindowStyle = struct { |
| 252 | 530 | frameless: bool = false, |
| @@ -283,14 +561,32 @@ pub const Window = struct { | ||
| 283 | 561 | // Initialize GTK if not already done |
| 284 | 562 | if (app_instance == null) { |
| 285 | 563 | gtk_init(null, null); |
| 286 | app_instance = gtk_application_new("com.craft.app", 0); | |
| 564 | const app = gtk_application_new("com.craft.app", 0) orelse return error.ApplicationCreationFailed; | |
| 565 | if (g_signal_connect_data(app, "activate", @ptrCast(&onApplicationActivated), null, null, 0) == 0) { | |
| 566 | g_object_unref(app); | |
| 567 | return error.SignalConnectionFailed; | |
| 568 | } | |
| 569 | // GtkApplicationWindow must not be added before the application's | |
| 570 | // startup signal. Register now because Craft creates its first | |
| 571 | // window before entering g_application_run(). | |
| 572 | if (g_application_register(app, null, null) == 0) { | |
| 573 | g_object_unref(app); | |
| 574 | return error.ApplicationRegistrationFailed; | |
| 575 | } | |
| 576 | app_instance = app; | |
| 287 | 577 | } |
| 288 | 578 | |
| 289 | 579 | const window = gtk_application_window_new(app_instance.?); |
| 290 | current_window = window; | |
| 580 | errdefer gtk_window_close(window); | |
| 291 | 581 | |
| 292 | 582 | // Create WebView |
| 293 | 583 | const webview = webkit_web_view_new(); |
| 584 | const manager = webkit_web_view_get_user_content_manager(webview); | |
| 585 | // Connect before registration: WebKit can emit a page message as soon | |
| 586 | // as the injected document-start API becomes available. | |
| 587 | if (g_signal_connect_data(manager, "script-message-received::craft", @ptrCast(&onScriptMessage), webview, null, 0) == 0 or | |
| 588 | webkit_user_content_manager_register_script_message_handler(manager, "craft") == 0) | |
| 589 | return error.SignalConnectionFailed; | |
| 294 | 590 | |
| 295 | 591 | // Configure WebView settings |
| 296 | 592 | const settings = webkit_web_view_get_settings(webview); |
| @@ -353,16 +649,28 @@ pub const Window = struct { | ||
| 353 | 649 | const x: i32 = options.x orelse 100; |
| 354 | 650 | const y: i32 = options.y orelse 100; |
| 355 | 651 | if (options.x != null and options.y != null) { |
| 356 | gtk_window_set_position(window, @intCast(x), @intCast(y)); | |
| 652 | gtk_window_move(window, @intCast(x), @intCast(y)); | |
| 357 | 653 | } |
| 358 | 654 | |
| 359 | 655 | // Add WebView to window |
| 360 | 656 | gtk_container_add(window, webview); |
| 361 | 657 | |
| 658 | if (g_signal_connect_data(window, "focus-in-event", @ptrCast(&onWindowFocusIn), null, null, 0) == 0 or | |
| 659 | g_signal_connect_data(window, "focus-out-event", @ptrCast(&onWindowFocusOut), null, null, 0) == 0 or | |
| 660 | g_signal_connect_data(window, "configure-event", @ptrCast(&onWindowConfigure), null, null, 0) == 0 or | |
| 661 | g_signal_connect_data(window, "window-state-event", @ptrCast(&onWindowState), null, null, 0) == 0) | |
| 662 | return error.SignalConnectionFailed; | |
| 663 | ||
| 362 | 664 | // Register window in the multi-window registry |
| 363 | const window_id = registerWindow(window, webview) orelse return error.TooManyWindows; | |
| 665 | const window_id = registerWindow(window, webview) orelse { | |
| 666 | return error.TooManyWindows; | |
| 667 | }; | |
| 668 | if (g_signal_connect_data(window, "destroy", @ptrCast(&onWindowDestroyed), null, null, 0) == 0) { | |
| 669 | _ = desktop_windows.forgetWindow(@intFromPtr(window)); | |
| 670 | return error.SignalConnectionFailed; | |
| 671 | } | |
| 364 | 672 | |
| 365 | return Window{ | |
| 673 | var created = Window{ | |
| 366 | 674 | .id = window_id, |
| 367 | 675 | .gtk_window = window, |
| 368 | 676 | .webview = webview, |
| @@ -372,6 +680,10 @@ pub const Window = struct { | ||
| 372 | 680 | .x = x, |
| 373 | 681 | .y = y, |
| 374 | 682 | }; |
| 683 | // Register the page API before any load. The user-content manager | |
| 684 | // reapplies this document-start script after navigation too. | |
| 685 | try created.injectScript(@embedFile("js/craft-bridge.js")); | |
| 686 | return created; | |
| 375 | 687 | } |
| 376 | 688 | |
| 377 | 689 | pub fn show(self: *Window) void { |
| @@ -384,33 +696,19 @@ pub const Window = struct { | ||
| 384 | 696 | } |
| 385 | 697 | |
| 386 | 698 | pub fn close(self: *Window) void { |
| 387 | // Remove from window registry | |
| 388 | unregisterWindow(self.id); | |
| 389 | ||
| 390 | // Update current_window if this was the active one | |
| 391 | if (current_window == self.gtk_window) { | |
| 392 | current_window = null; | |
| 393 | // Set current_window to the most recently registered window, if any | |
| 394 | var latest_id: u32 = 0; | |
| 395 | for (window_registry) |entry| { | |
| 396 | if (entry) |e| { | |
| 397 | if (e.id > latest_id) { | |
| 398 | latest_id = e.id; | |
| 399 | current_window = e.gtk_window; | |
| 400 | } | |
| 401 | } | |
| 402 | } | |
| 403 | } | |
| 404 | ||
| 699 | // The destroy signal unregisters only after GTK actually closes it. | |
| 700 | const live = desktop_windows.byId(self.id) orelse return; | |
| 701 | if (live.window != @intFromPtr(self.gtk_window)) return; | |
| 405 | 702 | gtk_window_close(self.gtk_window); |
| 406 | 703 | } |
| 407 | 704 | |
| 408 | 705 | pub fn setSize(self: *Window, width: u32, height: u32) void { |
| 409 | gtk_window_set_default_size(self.gtk_window, @intCast(width), @intCast(height)); | |
| 706 | if (width == 0 or height == 0 or width > @as(u32, std.math.maxInt(c_int)) or height > @as(u32, std.math.maxInt(c_int))) return; | |
| 707 | gtk_window_resize(self.gtk_window, @intCast(width), @intCast(height)); | |
| 410 | 708 | } |
| 411 | 709 | |
| 412 | 710 | pub fn setPosition(self: *Window, x: i32, y: i32) void { |
| 413 | gtk_window_set_position(self.gtk_window, @intCast(x), @intCast(y)); | |
| 711 | gtk_window_move(self.gtk_window, x, y); | |
| 414 | 712 | } |
| 415 | 713 | |
| 416 | 714 | pub fn setTitle(self: *Window, title: []const u8) void { |
| @@ -436,7 +734,7 @@ pub const Window = struct { | ||
| 436 | 734 | } |
| 437 | 735 | |
| 438 | 736 | pub fn minimize(self: *Window) void { |
| 439 | gtk_window_minimize(self.gtk_window); | |
| 737 | gtk_window_iconify(self.gtk_window); | |
| 440 | 738 | } |
| 441 | 739 | |
| 442 | 740 | pub fn setFullscreen(self: *Window, fullscreen: bool) void { |
| @@ -454,6 +752,7 @@ pub const Window = struct { | ||
| 454 | 752 | } |
| 455 | 753 | |
| 456 | 754 | pub fn injectScript(self: *Window, script: []const u8) !void { |
| 755 | if (std.mem.indexOfScalar(u8, script, 0) != null) return error.InvalidScript; | |
| 457 | 756 | const script_z = try .dupeZ(std.heap.c_allocator, u8, script); |
| 458 | 757 | defer std.heap.c_allocator.free(script_z); |
| 459 | 758 | |
| @@ -480,7 +779,7 @@ pub const Window = struct { | ||
| 480 | 779 | pub const App = struct { |
| 481 | 780 | pub fn run() !void { |
| 482 | 781 | if (app_instance) |app| { |
| 483 | _ = g_application_run(app, 0, undefined); | |
| 782 | _ = g_application_run(app, 0, null); | |
| 484 | 783 | } |
| 485 | 784 | } |
| 486 | 785 | |
| @@ -489,28 +788,17 @@ pub const App = struct { | ||
| 489 | 788 | } |
| 490 | 789 | }; |
| 491 | 790 | |
| 492 | /// Evaluate JavaScript in the current webview (cross-platform bridge helper). | |
| 493 | /// Uses the most recently registered window's webview from the window registry. | |
| 791 | /// Evaluate JavaScript in the page that sent the current bridge message. | |
| 792 | /// Native callers without a sender continue to use the latest live window. | |
| 494 | 793 | pub fn evalJS(script: []const u8) !void { |
| 495 | // Find the most recently registered window's webview | |
| 496 | var latest_id: u32 = 0; | |
| 497 | var latest_webview: ?*anyopaque = null; | |
| 498 | for (window_registry) |entry| { | |
| 499 | if (entry) |e| { | |
| 500 | if (e.id > latest_id) { | |
| 501 | latest_id = e.id; | |
| 502 | latest_webview = e.webview; | |
| 503 | } | |
| 504 | } | |
| 505 | } | |
| 506 | ||
| 507 | if (latest_webview) |webview| { | |
| 508 | const script_z = .dupeZ(std.heap.c_allocator, u8, script) catch return error.OutOfMemory; | |
| 509 | defer std.heap.c_allocator.free(script_z); | |
| 510 | webkit_web_view_run_javascript(webview, script_z, null, null, null); | |
| 511 | } else { | |
| 794 | var handles: [desktop_window_registry.capacity]usize = undefined; | |
| 795 | const live = desktop_windows.liveWebviews(&handles); | |
| 796 | const latest = desktop_windows.latest(); | |
| 797 | const target = window_reply_target.select(live, window_context.currentWebView(), if (latest) |entry| entry.webview else null) orelse | |
| 512 | 798 | return error.NoWebView; |
| 513 | } | |
| 799 | const script_z = try .dupeZ(std.heap.c_allocator, u8, script); | |
| 800 | defer std.heap.c_allocator.free(script_z); | |
| 801 | webkit_web_view_run_javascript(@ptrFromInt(target), script_z, null, null, null); | |
| 514 | 802 | } |
| 515 | 803 | |
| 516 | 804 | // Legacy API compatibility |
| @@ -526,6 +814,14 @@ pub fn createWindow(title: []const u8, width: u32, height: u32, html: []const u8 | ||
| 526 | 814 | } |
| 527 | 815 | |
| 528 | 816 | pub fn createWindowWithURL(title: []const u8, width: u32, height: u32, url: []const u8, style: WindowStyle) !*anyopaque { |
| 817 | return createStyledWindow(title, width, height, url, style, true); | |
| 818 | } | |
| 819 | ||
| 820 | pub fn createWindowWithHTML(title: []const u8, width: u32, height: u32, html: []const u8, style: WindowStyle) !*anyopaque { | |
| 821 | return createStyledWindow(title, width, height, html, style, false); | |
| 822 | } | |
| 823 | ||
| 824 | fn createStyledWindow(title: []const u8, width: u32, height: u32, content: []const u8, style: WindowStyle, comptime is_url: bool) !*anyopaque { | |
| 529 | 825 | var window = try Window.create(.{ |
| 530 | 826 | .title = title, |
| 531 | 827 | .width = width, |
| @@ -544,7 +840,8 @@ pub fn createWindowWithURL(title: []const u8, width: u32, height: u32, url: []co | ||
| 544 | 840 | .allow_notifications = style.allow_notifications, |
| 545 | 841 | .allow_clipboard = style.allow_clipboard, |
| 546 | 842 | }); |
| 547 | try window.loadURL(url); | |
| 843 | errdefer window.close(); | |
| 844 | if (is_url) try window.loadURL(content) else try window.loadHTML(content); | |
| 548 | 845 | window.show(); |
| 549 | 846 | return window.gtk_window; |
| 550 | 847 | } |
| @@ -170,6 +170,9 @@ pub const Window = struct { | ||
| 170 | 170 | } |
| 171 | 171 | |
| 172 | 172 | pub fn show(self: *Self) !void { |
| 173 | // Styled URL/HTML constructors already created and showed their native | |
| 174 | // window. Creating it again here would duplicate the first page. | |
| 175 | if (self.native_handle != null) return; | |
| 173 | 176 | switch (builtin.os.tag) { |
| 174 | 177 | .macos => try self.showMacOS(), |
| 175 | 178 | .linux => try self.showLinux(), |
| @@ -314,7 +317,7 @@ pub const App = struct { | ||
| 314 | 317 | try self.windows.append(self.allocator, window); |
| 315 | 318 | return window; |
| 316 | 319 | } else { |
| 317 | return error.UnsupportedPlatform; | |
| 320 | return self.createPortableWindow(title, width, height, html, style, false); | |
| 318 | 321 | } |
| 319 | 322 | } |
| 320 | 323 | |
| @@ -328,8 +331,60 @@ pub const App = struct { | ||
| 328 | 331 | try self.windows.append(self.allocator, window); |
| 329 | 332 | return window; |
| 330 | 333 | } else { |
| 331 | return error.UnsupportedPlatform; | |
| 334 | return self.createPortableWindow(title, width, height, url, style, true); | |
| 335 | } | |
| 336 | } | |
| 337 | ||
| 338 | fn createPortableWindow(self: *Self, title: []const u8, width: u32, height: u32, content: []const u8, style: WindowStyle, comptime is_url: bool) !*Window { | |
| 339 | if (builtin.os.tag == .linux) { | |
| 340 | const linux = ; | |
| 341 | const native_style: linux.WindowStyle = .{ | |
| 342 | .frameless = style.frameless, | |
| 343 | .transparent = style.transparent, | |
| 344 | .always_on_top = style.always_on_top, | |
| 345 | .resizable = style.resizable, | |
| 346 | .fullscreen = style.fullscreen, | |
| 347 | .x = style.x, | |
| 348 | .y = style.y, | |
| 349 | .dark_mode = style.dark_mode, | |
| 350 | .dev_tools = style.dev_tools, | |
| 351 | }; | |
| 352 | const native = if (is_url) | |
| 353 | try linux.createWindowWithURL(title, width, height, content, native_style) | |
| 354 | else | |
| 355 | try linux.createWindowWithHTML(title, width, height, content, native_style); | |
| 356 | errdefer linux.gtk_window_close(native); | |
| 357 | return self.rememberPortableWindow(title, width, height, if (is_url) "" else content, native); | |
| 358 | } else if (builtin.os.tag == .windows) { | |
| 359 | const windows = ; | |
| 360 | const native_style: windows.WindowStyle = .{ | |
| 361 | .frameless = style.frameless, | |
| 362 | .transparent = style.transparent, | |
| 363 | .always_on_top = style.always_on_top, | |
| 364 | .resizable = style.resizable, | |
| 365 | .fullscreen = style.fullscreen, | |
| 366 | .x = style.x, | |
| 367 | .y = style.y, | |
| 368 | .dark_mode = style.dark_mode, | |
| 369 | .dev_tools = style.dev_tools, | |
| 370 | }; | |
| 371 | const native = if (is_url) | |
| 372 | try windows.createWindowWithURL(title, width, height, content, native_style) | |
| 373 | else | |
| 374 | try windows.createWindowWithHTML(title, width, height, content, native_style); | |
| 375 | errdefer _ = windows.DestroyWindow(native); | |
| 376 | return self.rememberPortableWindow(title, width, height, if (is_url) "" else content, native); | |
| 332 | 377 | } |
| 378 | return error.UnsupportedPlatform; | |
| 379 | } | |
| 380 | ||
| 381 | fn rememberPortableWindow(self: *Self, title: []const u8, width: u32, height: u32, html: []const u8, native: *anyopaque) !*Window { | |
| 382 | const window = try self.allocator.create(Window); | |
| 383 | errdefer self.allocator.destroy(window); | |
| 384 | window.* = Window.init(title, width, height, html); | |
| 385 | window.native_handle = native; | |
| 386 | try self.windows.append(self.allocator, window); | |
| 387 | return window; | |
| 333 | 388 | } |
| 334 | 389 | |
| 335 | 390 | /// Create a window with a native macOS sidebar (Finder-style with vibrancy) - HTML mode |
| @@ -0,0 +1,35 @@ | ||
| 1 | //! Select a live desktop webview for a bridge reply. | |
| 2 | //! | |
| 3 | //! A reply made while serving a page belongs to that page. If its webview was | |
| 4 | //! destroyed during a nested run loop, sending the reply to the latest window | |
| 5 | //! would settle somebody else's call instead. Only native callers without a | |
| 6 | //! sender context may use the platform's fallback webview. | |
| 7 | ||
| 8 | const std = ; | |
| 9 | ||
| 10 | pub fn select(live_webviews: []const usize, sender: ?usize, fallback: ?usize) ?usize { | |
| 11 | const requested = sender orelse fallback orelse return null; | |
| 12 | if (requested == 0) return null; | |
| 13 | for (live_webviews) |webview| { | |
| 14 | if (webview == requested) return webview; | |
| 15 | } | |
| 16 | return null; | |
| 17 | } | |
| 18 | ||
| 19 | test "a page reply targets its own live webview" { | |
| 20 | const live = [_]usize{ 0x1000, 0x2000 }; | |
| 21 | try std.testing.expectEqual(@as(?usize, 0x1000), select(&live, 0x1000, 0x2000)); | |
| 22 | } | |
| 23 | ||
| 24 | test "a destroyed sender never falls through to another page" { | |
| 25 | const live = [_]usize{0x2000}; | |
| 26 | try std.testing.expectEqual(@as(?usize, null), select(&live, 0x1000, 0x2000)); | |
| 27 | } | |
| 28 | ||
| 29 | test "a native caller uses only a live fallback" { | |
| 30 | const live = [_]usize{ 0x1000, 0x2000 }; | |
| 31 | try std.testing.expectEqual(@as(?usize, 0x2000), select(&live, null, 0x2000)); | |
| 32 | try std.testing.expectEqual(@as(?usize, null), select(&live, null, 0x3000)); | |
| 33 | try std.testing.expectEqual(@as(?usize, null), select(&live, null, null)); | |
| 34 | try std.testing.expectEqual(@as(?usize, null), select(&live, null, 0)); | |
| 35 | } | |
packages/zig/src/windows.zigmodified+642-51
| @@ -1,4 +1,16 @@ | ||
| 1 | 1 | const std = ; |
| 2 | const bridge_error = ; | |
| 3 | const desktop_bridge_envelope = ; | |
| 4 | const desktop_bridge_text = ; | |
| 5 | const desktop_script_encoding = ; | |
| 6 | const desktop_window_events = ; | |
| 7 | const desktop_window_reads = ; | |
| 8 | const desktop_window_registry = ; | |
| 9 | const json_utils = ; | |
| 10 | const request_context = ; | |
| 11 | const window_context = ; | |
| 12 | const window_registry = ; | |
| 13 | const window_reply_target = ; | |
| 2 | 14 | |
| 3 | 15 | // Windows implementation using Win32 API and WebView2 |
| 4 | 16 | // Requires: Microsoft.Web.WebView2 NuGet package |
| @@ -78,8 +90,12 @@ pub const SW_HIDE: c_int = 0; | ||
| 78 | 90 | pub const SW_MAXIMIZE: c_int = 3; |
| 79 | 91 | pub const SW_MINIMIZE: c_int = 6; |
| 80 | 92 | pub const WM_DESTROY: UINT = 0x0002; |
| 93 | pub const WM_MOVE: UINT = 0x0003; | |
| 81 | 94 | pub const WM_SIZE: UINT = 0x0005; |
| 95 | pub const WM_ACTIVATE: UINT = 0x0006; | |
| 82 | 96 | pub const WM_CLOSE: UINT = 0x0010; |
| 97 | pub const WM_QUIT: UINT = 0x0012; | |
| 98 | const WM_CRAFT_OPEN_WINDOW: UINT = 0x8001; // WM_APP + 1 | |
| 83 | 99 | pub const PM_REMOVE: UINT = 0x0001; |
| 84 | 100 | pub const GWLP_USERDATA: c_int = -21; |
| 85 | 101 | |
| @@ -107,15 +123,21 @@ pub extern "user32" fn TranslateMessage(lpMsg: *const MSG) callconv(.c) BOOL; | ||
| 107 | 123 | pub extern "user32" fn DispatchMessageW(lpMsg: *const MSG) callconv(.c) LRESULT; |
| 108 | 124 | pub extern "user32" fn DefWindowProcW(hWnd: HWND, Msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c) LRESULT; |
| 109 | 125 | pub extern "user32" fn PostQuitMessage(nExitCode: c_int) callconv(.c) void; |
| 126 | pub extern "user32" fn PostMessageW(hWnd: ?HWND, Msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c) BOOL; | |
| 110 | 127 | pub extern "user32" fn DestroyWindow(hWnd: HWND) callconv(.c) BOOL; |
| 111 | 128 | pub extern "user32" fn SetWindowTextW(hWnd: HWND, lpString: LPCWSTR) callconv(.c) BOOL; |
| 129 | pub extern "user32" fn GetWindowTextLengthW(hWnd: HWND) callconv(.c) c_int; | |
| 130 | pub extern "user32" fn GetWindowTextW(hWnd: HWND, lpString: LPWSTR, nMaxCount: c_int) callconv(.c) c_int; | |
| 131 | pub extern "user32" fn GetForegroundWindow() callconv(.c) ?HWND; | |
| 112 | 132 | pub extern "user32" fn SetWindowPos(hWnd: HWND, hWndInsertAfter: ?HWND, X: c_int, Y: c_int, cx: c_int, cy: c_int, uFlags: UINT) callconv(.c) BOOL; |
| 113 | 133 | pub extern "user32" fn LoadCursorW(hInstance: ?HINSTANCE, lpCursorName: LPCWSTR) callconv(.c) ?*anyopaque; |
| 114 | 134 | pub extern "user32" fn GetClientRect(hWnd: HWND, lpRect: *RECT) callconv(.c) BOOL; |
| 135 | pub extern "user32" fn GetWindowRect(hWnd: HWND, lpRect: *RECT) callconv(.c) BOOL; | |
| 115 | 136 | pub extern "user32" fn SetWindowLongPtrW(hWnd: HWND, nIndex: c_int, dwNewLong: isize) callconv(.c) isize; |
| 116 | 137 | pub extern "user32" fn GetWindowLongPtrW(hWnd: HWND, nIndex: c_int) callconv(.c) isize; |
| 117 | 138 | pub extern "kernel32" fn GetModuleHandleW(lpModuleName: ?LPCWSTR) callconv(.c) ?HINSTANCE; |
| 118 | 139 | pub extern "kernel32" fn Sleep(dwMilliseconds: DWORD) callconv(.c) void; |
| 140 | pub extern "ole32" fn CoTaskMemFree(pv: ?*anyopaque) callconv(.c) void; | |
| 119 | 141 | |
| 120 | 142 | // ============================================================================ |
| 121 | 143 | // WebView2 COM vtable interfaces |
| @@ -265,6 +287,19 @@ pub const ICoreWebView2 = extern struct { | ||
| 265 | 287 | lpVtbl: *ICoreWebView2Vtbl, |
| 266 | 288 | }; |
| 267 | 289 | |
| 290 | pub const ICoreWebView2WebMessageReceivedEventArgsVtbl = extern struct { | |
| 291 | QueryInterface: *const fn (*ICoreWebView2WebMessageReceivedEventArgs, *const GUID, *?*anyopaque) callconv(.c) HRESULT, | |
| 292 | AddRef: *const fn (*ICoreWebView2WebMessageReceivedEventArgs) callconv(.c) c_ulong, | |
| 293 | Release: *const fn (*ICoreWebView2WebMessageReceivedEventArgs) callconv(.c) c_ulong, | |
| 294 | get_Source: *const fn (*ICoreWebView2WebMessageReceivedEventArgs, *?LPWSTR) callconv(.c) HRESULT, | |
| 295 | get_WebMessageAsJson: *const fn (*ICoreWebView2WebMessageReceivedEventArgs, *?LPWSTR) callconv(.c) HRESULT, | |
| 296 | TryGetWebMessageAsString: *const fn (*ICoreWebView2WebMessageReceivedEventArgs, *?LPWSTR) callconv(.c) HRESULT, | |
| 297 | }; | |
| 298 | ||
| 299 | pub const ICoreWebView2WebMessageReceivedEventArgs = extern struct { | |
| 300 | lpVtbl: *ICoreWebView2WebMessageReceivedEventArgsVtbl, | |
| 301 | }; | |
| 302 | ||
| 268 | 303 | // -- ICoreWebView2Settings --------------------------------------------------- |
| 269 | 304 | |
| 270 | 305 | pub const ICoreWebView2SettingsVtbl = extern struct { |
| @@ -534,6 +569,10 @@ const ControllerCompletedHandler = extern struct { | ||
| 534 | 569 | return perm_hr; |
| 535 | 570 | } |
| 536 | 571 | |
| 572 | // The callback only lends us the controller. Keep our own reference | |
| 573 | // past Invoke so WebView2 does not close before Window.create uses it. | |
| 574 | _ = controller.lpVtbl.AddRef(controller); | |
| 575 | ||
| 537 | 576 | // Store results |
| 538 | 577 | self.ctx.controller = controller; |
| 539 | 578 | self.ctx.webview = webview; |
| @@ -599,6 +638,85 @@ const PermissionRequestedHandler = extern struct { | ||
| 599 | 638 | |
| 600 | 639 | pub const ICoreWebView2PermissionRequestedEventHandler = PermissionRequestedHandler; |
| 601 | 640 | |
| 641 | // -- Page-to-host message handler -------------------------------------------- | |
| 642 | ||
| 643 | const WebMessageReceivedHandler = extern struct { | |
| 644 | lpVtbl: *const WebMessageReceivedHandlerVtbl, | |
| 645 | ref_count: c_ulong, | |
| 646 | window_id: u32, | |
| 647 | ||
| 648 | const WebMessageReceivedHandlerVtbl = extern struct { | |
| 649 | QueryInterface: *const fn (*WebMessageReceivedHandler, *const GUID, *?*anyopaque) callconv(.c) HRESULT, | |
| 650 | AddRef: *const fn (*WebMessageReceivedHandler) callconv(.c) c_ulong, | |
| 651 | Release: *const fn (*WebMessageReceivedHandler) callconv(.c) c_ulong, | |
| 652 | Invoke: *const fn (*WebMessageReceivedHandler, *ICoreWebView2, *ICoreWebView2WebMessageReceivedEventArgs) callconv(.c) HRESULT, | |
| 653 | }; | |
| 654 | ||
| 655 | const vtbl_instance = WebMessageReceivedHandlerVtbl{ | |
| 656 | .QueryInterface = &queryInterface, | |
| 657 | .AddRef = &addRef, | |
| 658 | .Release = &release, | |
| 659 | .Invoke = &invoke, | |
| 660 | }; | |
| 661 | ||
| 662 | fn queryInterface(self: *WebMessageReceivedHandler, _: *const GUID, ppv: *?*anyopaque) callconv(.c) HRESULT { | |
| 663 | ppv.* = @ptrCast(self); | |
| 664 | _ = addRef(self); | |
| 665 | return S_OK; | |
| 666 | } | |
| 667 | ||
| 668 | fn addRef(self: *WebMessageReceivedHandler) callconv(.c) c_ulong { | |
| 669 | self.ref_count += 1; | |
| 670 | return self.ref_count; | |
| 671 | } | |
| 672 | ||
| 673 | fn release(self: *WebMessageReceivedHandler) callconv(.c) c_ulong { | |
| 674 | if (self.ref_count > 0) self.ref_count -= 1; | |
| 675 | const remaining = self.ref_count; | |
| 676 | if (remaining == 0) std.heap.c_allocator.destroy(self); | |
| 677 | return remaining; | |
| 678 | } | |
| 679 | ||
| 680 | fn invoke(self: *WebMessageReceivedHandler, _: *ICoreWebView2, args: *ICoreWebView2WebMessageReceivedEventArgs) callconv(.c) HRESULT { | |
| 681 | // The handler is registered on exactly one native WebView2 instance. | |
| 682 | // Bind its live registry id rather than comparing raw COM interface | |
| 683 | // pointers, which need not have the same address for one object. | |
| 684 | const entry = desktop_windows.byId(self.window_id) orelse return S_OK; | |
| 685 | var message_wide: ?LPWSTR = null; | |
| 686 | if (!succeeded(args.lpVtbl.get_WebMessageAsJson(args, &message_wide))) return S_OK; | |
| 687 | const wide = message_wide orelse return S_OK; | |
| 688 | defer CoTaskMemFree(@ptrCast(wide)); | |
| 689 | const wide_text = std.mem.span(wide); | |
| 690 | const message = desktop_bridge_text.fromUtf16(std.heap.c_allocator, wide_text) catch return S_OK; | |
| 691 | defer std.heap.c_allocator.free(message); | |
| 692 | ||
| 693 | var envelope = desktop_bridge_envelope.parse(std.heap.c_allocator, message) catch return S_OK; | |
| 694 | defer envelope.deinit(); | |
| 695 | window_context.push(entry.window, entry.webview); | |
| 696 | defer window_context.pop(); | |
| 697 | request_context.push(envelope.request_id); | |
| 698 | defer request_context.pop(); | |
| 699 | ||
| 700 | if (!std.mem.eql(u8, envelope.kind, "window")) { | |
| 701 | bridge_error.sendErrorToJS(std.heap.c_allocator, envelope.action, error.PlatformNotSupported); | |
| 702 | return S_OK; | |
| 703 | } | |
| 704 | if (std.mem.eql(u8, envelope.action, "open") or std.mem.eql(u8, envelope.action, "create")) { | |
| 705 | // WebView2 does not deliver async completion callbacks inside its | |
| 706 | // own event callback. Window.create pumps until such a callback, | |
| 707 | // so run it from a posted Win32 message after Invoke returns. | |
| 708 | queueWindowOpen(entry, envelope.action, envelope.data, envelope.request_id) catch |err| { | |
| 709 | bridge_error.sendErrorToJS(std.heap.c_allocator, envelope.action, bridge_error.fromHandlerError(err)); | |
| 710 | }; | |
| 711 | return S_OK; | |
| 712 | } | |
| 713 | handleWindowAction(envelope.action, envelope.data) catch |err| { | |
| 714 | bridge_error.sendErrorToJS(std.heap.c_allocator, envelope.action, bridge_error.fromHandlerError(err)); | |
| 715 | }; | |
| 716 | return S_OK; | |
| 717 | } | |
| 718 | }; | |
| 719 | ||
| 602 | 720 | // -- ExecuteScript completed handler (fire-and-forget) ----------------------- |
| 603 | 721 | |
| 604 | 722 | const ExecuteScriptCompletedHandler = extern struct { |
| @@ -647,6 +765,57 @@ const ExecuteScriptCompletedHandler = extern struct { | ||
| 647 | 765 | |
| 648 | 766 | pub const ICoreWebView2ExecuteScriptCompletedHandler = ExecuteScriptCompletedHandler; |
| 649 | 767 | |
| 768 | // -- Document-start script completed handler --------------------------------- | |
| 769 | ||
| 770 | const ScriptInstallContext = struct { | |
| 771 | done: bool = false, | |
| 772 | result: HRESULT = -1, | |
| 773 | }; | |
| 774 | ||
| 775 | const ScriptInstallCompletedHandler = extern struct { | |
| 776 | lpVtbl: *const ScriptInstallCompletedHandlerVtbl, | |
| 777 | ref_count: c_ulong, | |
| 778 | ctx: *ScriptInstallContext, | |
| 779 | ||
| 780 | const ScriptInstallCompletedHandlerVtbl = extern struct { | |
| 781 | QueryInterface: *const fn (*ScriptInstallCompletedHandler, *const GUID, *?*anyopaque) callconv(.c) HRESULT, | |
| 782 | AddRef: *const fn (*ScriptInstallCompletedHandler) callconv(.c) c_ulong, | |
| 783 | Release: *const fn (*ScriptInstallCompletedHandler) callconv(.c) c_ulong, | |
| 784 | Invoke: *const fn (*ScriptInstallCompletedHandler, HRESULT, LPCWSTR) callconv(.c) HRESULT, | |
| 785 | }; | |
| 786 | ||
| 787 | const vtbl_instance = ScriptInstallCompletedHandlerVtbl{ | |
| 788 | .QueryInterface = &queryInterface, | |
| 789 | .AddRef = &addRef, | |
| 790 | .Release = &release, | |
| 791 | .Invoke = &invoke, | |
| 792 | }; | |
| 793 | ||
| 794 | fn queryInterface(self: *ScriptInstallCompletedHandler, _: *const GUID, ppv: *?*anyopaque) callconv(.c) HRESULT { | |
| 795 | ppv.* = @ptrCast(self); | |
| 796 | _ = addRef(self); | |
| 797 | return S_OK; | |
| 798 | } | |
| 799 | ||
| 800 | fn addRef(self: *ScriptInstallCompletedHandler) callconv(.c) c_ulong { | |
| 801 | self.ref_count += 1; | |
| 802 | return self.ref_count; | |
| 803 | } | |
| 804 | ||
| 805 | fn release(self: *ScriptInstallCompletedHandler) callconv(.c) c_ulong { | |
| 806 | if (self.ref_count > 0) self.ref_count -= 1; | |
| 807 | const remaining = self.ref_count; | |
| 808 | if (remaining == 0) std.heap.c_allocator.destroy(self); | |
| 809 | return remaining; | |
| 810 | } | |
| 811 | ||
| 812 | fn invoke(self: *ScriptInstallCompletedHandler, hr: HRESULT, _: LPCWSTR) callconv(.c) HRESULT { | |
| 813 | self.ctx.result = hr; | |
| 814 | self.ctx.done = true; | |
| 815 | return S_OK; | |
| 816 | } | |
| 817 | }; | |
| 818 | ||
| 650 | 819 | // ============================================================================ |
| 651 | 820 | // WebView2Loader — loaded dynamically at runtime to avoid link-time dependency |
| 652 | 821 | // ============================================================================ |
| @@ -699,11 +868,316 @@ var app_running = false; | ||
| 699 | 868 | var window_class_registered = false; |
| 700 | 869 | const CLASS_NAME: [:0]const u16 = &[_:0]u16{ 'Z', 'y', 't', 'e', 'W', 'i', 'n', 'd', 'o', 'w' }; |
| 701 | 870 | |
| 702 | // WebView2 owns these COM interfaces independently of the stack value returned | |
| 703 | // from Window.create. Keep only stable COM pointers in global callback state. | |
| 704 | var g_active_hwnd: ?HWND = null; | |
| 705 | var g_active_controller: ?*ICoreWebView2Controller = null; | |
| 706 | var g_active_webview: ?*ICoreWebView2 = null; | |
| 871 | // Native callbacks arrive with an HWND, not the stack value returned by | |
| 872 | // Window.create. Keep stable handles for every live window here. | |
| 873 | var desktop_windows: desktop_window_registry.Registry = .{}; | |
| 874 | ||
| 875 | const PendingWindowOpen = struct { | |
| 876 | next: ?*PendingWindowOpen = null, | |
| 877 | owner_id: u32, | |
| 878 | request_id: ?u64, | |
| 879 | action: []const u8, | |
| 880 | data: ?[]u8, | |
| 881 | }; | |
| 882 | ||
| 883 | var pending_open_head: ?*PendingWindowOpen = null; | |
| 884 | var pending_open_tail: ?*PendingWindowOpen = null; | |
| 885 | var pending_open_count: usize = 0; | |
| 886 | var processing_open = false; | |
| 887 | ||
| 888 | fn freePendingOpen(task: *PendingWindowOpen) void { | |
| 889 | if (task.data) |data| std.heap.c_allocator.free(data); | |
| 890 | std.heap.c_allocator.destroy(task); | |
| 891 | } | |
| 892 | ||
| 893 | fn queueWindowOpen(owner: desktop_window_registry.Entry, action: []const u8, data: ?[]const u8, request_id: ?u64) !void { | |
| 894 | if (pending_open_count >= desktop_window_registry.capacity) return error.TooManyWindows; | |
| 895 | const copied_data: ?[]u8 = if (data) |value| try std.heap.c_allocator.dupe(u8, value) else null; | |
| 896 | errdefer if (copied_data) |value| std.heap.c_allocator.free(value); | |
| 897 | const task = try std.heap.c_allocator.create(PendingWindowOpen); | |
| 898 | errdefer std.heap.c_allocator.destroy(task); | |
| 899 | task.* = .{ | |
| 900 | .owner_id = owner.id, | |
| 901 | .request_id = request_id, | |
| 902 | .action = if (std.mem.eql(u8, action, "open")) "open" else "create", | |
| 903 | .data = copied_data, | |
| 904 | }; | |
| 905 | const owner_hwnd: HWND = @ptrFromInt(owner.window); | |
| 906 | if (PostMessageW(owner_hwnd, WM_CRAFT_OPEN_WINDOW, 0, 0) == 0) | |
| 907 | return error.NativeCallFailed; | |
| 908 | if (pending_open_tail) |tail| tail.next = task else pending_open_head = task; | |
| 909 | pending_open_tail = task; | |
| 910 | pending_open_count += 1; | |
| 911 | } | |
| 912 | ||
| 913 | fn discardPendingOpens(owner_id: u32) void { | |
| 914 | var previous: ?*PendingWindowOpen = null; | |
| 915 | var current = pending_open_head; | |
| 916 | while (current) |task| { | |
| 917 | const next = task.next; | |
| 918 | if (task.owner_id == owner_id) { | |
| 919 | if (previous) |prior| prior.next = next else pending_open_head = next; | |
| 920 | if (pending_open_tail != null and pending_open_tail.? == task) pending_open_tail = previous; | |
| 921 | pending_open_count -= 1; | |
| 922 | freePendingOpen(task); | |
| 923 | } else { | |
| 924 | previous = task; | |
| 925 | } | |
| 926 | current = next; | |
| 927 | } | |
| 928 | } | |
| 929 | ||
| 930 | fn processPendingOpens() void { | |
| 931 | if (processing_open) return; | |
| 932 | processing_open = true; | |
| 933 | defer processing_open = false; | |
| 934 | ||
| 935 | while (pending_open_head) |task| { | |
| 936 | pending_open_head = task.next; | |
| 937 | if (pending_open_head == null) pending_open_tail = null; | |
| 938 | pending_open_count -= 1; | |
| 939 | if (desktop_windows.byId(task.owner_id)) |owner| { | |
| 940 | window_context.push(owner.window, owner.webview); | |
| 941 | request_context.push(task.request_id); | |
| 942 | const data: ?[]const u8 = if (task.data) |value| value else null; | |
| 943 | openNamedWindow(task.action, data) catch |err| { | |
| 944 | if (desktop_windows.byId(task.owner_id) != null) | |
| 945 | bridge_error.sendErrorToJS(std.heap.c_allocator, task.action, bridge_error.fromHandlerError(err)); | |
| 946 | }; | |
| 947 | request_context.pop(); | |
| 948 | window_context.pop(); | |
| 949 | } | |
| 950 | freePendingOpen(task); | |
| 951 | } | |
| 952 | } | |
| 953 | ||
| 954 | fn deliverToWebview(webview_handle: usize, name: []const u8, detail_json: []const u8, window_name: ?[]const u8) void { | |
| 955 | const script = desktop_window_events.format(std.heap.c_allocator, name, detail_json, window_name) catch return; | |
| 956 | defer std.heap.c_allocator.free(script); | |
| 957 | var wide = desktop_script_encoding.encode(std.heap.c_allocator, script) catch return; | |
| 958 | defer wide.deinit(std.heap.c_allocator); | |
| 959 | const webview: *ICoreWebView2 = @ptrFromInt(webview_handle); | |
| 960 | const handler = std.heap.c_allocator.create(ExecuteScriptCompletedHandler) catch return; | |
| 961 | handler.* = .{ .lpVtbl = &ExecuteScriptCompletedHandler.vtbl_instance, .ref_count = 1 }; | |
| 962 | _ = webview.lpVtbl.ExecuteScript(webview, wide.ptr(), handler); | |
| 963 | _ = ExecuteScriptCompletedHandler.esRelease(handler); | |
| 964 | } | |
| 965 | ||
| 966 | fn deliverWindowEvent(entry: desktop_window_registry.Entry, name: []const u8, detail_json: []const u8) void { | |
| 967 | deliverToWebview(entry.webview, name, detail_json, null); | |
| 968 | const owner = window_registry.ownerWebViewOf(entry.window) orelse return; | |
| 969 | if (owner == entry.webview or desktop_windows.byWebview(owner) == null) return; | |
| 970 | const window_name = window_registry.nameOf(entry.window) orelse return; | |
| 971 | deliverToWebview(owner, name, detail_json, window_name); | |
| 972 | } | |
| 973 | ||
| 974 | fn observeWindowGeometry(entry: desktop_window_registry.Entry) void { | |
| 975 | var rect: RECT = undefined; | |
| 976 | if (GetWindowRect(@ptrFromInt(entry.window), &rect) == 0) return; | |
| 977 | const width = @as(i64, rect.right) - @as(i64, rect.left); | |
| 978 | const height = @as(i64, rect.bottom) - @as(i64, rect.top); | |
| 979 | if (width < 0 or height < 0 or width > std.math.maxInt(u32) or height > std.math.maxInt(u32)) return; | |
| 980 | const change = desktop_windows.observeGeometry(entry.window, .{ | |
| 981 | .x = @intCast(rect.left), | |
| 982 | .y = @intCast(rect.top), | |
| 983 | .width = @intCast(width), | |
| 984 | .height = @intCast(height), | |
| 985 | }) orelse return; | |
| 986 | var detail_buf: [96]u8 = undefined; | |
| 987 | if (change.moved) { | |
| 988 | const detail = std.fmt.bufPrint(&detail_buf, "", .{ rect.left, rect.top }) catch return; | |
| 989 | deliverWindowEvent(entry, "move", detail); | |
| 990 | } | |
| 991 | if (change.resized) { | |
| 992 | const detail = std.fmt.bufPrint(&detail_buf, "", .{ width, height }) catch return; | |
| 993 | deliverWindowEvent(entry, "resize", detail); | |
| 994 | } | |
| 995 | } | |
| 996 | ||
| 997 | fn namedWindowResult(allocator: std.mem.Allocator, name: []const u8) ![]u8 { | |
| 998 | var json: std.ArrayListUnmanaged(u8) = .empty; | |
| 999 | errdefer json.deinit(allocator); | |
| 1000 | try json.appendSlice(allocator, "{\"name\":\""); | |
| 1001 | try bridge_error.appendJsonEscaped(allocator, &json, name); | |
| 1002 | try json.appendSlice(allocator, "\"}"); | |
| 1003 | return json.toOwnedSlice(allocator); | |
| 1004 | } | |
| 1005 | ||
| 1006 | fn openNamedWindow(action: []const u8, data: ?[]const u8) !void { | |
| 1007 | const allocator = std.heap.c_allocator; | |
| 1008 | const json = data orelse return error.MissingData; | |
| 1009 | const explicit_name = try json_utils.getStringDecoded(allocator, json, "name"); | |
| 1010 | defer if (explicit_name) |name| allocator.free(name); | |
| 1011 | const fallback_id = if (explicit_name == null) | |
| 1012 | try json_utils.getStringDecoded(allocator, json, "id") | |
| 1013 | else | |
| 1014 | null; | |
| 1015 | defer if (fallback_id) |name| allocator.free(name); | |
| 1016 | const name = explicit_name orelse fallback_id orelse return error.InvalidParameter; | |
| 1017 | if (name.len == 0 or name.len > window_registry.max_name or | |
| 1018 | std.mem.eql(u8, name, "main") or std.mem.indexOfScalar(u8, name, 0) != null) | |
| 1019 | return error.InvalidParameter; | |
| 1020 | ||
| 1021 | const url = try json_utils.getStringDecoded(allocator, json, "url"); | |
| 1022 | defer if (url) |text| allocator.free(text); | |
| 1023 | const html = try json_utils.getStringDecoded(allocator, json, "html"); | |
| 1024 | defer if (html) |text| allocator.free(text); | |
| 1025 | if (url == null and html == null) return error.InvalidParameter; | |
| 1026 | const title = try json_utils.getStringDecoded(allocator, json, "title"); | |
| 1027 | defer if (title) |text| allocator.free(text); | |
| 1028 | for ([_]?[]const u8{ url, html, title }) |value| { | |
| 1029 | if (value) |text| if (std.mem.indexOfScalar(u8, text, 0) != null) | |
| 1030 | return error.InvalidParameter; | |
| 1031 | } | |
| 1032 | ||
| 1033 | const result = try namedWindowResult(allocator, name); | |
| 1034 | defer allocator.free(result); | |
| 1035 | const owner = window_context.currentWebView() orelse return error.WebViewHandleNotSet; | |
| 1036 | if (window_registry.byName(name)) |existing| { | |
| 1037 | if (desktop_windows.byWindow(existing) == null) return error.WindowHandleNotSet; | |
| 1038 | if (!window_registry.rememberNamedOwned(existing, name, owner)) return error.InvalidParameter; | |
| 1039 | _ = ShowWindow(@ptrFromInt(existing), SW_SHOW); | |
| 1040 | _ = UpdateWindow(@ptrFromInt(existing)); | |
| 1041 | bridge_error.sendResultToJS(allocator, action, result); | |
| 1042 | return; | |
| 1043 | } | |
| 1044 | ||
| 1045 | const width = json_utils.getInt(u32, json, "width") orelse 800; | |
| 1046 | const height = json_utils.getInt(u32, json, "height") orelse 600; | |
| 1047 | if (width == 0 or height == 0 or width > @as(u32, std.math.maxInt(c_int)) or height > @as(u32, std.math.maxInt(c_int))) | |
| 1048 | return error.InvalidParameter; | |
| 1049 | var created = try Window.create(.{ | |
| 1050 | .title = title orelse name, | |
| 1051 | .width = width, | |
| 1052 | .height = height, | |
| 1053 | .x = json_utils.getInt(i32, json, "x"), | |
| 1054 | .y = json_utils.getInt(i32, json, "y"), | |
| 1055 | .resizable = json_utils.getBool(json, "resizable") orelse true, | |
| 1056 | .frameless = json_utils.getBool(json, "frameless") orelse false, | |
| 1057 | .always_on_top = json_utils.getBool(json, "alwaysOnTop") orelse false, | |
| 1058 | .fullscreen = json_utils.getBool(json, "fullscreen") orelse false, | |
| 1059 | .dev_tools = json_utils.getBool(json, "devTools") orelse false, | |
| 1060 | }); | |
| 1061 | errdefer created.close(); | |
| 1062 | if (url) |text| try created.loadURL(text) else if (html) |text| try created.loadHTML(text); | |
| 1063 | if (desktop_windows.byWebview(owner) == null) return error.WindowHandleNotSet; | |
| 1064 | if (!window_registry.rememberNamedOwned(@intFromPtr(created.hwnd), name, owner)) | |
| 1065 | return error.TooManyWindows; | |
| 1066 | created.show(); | |
| 1067 | bridge_error.sendResultToJS(allocator, action, result); | |
| 1068 | } | |
| 1069 | ||
| 1070 | fn targetWindow(data: ?[]const u8) !desktop_window_registry.Entry { | |
| 1071 | if (data) |json| { | |
| 1072 | const name = try json_utils.getStringDecoded(std.heap.c_allocator, json, "windowId"); | |
| 1073 | defer if (name) |text| std.heap.c_allocator.free(text); | |
| 1074 | if (name) |text| { | |
| 1075 | if (!std.mem.eql(u8, text, "main")) { | |
| 1076 | const handle = window_registry.byName(text) orelse return error.NotFound; | |
| 1077 | return desktop_windows.byWindow(handle) orelse error.NotFound; | |
| 1078 | } | |
| 1079 | } | |
| 1080 | } | |
| 1081 | return desktop_windows.byWindow(window_context.current() orelse return error.WindowHandleNotSet) orelse error.WindowHandleNotSet; | |
| 1082 | } | |
| 1083 | ||
| 1084 | fn windowGeometry(hwnd: HWND) !desktop_window_registry.Geometry { | |
| 1085 | var rect: RECT = undefined; | |
| 1086 | if (GetWindowRect(hwnd, &rect) == 0) return error.NativeCallFailed; | |
| 1087 | const width = @as(i64, rect.right) - @as(i64, rect.left); | |
| 1088 | const height = @as(i64, rect.bottom) - @as(i64, rect.top); | |
| 1089 | if (width < 0 or height < 0 or width > std.math.maxInt(u32) or height > std.math.maxInt(u32)) | |
| 1090 | return error.NativeCallFailed; | |
| 1091 | return .{ .x = @intCast(rect.left), .y = @intCast(rect.top), .width = @intCast(width), .height = @intCast(height) }; | |
| 1092 | } | |
| 1093 | ||
| 1094 | fn windowTitle(allocator: std.mem.Allocator, hwnd: HWND) ![:0]u8 { | |
| 1095 | const length = GetWindowTextLengthW(hwnd); | |
| 1096 | if (length < 0) return error.NativeCallFailed; | |
| 1097 | if (@as(usize, @intCast(length)) >= desktop_bridge_envelope.max_message_bytes) | |
| 1098 | return error.MessageTooLarge; | |
| 1099 | const wide = try allocator.alloc(u16, @as(usize, @intCast(length)) + 1); | |
| 1100 | defer allocator.free(wide); | |
| 1101 | const copied = GetWindowTextW(hwnd, @ptrCast(wide.ptr), @intCast(wide.len)); | |
| 1102 | if (copied < 0) return error.NativeCallFailed; | |
| 1103 | const utf8 = try desktop_bridge_text.fromUtf16(allocator, wide[0..@intCast(copied)]); | |
| 1104 | return utf8; | |
| 1105 | } | |
| 1106 | ||
| 1107 | fn sendWindowRead(action: []const u8, data: ?[]const u8) !void { | |
| 1108 | const allocator = std.heap.c_allocator; | |
| 1109 | const entry = try targetWindow(data); | |
| 1110 | const hwnd: HWND = @ptrFromInt(entry.window); | |
| 1111 | const json = if (std.mem.eql(u8, action, "getTitle")) blk: { | |
| 1112 | const title = try windowTitle(allocator, hwnd); | |
| 1113 | defer allocator.free(title); | |
| 1114 | break :blk try desktop_window_reads.string(allocator, title); | |
| 1115 | } else if (std.mem.eql(u8, action, "getFocused")) blk: { | |
| 1116 | const focused = if (GetForegroundWindow()) |window| @intFromPtr(window) else 0; | |
| 1117 | const name = desktop_window_reads.focusedName(&desktop_windows, entry.window, focused, window_registry.nameOf(focused)); | |
| 1118 | break :blk try desktop_window_reads.string(allocator, name); | |
| 1119 | } else try desktop_window_reads.geometry(allocator, action, try windowGeometry(hwnd)); | |
| 1120 | defer allocator.free(json); | |
| 1121 | bridge_error.sendResultToJS(allocator, action, json); | |
| 1122 | } | |
| 1123 | ||
| 1124 | fn handleWindowAction(action: []const u8, data: ?[]const u8) !void { | |
| 1125 | if (std.mem.eql(u8, action, "open") or std.mem.eql(u8, action, "create")) | |
| 1126 | return openNamedWindow(action, data); | |
| 1127 | if (std.mem.eql(u8, action, "getTitle") or std.mem.eql(u8, action, "getSize") or | |
| 1128 | std.mem.eql(u8, action, "getPosition") or std.mem.eql(u8, action, "getBounds") or | |
| 1129 | std.mem.eql(u8, action, "getFocused")) | |
| 1130 | return sendWindowRead(action, data); | |
| 1131 | ||
| 1132 | const entry = try targetWindow(data); | |
| 1133 | const hwnd: HWND = @ptrFromInt(entry.window); | |
| 1134 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 1135 | if (std.mem.eql(u8, action, "show") or std.mem.eql(u8, action, "focus")) { | |
| 1136 | _ = ShowWindow(hwnd, SW_SHOW); | |
| 1137 | _ = UpdateWindow(hwnd); | |
| 1138 | } else if (std.mem.eql(u8, action, "hide")) { | |
| 1139 | _ = ShowWindow(hwnd, SW_HIDE); | |
| 1140 | } else if (std.mem.eql(u8, action, "close") or std.mem.eql(u8, action, "destroy")) { | |
| 1141 | if (DestroyWindow(hwnd) == 0) return error.NativeCallFailed; | |
| 1142 | } else if (std.mem.eql(u8, action, "minimize")) { | |
| 1143 | _ = ShowWindow(hwnd, SW_MINIMIZE); | |
| 1144 | } else if (std.mem.eql(u8, action, "maximize")) { | |
| 1145 | _ = ShowWindow(hwnd, SW_MAXIMIZE); | |
| 1146 | } else if (std.mem.eql(u8, action, "restore") or std.mem.eql(u8, action, "unmaximize")) { | |
| 1147 | _ = ShowWindow(hwnd, 9); // SW_RESTORE | |
| 1148 | } else if (std.mem.eql(u8, action, "reload")) { | |
| 1149 | if (!succeeded(webview.lpVtbl.Reload(webview))) return error.NativeCallFailed; | |
| 1150 | } else if (std.mem.eql(u8, action, "setTitle")) { | |
| 1151 | const json = data orelse return error.MissingData; | |
| 1152 | const title = (try json_utils.getStringDecoded(std.heap.c_allocator, json, "title")) orelse return error.InvalidParameter; | |
| 1153 | defer std.heap.c_allocator.free(title); | |
| 1154 | if (std.mem.indexOfScalar(u8, title, 0) != null) return error.InvalidParameter; | |
| 1155 | const title_wide = utf8ToUtf16Z(256, title) catch return error.InvalidParameter; | |
| 1156 | if (SetWindowTextW(hwnd, @ptrCast(&title_wide)) == 0) return error.NativeCallFailed; | |
| 1157 | } else if (std.mem.eql(u8, action, "setSize")) { | |
| 1158 | const json = data orelse return error.MissingData; | |
| 1159 | const width = json_utils.getInt(u32, json, "width") orelse return error.InvalidParameter; | |
| 1160 | const height = json_utils.getInt(u32, json, "height") orelse return error.InvalidParameter; | |
| 1161 | if (width == 0 or height == 0 or width > @as(u32, std.math.maxInt(c_int)) or height > @as(u32, std.math.maxInt(c_int))) | |
| 1162 | return error.InvalidParameter; | |
| 1163 | if (SetWindowPos(hwnd, null, 0, 0, @intCast(width), @intCast(height), 0x0002) == 0) return error.NativeCallFailed; | |
| 1164 | } else if (std.mem.eql(u8, action, "setPosition")) { | |
| 1165 | const json = data orelse return error.MissingData; | |
| 1166 | const x = json_utils.getInt(i32, json, "x") orelse return error.InvalidParameter; | |
| 1167 | const y = json_utils.getInt(i32, json, "y") orelse return error.InvalidParameter; | |
| 1168 | if (SetWindowPos(hwnd, null, x, y, 0, 0, 0x0001) == 0) return error.NativeCallFailed; | |
| 1169 | } else if (std.mem.eql(u8, action, "loadURL") or std.mem.eql(u8, action, "loadHTML")) { | |
| 1170 | const json = data orelse return error.MissingData; | |
| 1171 | const key: []const u8 = if (std.mem.eql(u8, action, "loadURL")) "url" else "html"; | |
| 1172 | const value = (try json_utils.getStringDecoded(std.heap.c_allocator, json, key)) orelse return error.InvalidParameter; | |
| 1173 | defer std.heap.c_allocator.free(value); | |
| 1174 | if (std.mem.indexOfScalar(u8, value, 0) != null) return error.InvalidParameter; | |
| 1175 | var native = Window{ .id = entry.id, .hwnd = hwnd, .controller = @ptrFromInt(entry.context), .webview = webview, .title = "", .width = 0, .height = 0, .x = 0, .y = 0 }; | |
| 1176 | if (std.mem.eql(u8, action, "loadURL")) try native.loadURL(value) else try native.loadHTML(value); | |
| 1177 | } else { | |
| 1178 | return error.PlatformNotSupported; | |
| 1179 | } | |
| 1180 | } | |
| 707 | 1181 | |
| 708 | 1182 | pub const WindowStyle = struct { |
| 709 | 1183 | frameless: bool = false, |
| @@ -721,6 +1195,7 @@ pub const WindowStyle = struct { | ||
| 721 | 1195 | }; |
| 722 | 1196 | |
| 723 | 1197 | pub const Window = struct { |
| 1198 | id: u32, | |
| 724 | 1199 | hwnd: HWND, |
| 725 | 1200 | controller: ?*ICoreWebView2Controller, |
| 726 | 1201 | webview: ?*ICoreWebView2, |
| @@ -766,10 +1241,8 @@ pub const Window = struct { | ||
| 766 | 1241 | const ex_style: DWORD = if (options.always_on_top) WS_EX_TOPMOST else 0; |
| 767 | 1242 | |
| 768 | 1243 | // Convert title to wide string |
| 769 | var title_wide: [256]u16 = undefined; | |
| 770 | const title_len = std.unicode.utf8ToUtf16Le(&title_wide, options.title) catch return error.WindowCreationFailed; | |
| 771 | title_wide[title_len] = 0; | |
| 772 | const title_ptr: [*:0]const u16 = title_wide[0..title_len :0]; | |
| 1244 | const title_wide = utf8ToUtf16Z(256, options.title) catch return error.WindowCreationFailed; | |
| 1245 | const title_ptr: LPCWSTR = @ptrCast(&title_wide); | |
| 773 | 1246 | |
| 774 | 1247 | // Calculate window position |
| 775 | 1248 | const x = options.x orelse CW_USEDEFAULT; |
| @@ -790,6 +1263,7 @@ pub const Window = struct { | ||
| 790 | 1263 | hInstance, |
| 791 | 1264 | null, |
| 792 | 1265 | ) orelse return error.WindowCreationFailed; |
| 1266 | errdefer _ = DestroyWindow(hwnd); | |
| 793 | 1267 | |
| 794 | 1268 | // ---------------------------------------------------------------- |
| 795 | 1269 | // Async WebView2 initialization |
| @@ -851,10 +1325,35 @@ pub const Window = struct { | ||
| 851 | 1325 | |
| 852 | 1326 | std.debug.print("[Media] Windows WebView2 configured for camera/microphone access\n", .{}); |
| 853 | 1327 | |
| 854 | const window = Window{ | |
| 1328 | const controller = init_ctx.controller orelse return error.WebView2InitFailed; | |
| 1329 | const webview = init_ctx.webview orelse return error.WebView2InitFailed; | |
| 1330 | const window_id = desktop_windows.rememberWithContext(@intFromPtr(hwnd), @intFromPtr(webview), @intFromPtr(controller)) orelse { | |
| 1331 | _ = controller.lpVtbl.Close(controller); | |
| 1332 | _ = webview.lpVtbl.Release(webview); | |
| 1333 | _ = controller.lpVtbl.Release(controller); | |
| 1334 | return error.TooManyWindows; | |
| 1335 | }; | |
| 1336 | ||
| 1337 | const message_handler = try std.heap.c_allocator.create(WebMessageReceivedHandler); | |
| 1338 | message_handler.* = .{ .lpVtbl = &WebMessageReceivedHandler.vtbl_instance, .ref_count = 1, .window_id = window_id }; | |
| 1339 | var message_token: EventRegistrationToken = .{ .value = 0 }; | |
| 1340 | const message_hr = webview.lpVtbl.add_WebMessageReceived(webview, @ptrCast(message_handler), &message_token); | |
| 1341 | _ = WebMessageReceivedHandler.release(message_handler); | |
| 1342 | if (!succeeded(message_hr)) { | |
| 1343 | std.debug.print("[WebView2] Page message registration failed: 0x{x}\n", .{@as(u32, @bitCast(message_hr))}); | |
| 1344 | return error.WebMessageRegistrationFailed; | |
| 1345 | } | |
| 1346 | if (!desktop_windows.setMessageToken(@intFromPtr(hwnd), message_token.value)) { | |
| 1347 | _ = webview.lpVtbl.remove_WebMessageReceived(webview, message_token); | |
| 1348 | return error.WebMessageRegistrationFailed; | |
| 1349 | } | |
| 1350 | observeWindowGeometry(desktop_windows.byId(window_id).?); | |
| 1351 | ||
| 1352 | var window = Window{ | |
| 1353 | .id = window_id, | |
| 855 | 1354 | .hwnd = hwnd, |
| 856 | .controller = init_ctx.controller, | |
| 857 | .webview = init_ctx.webview, | |
| 1355 | .controller = controller, | |
| 1356 | .webview = webview, | |
| 858 | 1357 | .title = options.title, |
| 859 | 1358 | .width = options.width, |
| 860 | 1359 | .height = options.height, |
| @@ -862,35 +1361,42 @@ pub const Window = struct { | ||
| 862 | 1361 | .y = y, |
| 863 | 1362 | }; |
| 864 | 1363 | |
| 865 | g_active_hwnd = hwnd; | |
| 866 | g_active_controller = window.controller; | |
| 867 | g_active_webview = window.webview; | |
| 868 | ||
| 1364 | // Install before the first Navigate/NavigateToString so every page | |
| 1365 | // starts with the same bridge surface as a macOS Craft window. | |
| 1366 | try window.injectScript(@embedFile("js/craft-bridge.js")); | |
| 869 | 1367 | return window; |
| 870 | 1368 | } |
| 871 | 1369 | |
| 1370 | fn liveEntry(self: *const Window) ?desktop_window_registry.Entry { | |
| 1371 | const entry = desktop_windows.byId(self.id) orelse return null; | |
| 1372 | return if (entry.window == @intFromPtr(self.hwnd)) entry else null; | |
| 1373 | } | |
| 1374 | ||
| 872 | 1375 | pub fn show(self: *Window) void { |
| 1376 | if (self.liveEntry() == null) return; | |
| 873 | 1377 | _ = ShowWindow(self.hwnd, SW_SHOW); |
| 874 | 1378 | _ = UpdateWindow(self.hwnd); |
| 875 | 1379 | } |
| 876 | 1380 | |
| 877 | 1381 | pub fn hide(self: *Window) void { |
| 1382 | if (self.liveEntry() == null) return; | |
| 878 | 1383 | _ = ShowWindow(self.hwnd, SW_HIDE); |
| 879 | 1384 | } |
| 880 | 1385 | |
| 881 | 1386 | pub fn close(self: *Window) void { |
| 882 | if (self.controller) |ctrl| { | |
| 883 | _ = ctrl.lpVtbl.Close(ctrl); | |
| 1387 | if (self.liveEntry() == null) { | |
| 1388 | self.controller = null; | |
| 1389 | self.webview = null; | |
| 1390 | return; | |
| 1391 | } | |
| 1392 | if (DestroyWindow(self.hwnd) != 0) { | |
| 1393 | self.controller = null; | |
| 1394 | self.webview = null; | |
| 884 | 1395 | } |
| 885 | self.controller = null; | |
| 886 | self.webview = null; | |
| 887 | g_active_hwnd = null; | |
| 888 | g_active_controller = null; | |
| 889 | g_active_webview = null; | |
| 890 | _ = DestroyWindow(self.hwnd); | |
| 891 | 1396 | } |
| 892 | 1397 | |
| 893 | 1398 | pub fn setSize(self: *Window, width: u32, height: u32) void { |
| 1399 | if (self.liveEntry() == null) return; | |
| 894 | 1400 | _ = SetWindowPos(self.hwnd, null, 0, 0, @intCast(width), @intCast(height), 0x0002); // SWP_NOMOVE |
| 895 | 1401 | self.width = width; |
| 896 | 1402 | self.height = height; |
| @@ -898,21 +1404,22 @@ pub const Window = struct { | ||
| 898 | 1404 | } |
| 899 | 1405 | |
| 900 | 1406 | pub fn setPosition(self: *Window, x_pos: i32, y_pos: i32) void { |
| 1407 | if (self.liveEntry() == null) return; | |
| 901 | 1408 | _ = SetWindowPos(self.hwnd, null, @intCast(x_pos), @intCast(y_pos), 0, 0, 0x0001); // SWP_NOSIZE |
| 902 | 1409 | self.x = x_pos; |
| 903 | 1410 | self.y = y_pos; |
| 904 | 1411 | } |
| 905 | 1412 | |
| 906 | 1413 | pub fn setTitle(self: *Window, title: []const u8) void { |
| 907 | var title_wide_buf: [256]u16 = undefined; | |
| 908 | const len = std.unicode.utf8ToUtf16Le(&title_wide_buf, title) catch return; | |
| 909 | title_wide_buf[len] = 0; | |
| 910 | _ = SetWindowTextW(self.hwnd, &title_wide_buf); | |
| 1414 | if (self.liveEntry() == null) return; | |
| 1415 | const title_wide_buf = utf8ToUtf16Z(256, title) catch return; | |
| 1416 | _ = SetWindowTextW(self.hwnd, @ptrCast(&title_wide_buf)); | |
| 911 | 1417 | self.title = title; |
| 912 | 1418 | } |
| 913 | 1419 | |
| 914 | 1420 | pub fn loadURL(self: *Window, url: []const u8) !void { |
| 915 | const webview = self.webview orelse return error.WebView2NotInitialized; | |
| 1421 | const entry = self.liveEntry() orelse return error.NoWebView; | |
| 1422 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 916 | 1423 | var url_wide = try utf8ToUtf16Z(4096, url); |
| 917 | 1424 | const url_ptr: LPCWSTR = @ptrCast(&url_wide); |
| 918 | 1425 | const hr = webview.lpVtbl.Navigate(webview, url_ptr); |
| @@ -923,7 +1430,8 @@ pub const Window = struct { | ||
| 923 | 1430 | } |
| 924 | 1431 | |
| 925 | 1432 | pub fn loadHTML(self: *Window, html: []const u8) !void { |
| 926 | const webview = self.webview orelse return error.WebView2NotInitialized; | |
| 1433 | const entry = self.liveEntry() orelse return error.NoWebView; | |
| 1434 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 927 | 1435 | // NavigateToString needs null-terminated UTF-16. |
| 928 | 1436 | // For large HTML we allocate on the heap. |
| 929 | 1437 | const wide_len = html.len + 1; // rough upper bound for ASCII-heavy content |
| @@ -942,10 +1450,12 @@ pub const Window = struct { | ||
| 942 | 1450 | } |
| 943 | 1451 | |
| 944 | 1452 | pub fn maximize(self: *Window) void { |
| 1453 | if (self.liveEntry() == null) return; | |
| 945 | 1454 | _ = ShowWindow(self.hwnd, SW_MAXIMIZE); |
| 946 | 1455 | } |
| 947 | 1456 | |
| 948 | 1457 | pub fn minimize(self: *Window) void { |
| 1458 | if (self.liveEntry() == null) return; | |
| 949 | 1459 | _ = ShowWindow(self.hwnd, SW_MINIMIZE); |
| 950 | 1460 | } |
| 951 | 1461 | |
| @@ -958,7 +1468,8 @@ pub const Window = struct { | ||
| 958 | 1468 | } |
| 959 | 1469 | |
| 960 | 1470 | pub fn executeJavaScript(self: *Window, script: []const u8) !void { |
| 961 | const webview = self.webview orelse return error.WebView2NotInitialized; | |
| 1471 | const entry = self.liveEntry() orelse return error.NoWebView; | |
| 1472 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 962 | 1473 | var script_wide = try utf8ToUtf16Z(16384, script); |
| 963 | 1474 | const script_ptr: LPCWSTR = @ptrCast(&script_wide); |
| 964 | 1475 | |
| @@ -978,15 +1489,39 @@ pub const Window = struct { | ||
| 978 | 1489 | } |
| 979 | 1490 | |
| 980 | 1491 | pub fn injectScript(self: *Window, script: []const u8) !void { |
| 981 | const webview = self.webview orelse return error.WebView2NotInitialized; | |
| 982 | var script_wide = try utf8ToUtf16Z(16384, script); | |
| 983 | const script_ptr: LPCWSTR = @ptrCast(&script_wide); | |
| 984 | ||
| 985 | const hr = webview.lpVtbl.AddScriptToExecuteOnDocumentCreated(webview, script_ptr, null); | |
| 1492 | const entry = self.liveEntry() orelse return error.NoWebView; | |
| 1493 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 1494 | var script_wide = try desktop_script_encoding.encode(std.heap.c_allocator, script); | |
| 1495 | defer script_wide.deinit(std.heap.c_allocator); | |
| 1496 | ||
| 1497 | var install: ScriptInstallContext = .{}; | |
| 1498 | const handler = try std.heap.c_allocator.create(ScriptInstallCompletedHandler); | |
| 1499 | handler.* = .{ .lpVtbl = &ScriptInstallCompletedHandler.vtbl_instance, .ref_count = 1, .ctx = &install }; | |
| 1500 | const hr = webview.lpVtbl.AddScriptToExecuteOnDocumentCreated(webview, script_wide.ptr(), handler); | |
| 1501 | _ = ScriptInstallCompletedHandler.release(handler); | |
| 986 | 1502 | if (!succeeded(hr)) { |
| 987 | 1503 | std.debug.print("[WebView2] AddScriptToExecuteOnDocumentCreated failed: 0x{x}\n", .{@as(u32, @bitCast(hr))}); |
| 988 | 1504 | return error.ScriptInjectionFailed; |
| 989 | 1505 | } |
| 1506 | ||
| 1507 | // WebView2 installs this asynchronously. Navigate immediately after | |
| 1508 | // the API call and the first page can miss the bridge entirely. | |
| 1509 | var msg: MSG = undefined; | |
| 1510 | var quit_requested = false; | |
| 1511 | while (!install.done) { | |
| 1512 | if (PeekMessageW(&msg, null, 0, 0, PM_REMOVE) != 0) { | |
| 1513 | if (msg.message == WM_QUIT) { | |
| 1514 | quit_requested = true; | |
| 1515 | } else { | |
| 1516 | _ = TranslateMessage(&msg); | |
| 1517 | _ = DispatchMessageW(&msg); | |
| 1518 | } | |
| 1519 | } else { | |
| 1520 | Sleep(1); | |
| 1521 | } | |
| 1522 | } | |
| 1523 | if (quit_requested) PostQuitMessage(0); | |
| 1524 | if (!succeeded(install.result)) return error.ScriptInjectionFailed; | |
| 990 | 1525 | } |
| 991 | 1526 | |
| 992 | 1527 | pub fn enableGPUAcceleration(self: *Window, enable: bool) !void { |
| @@ -997,13 +1532,15 @@ pub const Window = struct { | ||
| 997 | 1532 | } |
| 998 | 1533 | |
| 999 | 1534 | pub fn openDevTools(self: *Window) void { |
| 1000 | const webview = self.webview orelse return; | |
| 1535 | const entry = self.liveEntry() orelse return; | |
| 1536 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 1001 | 1537 | _ = webview.lpVtbl.OpenDevToolsWindow(webview); |
| 1002 | 1538 | } |
| 1003 | 1539 | |
| 1004 | 1540 | // Resize the WebView2 control to match the current client area |
| 1005 | 1541 | fn resizeWebView(self: *Window) void { |
| 1006 | const controller = self.controller orelse return; | |
| 1542 | const entry = self.liveEntry() orelse return; | |
| 1543 | const controller: *ICoreWebView2Controller = @ptrFromInt(entry.context); | |
| 1007 | 1544 | var bounds: RECT = undefined; |
| 1008 | 1545 | _ = GetClientRect(self.hwnd, &bounds); |
| 1009 | 1546 | _ = controller.lpVtbl.put_Bounds(controller, bounds); |
| @@ -1012,19 +1549,57 @@ pub const Window = struct { | ||
| 1012 | 1549 | |
| 1013 | 1550 | fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c) LRESULT { |
| 1014 | 1551 | switch (msg) { |
| 1552 | WM_CRAFT_OPEN_WINDOW => { | |
| 1553 | processPendingOpens(); | |
| 1554 | return 0; | |
| 1555 | }, | |
| 1015 | 1556 | WM_SIZE => { |
| 1016 | // Resize the WebView2 control to fill the window | |
| 1017 | if (g_active_hwnd == hwnd) { | |
| 1018 | if (g_active_controller) |controller| { | |
| 1019 | var bounds: RECT = undefined; | |
| 1020 | _ = GetClientRect(hwnd, &bounds); | |
| 1021 | _ = controller.lpVtbl.put_Bounds(controller, bounds); | |
| 1557 | // A resize belongs to this HWND, not whichever window was created last. | |
| 1558 | if (desktop_windows.byWindow(@intFromPtr(hwnd))) |entry| { | |
| 1559 | const controller: *ICoreWebView2Controller = @ptrFromInt(entry.context); | |
| 1560 | var bounds: RECT = undefined; | |
| 1561 | _ = GetClientRect(hwnd, &bounds); | |
| 1562 | _ = controller.lpVtbl.put_Bounds(controller, bounds); | |
| 1563 | const minimized = wParam == 1; // SIZE_MINIMIZED | |
| 1564 | if (desktop_windows.observeState(entry.window, minimized, false)) |change| { | |
| 1565 | if (change.minimized) |now_minimized| | |
| 1566 | deliverWindowEvent(entry, if (now_minimized) "minimize" else "restore", ""); | |
| 1022 | 1567 | } |
| 1568 | if (!minimized) observeWindowGeometry(entry); | |
| 1569 | } | |
| 1570 | return 0; | |
| 1571 | }, | |
| 1572 | WM_MOVE => { | |
| 1573 | if (desktop_windows.byWindow(@intFromPtr(hwnd))) |entry| { | |
| 1574 | if (!entry.minimized) observeWindowGeometry(entry); | |
| 1023 | 1575 | } |
| 1024 | 1576 | return 0; |
| 1025 | 1577 | }, |
| 1026 | WM_DESTROY, WM_CLOSE => { | |
| 1027 | PostQuitMessage(0); | |
| 1578 | WM_ACTIVATE => { | |
| 1579 | if (desktop_windows.byWindow(@intFromPtr(hwnd))) |entry| | |
| 1580 | deliverWindowEvent(entry, if ((wParam & 0xffff) == 0) "blur" else "focus", ""); | |
| 1581 | }, | |
| 1582 | WM_CLOSE => { | |
| 1583 | _ = DestroyWindow(hwnd); | |
| 1584 | return 0; | |
| 1585 | }, | |
| 1586 | WM_DESTROY => { | |
| 1587 | if (desktop_windows.byWindow(@intFromPtr(hwnd))) |entry| deliverWindowEvent(entry, "close", ""); | |
| 1588 | if (desktop_windows.forgetWindow(@intFromPtr(hwnd))) |entry| { | |
| 1589 | discardPendingOpens(entry.id); | |
| 1590 | const controller: *ICoreWebView2Controller = @ptrFromInt(entry.context); | |
| 1591 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 1592 | window_registry.forgetOwner(entry.webview); | |
| 1593 | window_registry.forget(entry.window); | |
| 1594 | if (entry.message_token) |token| { | |
| 1595 | _ = webview.lpVtbl.remove_WebMessageReceived(webview, .{ .value = token }); | |
| 1596 | } | |
| 1597 | _ = controller.lpVtbl.Close(controller); | |
| 1598 | _ = webview.lpVtbl.Release(webview); | |
| 1599 | _ = controller.lpVtbl.Release(controller); | |
| 1600 | // Closing a child window must not end every window's event loop. | |
| 1601 | if (app_running and desktop_windows.count() == 0) PostQuitMessage(0); | |
| 1602 | } | |
| 1028 | 1603 | return 0; |
| 1029 | 1604 | }, |
| 1030 | 1605 | else => {}, |
| @@ -1035,6 +1610,7 @@ fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c | ||
| 1035 | 1610 | pub const App = struct { |
| 1036 | 1611 | pub fn run() !void { |
| 1037 | 1612 | app_running = true; |
| 1613 | defer app_running = false; | |
| 1038 | 1614 | var msg: MSG = undefined; |
| 1039 | 1615 | |
| 1040 | 1616 | while (GetMessageW(&msg, null, 0, 0) != 0) { |
| @@ -1049,10 +1625,15 @@ pub const App = struct { | ||
| 1049 | 1625 | } |
| 1050 | 1626 | }; |
| 1051 | 1627 | |
| 1052 | /// Evaluate JavaScript in the current webview (cross-platform bridge helper). | |
| 1053 | /// Uses the active window's WebView2 instance. | |
| 1628 | /// Evaluate a reply in the authenticated sender, if that webview is still live. | |
| 1629 | /// Native callers without a sender use the most recently created live window. | |
| 1054 | 1630 | pub fn evalJS(script: []const u8) !void { |
| 1055 | const webview = g_active_webview orelse return error.NoWebView; | |
| 1631 | var handles: [desktop_window_registry.capacity]usize = undefined; | |
| 1632 | const live = desktop_windows.liveWebviews(&handles); | |
| 1633 | const latest = desktop_windows.latest(); | |
| 1634 | const target = window_reply_target.select(live, window_context.currentWebView(), if (latest) |entry| entry.webview else null) orelse | |
| 1635 | return error.NoWebView; | |
| 1636 | const webview: *ICoreWebView2 = @ptrFromInt(target); | |
| 1056 | 1637 | var script_wide = try utf8ToUtf16Z(16384, script); |
| 1057 | 1638 | const handler = try std.heap.c_allocator.create(ExecuteScriptCompletedHandler); |
| 1058 | 1639 | handler.* = .{ .lpVtbl = &ExecuteScriptCompletedHandler.vtbl_instance, .ref_count = 1 }; |
| @@ -1074,6 +1655,14 @@ pub fn createWindow(title: []const u8, width: u32, height: u32, html: []const u8 | ||
| 1074 | 1655 | } |
| 1075 | 1656 | |
| 1076 | 1657 | pub fn createWindowWithURL(title: []const u8, width: u32, height: u32, url: []const u8, style: WindowStyle) !*anyopaque { |
| 1658 | return createStyledWindow(title, width, height, url, style, true); | |
| 1659 | } | |
| 1660 | ||
| 1661 | pub fn createWindowWithHTML(title: []const u8, width: u32, height: u32, html: []const u8, style: WindowStyle) !*anyopaque { | |
| 1662 | return createStyledWindow(title, width, height, html, style, false); | |
| 1663 | } | |
| 1664 | ||
| 1665 | fn createStyledWindow(title: []const u8, width: u32, height: u32, content: []const u8, style: WindowStyle, comptime is_url: bool) !*anyopaque { | |
| 1077 | 1666 | var window = try Window.create(.{ |
| 1078 | 1667 | .title = title, |
| 1079 | 1668 | .width = width, |
| @@ -1083,11 +1672,13 @@ pub fn createWindowWithURL(title: []const u8, width: u32, height: u32, url: []co | ||
| 1083 | 1672 | .resizable = style.resizable, |
| 1084 | 1673 | .frameless = style.frameless, |
| 1085 | 1674 | .transparent = style.transparent, |
| 1675 | .always_on_top = style.always_on_top, | |
| 1086 | 1676 | .fullscreen = style.fullscreen, |
| 1087 | 1677 | .dark_mode = style.dark_mode, |
| 1088 | 1678 | .dev_tools = style.dev_tools, |
| 1089 | 1679 | }); |
| 1090 | try window.loadURL(url); | |
| 1680 | errdefer window.close(); | |
| 1681 | if (is_url) try window.loadURL(content) else try window.loadHTML(content); | |
| 1091 | 1682 | window.show(); |
| 1092 | 1683 | return window.hwnd; |
| 1093 | 1684 | } |
| @@ -1158,6 +1749,6 @@ pub fn getClipboard(allocator: std.mem.Allocator) ![]u8 { | ||
| 1158 | 1749 | const text_len = std.mem.indexOfSentinel(u16, 0, text_wide); |
| 1159 | 1750 | |
| 1160 | 1751 | // Convert UTF-16 to UTF-8 |
| 1161 | const utf8_len = std.unicode.utf16leToUtf8AllocZ(allocator, text_wide[0..text_len]) catch return ""; | |
| 1752 | const utf8_len = std.unicode.utf16LeToUtf8AllocZ(allocator, text_wide[0..text_len]) catch return ""; | |
| 1162 | 1753 | return utf8_len; |
| 1163 | 1754 | } |
| @@ -222,6 +222,25 @@ const WEBVIEW_HOST = | ||
| 222 | 222 | \\globalThis.setTimeout = function () { return 0 }; |
| 223 | 223 | \\globalThis.clearTimeout = function () {}; |
| 224 | 224 | ; |
| 225 | ||
| 226 | test "the embedded bridge posts through WebView2 when WebKit is absent" { | |
| 227 | var fx = try Fixture.init(); | |
| 228 | defer fx.deinit(); | |
| 229 | const ctx = fx.ctx; | |
| 230 | ||
| 231 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 232 | _ = try ctx.evaluate( | |
| 233 | \\delete window.webkit; | |
| 234 | \\window.chrome = { webview: { postMessage: function (m) { posted.push(m) } } }; | |
| 235 | ); | |
| 236 | _ = try ctx.evaluate(BRIDGE); | |
| 237 | _ = try ctx.evaluate("window.craft.window.show()"); | |
| 238 | ||
| 239 | try testing.expectEqualStrings("1", try fx.text("String(posted.length)")); | |
| 240 | try testing.expectEqualStrings("window", try fx.text("posted[0].t")); | |
| 241 | try testing.expectEqualStrings("show", try fx.text("posted[0].a")); | |
| 242 | try testing.expectEqualStrings("true", try fx.text("posted[0].i > 0")); | |
| 243 | } | |
| 225 | 244 | // The timer stubs are load-bearing rather than cosmetic. `_req` arms a |
| 226 | 245 | // 30-second reaping timeout inside its Promise executor, so without a |
| 227 | 246 | // `setTimeout` the executor throws a ReferenceError and every request-shaped |
| @@ -65,6 +65,20 @@ test('npm publication follows artifact validation, and macOS downloads run on bo | ||
| 65 | 65 | expect(steps(release.jobs['attach-release-sbom'])).toContain('cmp "sbom/$document" "$VERIFY_DIR/$document"') |
| 66 | 66 | }) |
| 67 | 67 | |
| 68 | test('Windows release stages the pinned WebView2 loader before scanning and publishing', () => { | |
| 69 | const native = release.jobs.pantry.steps! | |
| 70 | const staged = native.findIndex(step => step.name === 'Stage Windows WebView2 loader') | |
| 71 | const scan = native.findIndex(step => step.name === 'Scan binaries prepared for publication') | |
| 72 | const publish = native.findIndex(step => step.name === 'Publish & Release') | |
| 73 | expect(staged).toBeGreaterThan(native.findIndex(step => step.name === 'Cross-compile additional targets (Linux)')) | |
| 74 | expect(scan).toBeGreaterThan(staged) | |
| 75 | expect(publish).toBeGreaterThan(scan) | |
| 76 | const command = native[staged]?.run ?? '' | |
| 77 | expect(command).toContain('1.0.4191.47') | |
| 78 | expect(command).toContain('f492bbf547d0da329553b6727435b677579b1e9f91cc9e4a1ad029366d5f23d0') | |
| 79 | expect(command).toContain('zig-out/cross/windows-x64/WebView2Loader.dll') | |
| 80 | }) | |
| 81 | ||
| 68 | 82 | test('a registry network failure remains a visible best-effort warning', () => { |
| 69 | 83 | const command = steps(release.jobs['notify-registry']) |
| 70 | 84 | expect(command).not.toBe('') |
| @@ -70,13 +70,20 @@ test('native inventory binds exact binaries and Zig runtime version', () => { | ||
| 70 | 70 | const root = fixture() |
| 71 | 71 | binary(root, 'bin/craft', '7f454c46') |
| 72 | 72 | binary(root, 'cross/windows-x64/craft.exe', '4d5a') |
| 73 | binary(root, 'cross/windows-x64/WebView2Loader.dll', '4d5a') | |
| 73 | 74 | const inventory = nativeInventory(root, 'linux', '1.2.3', '0.17.0-dev.1963') |
| 74 | expect(inventory.components.map(component => component.name)).toEqual(['craft-linux-x64', 'craft-windows-x64', 'zig-std']) | |
| 75 | expect(inventory.components.map(component => component.name)).toEqual(['craft-linux-x64', 'craft-windows-x64', 'WebView2Loader', 'zig-std']) | |
| 75 | 76 | expect(inventory.components[0]?.hashes).toEqual([{ alg: 'SHA-256', content: createHash('sha256').update(readFileSync(join(root, 'bin/craft'))).digest('hex') }]) |
| 77 | expect(inventory.components[2]?.version).toBe('1.0.4191.47') | |
| 78 | expect(inventory.components[2]?.type).toBe('library') | |
| 79 | expect(inventory.dependencies).toContainEqual({ ref: 'urn:craft:binary:craft-windows-x64', dependsOn: ['urn:craft:zig-std', 'urn:craft:binary:WebView2Loader'] }) | |
| 76 | 80 | writeFileSync(join(root, 'cross/windows-x64/craft.exe'), 'not a binary') |
| 77 | 81 | expect(() => nativeInventory(root, 'linux', '1.2.3', '0.17.0-dev.1963')).toThrow('missing or empty') |
| 78 | 82 | binary(root, 'cross/windows-x64/craft.exe', '7f454c46') |
| 79 | 83 | expect(() => nativeInventory(root, 'linux', '1.2.3', '0.17.0-dev.1963')).toThrow('unexpected executable format') |
| 84 | binary(root, 'cross/windows-x64/craft.exe', '4d5a') | |
| 85 | writeFileSync(join(root, 'cross/windows-x64/WebView2Loader.dll'), 'not a binary') | |
| 86 | expect(() => nativeInventory(root, 'linux', '1.2.3', '0.17.0-dev.1963')).toThrow('WebView2Loader: missing or empty') | |
| 80 | 87 | }) |
| 81 | 88 | |
| 82 | 89 | test('publisher uses only hash-verified archives and skips an existing version', () => { |
| @@ -7,6 +7,7 @@ import { enforceScanPolicy, scanSbom } from './scan-sbom' | ||
| 7 | 7 | type Archive = { name: string, version: string, file: string, sha256: string } |
| 8 | 8 | type Component = { name: string, version?: string, 'bom-ref'?: string, [key: string]: unknown } |
| 9 | 9 | type Inventory = { bomFormat: string, components: Component[], [key: string]: unknown } |
| 10 | type NativeArtifact = { path: string, name: string, magic: string[], type: 'application' | 'library', version?: string } | |
| 10 | 11 | |
| 11 | 12 | const root = resolve(import.meta.dir, '..') |
| 12 | 13 | const sha256 = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex') |
| @@ -108,18 +109,21 @@ function scanNpm(directory: string, output: string, syft: string, grype: string) | ||
| 108 | 109 | finally { rmSync(consumer, { recursive: true, force: true }) } |
| 109 | 110 | } |
| 110 | 111 | |
| 112 | function nativeArtifacts(platform: 'macos' | 'linux'): NativeArtifact[] { | |
| 113 | return platform === 'macos' | |
| 114 | ? [{ path: 'bin/craft', name: 'craft-darwin-arm64', magic: ['cffaedfe', 'feedfacf'], type: 'application' }, { path: 'cross/darwin-x64/craft', name: 'craft-darwin-x64', magic: ['cffaedfe', 'feedfacf'], type: 'application' }] | |
| 115 | : [{ path: 'bin/craft', name: 'craft-linux-x64', magic: ['7f454c46'], type: 'application' }, { path: 'cross/windows-x64/craft.exe', name: 'craft-windows-x64', magic: ['4d5a'], type: 'application' }, { path: 'cross/windows-x64/WebView2Loader.dll', name: 'WebView2Loader', magic: ['4d5a'], type: 'library', version: '1.0.4191.47' }] | |
| 116 | } | |
| 117 | ||
| 111 | 118 | export function nativeInventory(binaryRoot: string, platform: 'macos' | 'linux', version: string, zigVersion: string, discovered: Component[][] = []): Inventory { |
| 112 | const entries = platform === 'macos' | |
| 113 | ? [{ path: 'bin/craft', name: 'craft-darwin-arm64', magic: ['cffaedfe', 'feedfacf'] }, { path: 'cross/darwin-x64/craft', name: 'craft-darwin-x64', magic: ['cffaedfe', 'feedfacf'] }] | |
| 114 | : [{ path: 'bin/craft', name: 'craft-linux-x64', magic: ['7f454c46'] }, { path: 'cross/windows-x64/craft.exe', name: 'craft-windows-x64', magic: ['4d5a'] }] | |
| 115 | const binaries: Component[] = entries.map(({ path, name, magic }) => { | |
| 119 | const artifacts: Component[] = nativeArtifacts(platform).map(({ path, name, magic, type, version: artifactVersion }) => { | |
| 116 | 120 | const absolute = join(binaryRoot, path) |
| 117 | 121 | if (!existsSync(absolute) || !statSync(absolute).isFile() || statSync(absolute).size < 1024) |
| 118 | 122 | throw new Error(`${name}: missing or empty release binary`) |
| 119 | 123 | const bytes = readFileSync(absolute) |
| 120 | 124 | if (!magic.some(prefix => bytes.subarray(0, prefix.length / 2).toString('hex') === prefix)) |
| 121 | 125 | throw new Error(`${name}: unexpected executable format`) |
| 122 | return { 'bom-ref': `urn:craft:binary:${name}`, type: 'application', name, version, | |
| 126 | return { 'bom-ref': `urn:craft:binary:${name}`, type, name, version: artifactVersion ?? version, | |
| 123 | 127 | hashes: [{ alg: 'SHA-256', content: createHash('sha256').update(bytes).digest('hex') }], |
| 124 | 128 | properties: [{ name: 'craft:artifact-path', value: path }], |
| 125 | 129 | } |
| @@ -128,8 +132,11 @@ export function nativeInventory(binaryRoot: string, platform: 'macos' | 'linux', | ||
| 128 | 132 | return { |
| 129 | 133 | bomFormat: 'CycloneDX', specVersion: '1.6', version: 1, |
| 130 | 134 | metadata: { component: { type: 'application', name: 'craft-native-release', version } }, |
| 131 | components: [...binaries, zig, ...discovered.flat()], | |
| 132 | dependencies: binaries.map(binary => ({ ref: binary['bom-ref'], dependsOn: [zig['bom-ref']] })), | |
| 135 | components: [...artifacts, zig, ...discovered.flat()], | |
| 136 | dependencies: artifacts.filter(artifact => artifact.type === 'application').map(artifact => ({ | |
| 137 | ref: artifact['bom-ref'], | |
| 138 | dependsOn: artifact.name === 'craft-windows-x64' ? [zig['bom-ref'], 'urn:craft:binary:WebView2Loader'] : [zig['bom-ref']], | |
| 139 | })), | |
| 133 | 140 | } |
| 134 | 141 | } |
| 135 | 142 | |
| @@ -137,7 +144,7 @@ function scanNative(binaryRoot: string, platform: 'macos' | 'linux', output: str | ||
| 137 | 144 | mkdirSync(output, { recursive: true }) |
| 138 | 145 | const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string } |
| 139 | 146 | const zigVersion = Bun.JSONC.parse(readFileSync(join(root, 'pantry.jsonc'), 'utf8')).dependencies['ziglang.org'] as string |
| 140 | const paths = platform === 'macos' ? ['bin/craft', 'cross/darwin-x64/craft'] : ['bin/craft', 'cross/windows-x64/craft.exe'] | |
| 147 | const paths = nativeArtifacts(platform).map(artifact => artifact.path) | |
| 141 | 148 | const discovered = paths.map((path, index) => { |
| 142 | 149 | const report = join(output, `native-${index}.cyclonedx.json`) |
| 143 | 150 | run([syft, join(binaryRoot, path), '-o', `cyclonedx-json=${report}`]) |
| @@ -152,7 +159,7 @@ function scanNative(binaryRoot: string, platform: 'macos' | 'linux', output: str | ||
| 152 | 159 | const summary = scanSbom(sbom, join(output, 'grype'), grype) |
| 153 | 160 | enforceScanPolicy(summary.counts, true) |
| 154 | 161 | const after = nativeInventory(binaryRoot, platform, version, zigVersion) |
| 155 | for (const [index, binary] of after.components.slice(0, 2).entries()) { | |
| 162 | for (const [index, binary] of after.components.slice(0, paths.length).entries()) { | |
| 156 | 163 | if (JSON.stringify(binary.hashes) !== JSON.stringify(inventory.components[index]!.hashes)) |
| 157 | 164 | throw new Error(`${binary.name}: release binary changed after scanning`) |
| 158 | 165 | } |
| @@ -0,0 +1,201 @@ | ||
| 1 | /** Exercise the shipped Linux or Windows binary's page-to-native multi-window bridge. */ | |
| 2 | import { spawn } from 'node:child_process' | |
| 3 | import { createServer } from 'node:http' | |
| 4 | import type { AddressInfo } from 'node:net' | |
| 5 | import { platform } from 'node:os' | |
| 6 | ||
| 7 | const binary = process.argv[2] | |
| 8 | if (!binary) throw new Error('usage: bun scripts/verify-desktop-multi-window.ts <craft-binary>') | |
| 9 | const isWindows = platform() === 'win32' | |
| 10 | const platformName = isWindows ? 'Windows' : 'Linux' | |
| 11 | ||
| 12 | const mainPage = `<!doctype html><script> | |
| 13 | (async () => { | |
| 14 | const report = (step) => fetch('/report?step=' + encodeURIComponent(step), { method: 'POST' }) | |
| 15 | const waitFor = async (step) => { | |
| 16 | for (let attempt = 0; attempt < 200; attempt++) { | |
| 17 | const status = await (await fetch('/status')).json() | |
| 18 | if (status.steps.includes(step)) return | |
| 19 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 20 | } | |
| 21 | throw new Error('timed out waiting for ' + step) | |
| 22 | } | |
| 23 | const child = (cycle) => window.craft.window.open({ | |
| 24 | name: 'settings', title: 'Child ' + cycle, | |
| 25 | url: location.origin + '/child?cycle=' + cycle, | |
| 26 | }) | |
| 27 | try { | |
| 28 | if (!window.craft || !window.craft.window || !window.craft.window.open) | |
| 29 | throw new Error('document-start Craft bridge missing') | |
| 30 | await report('main') | |
| 31 | const first = await child(1) | |
| 32 | if (first.name !== 'settings') throw new Error('wrong child handle') | |
| 33 | await waitFor('child-1') | |
| 34 | const bounds = await window.craft.window._call('getBounds', {}, 'settings') | |
| 35 | if (!(bounds.width > 0 && bounds.height > 0)) throw new Error('child bounds not routed to creator') | |
| 36 | await window.craft.window._call('setSize', { width: 640, height: 480 }, 'settings') | |
| 37 | let resized = false | |
| 38 | for (let attempt = 0; attempt < 40; attempt++) { | |
| 39 | const size = await window.craft.window._call('getSize', {}, 'settings') | |
| 40 | if (Math.abs(size.width - 640) <= 40 && Math.abs(size.height - 480) <= 40) { | |
| 41 | resized = true | |
| 42 | break | |
| 43 | } | |
| 44 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 45 | } | |
| 46 | if (!resized) throw new Error('live child resize did not change its size') | |
| 47 | const title = await window.craft.window._call('getTitle', {}, 'settings') | |
| 48 | if (title !== 'Child 1') throw new Error('child title not routed to creator: ' + title) | |
| 49 | ||
| 50 | const [queuedA, queuedB] = await Promise.all([ | |
| 51 | window.craft.window.open({ name: 'queue-a', title: 'Child queue-a', url: location.origin + '/child?cycle=queue-a' }), | |
| 52 | window.craft.window.open({ name: 'queue-b', title: 'Child queue-b', url: location.origin + '/child?cycle=queue-b' }), | |
| 53 | ]) | |
| 54 | if (queuedA.name !== 'queue-a' || queuedB.name !== 'queue-b') | |
| 55 | throw new Error('concurrent child opens returned the wrong handles') | |
| 56 | await waitFor('child-queue-a') | |
| 57 | await waitFor('child-queue-b') | |
| 58 | const queueClosed = new Set() | |
| 59 | const queueClosedDone = new Promise((resolve, reject) => { | |
| 60 | const timeout = setTimeout(() => reject(new Error('queued child close events missing')), 20000) | |
| 61 | window.addEventListener('craft:window:close', (event) => { | |
| 62 | if (event.detail.windowId !== 'queue-a' && event.detail.windowId !== 'queue-b') return | |
| 63 | queueClosed.add(event.detail.windowId) | |
| 64 | if (queueClosed.size !== 2) return | |
| 65 | clearTimeout(timeout) | |
| 66 | resolve() | |
| 67 | }) | |
| 68 | }) | |
| 69 | await window.craft.window._call('close', {}, 'queue-a') | |
| 70 | await window.craft.window._call('close', {}, 'queue-b') | |
| 71 | await queueClosedDone | |
| 72 | for (const name of ['queue-a', 'queue-b']) { | |
| 73 | let rejected = false | |
| 74 | try { await window.craft.window._call('getBounds', {}, name) } | |
| 75 | catch (_) { rejected = true } | |
| 76 | if (!rejected) throw new Error('closed queued child still answered a read: ' + name) | |
| 77 | } | |
| 78 | await report('queued-closed') | |
| 79 | ||
| 80 | const closed = new Promise((resolve, reject) => { | |
| 81 | const timeout = setTimeout(() => reject(new Error('child close event missing')), 20000) | |
| 82 | window.addEventListener('craft:window:close', (event) => { | |
| 83 | if (event.detail.windowId !== 'settings') return | |
| 84 | clearTimeout(timeout) | |
| 85 | resolve() | |
| 86 | }) | |
| 87 | }) | |
| 88 | await window.craft.window._call('close', {}, 'settings') | |
| 89 | await closed | |
| 90 | await report('closed') | |
| 91 | const survivingMain = await window.craft.window._call('getBounds', {}, 'main') | |
| 92 | if (!(survivingMain.width > 0 && survivingMain.height > 0)) | |
| 93 | throw new Error('closing child invalidated main controller') | |
| 94 | let staleRejected = false | |
| 95 | try { await window.craft.window._call('getBounds', {}, 'settings') } | |
| 96 | catch (_) { staleRejected = true } | |
| 97 | if (!staleRejected) throw new Error('destroyed child still answered a read') | |
| 98 | ||
| 99 | const second = await child(2) | |
| 100 | if (second.name !== 'settings') throw new Error('reopened child has wrong handle') | |
| 101 | await waitFor('child-2') | |
| 102 | const reopenedTitle = await window.craft.window._call('getTitle', {}, 'settings') | |
| 103 | if (reopenedTitle !== 'Child 2') throw new Error('reopened child retained stale title') | |
| 104 | await report('done') | |
| 105 | } | |
| 106 | catch (error) { | |
| 107 | await fetch('/report?error=' + encodeURIComponent(String(error)), { method: 'POST' }) | |
| 108 | } | |
| 109 | })() | |
| 110 | </script>` | |
| 111 | ||
| 112 | const childPage = `<!doctype html><script> | |
| 113 | (async () => { | |
| 114 | try { | |
| 115 | const cycle = new URLSearchParams(location.search).get('cycle') | |
| 116 | const bounds = await window.craft.window._call('getBounds', {}, 'main') | |
| 117 | const title = await window.craft.window._call('getTitle', {}, 'main') | |
| 118 | if (!(bounds.width > 0 && bounds.height > 0) || title !== 'Child ' + cycle) | |
| 119 | throw new Error('child page read targeted another webview') | |
| 120 | await fetch('/report?step=child-' + cycle, { method: 'POST' }) | |
| 121 | } | |
| 122 | catch (error) { | |
| 123 | await fetch('/report?error=' + encodeURIComponent(String(error)), { method: 'POST' }) | |
| 124 | } | |
| 125 | })() | |
| 126 | </script>` | |
| 127 | ||
| 128 | const steps = new Set<string>() | |
| 129 | const requests: string[] = [] | |
| 130 | let resolveDone!: () => void | |
| 131 | let rejectDone!: (error: Error) => void | |
| 132 | const done = new Promise<void>((resolve, reject) => { | |
| 133 | resolveDone = resolve | |
| 134 | rejectDone = reject | |
| 135 | }) | |
| 136 | const server = createServer((request, response) => { | |
| 137 | const url = new URL(request.url ?? '/', 'http://127.0.0.1') | |
| 138 | requests.push(url.pathname) | |
| 139 | if (url.pathname === '/main' || url.pathname === '/child') { | |
| 140 | response.setHeader('content-type', 'text/html; charset=utf-8') | |
| 141 | response.end(url.pathname === '/main' ? mainPage : childPage) | |
| 142 | } | |
| 143 | else if (url.pathname === '/status') { | |
| 144 | response.setHeader('content-type', 'application/json') | |
| 145 | response.end(JSON.stringify({ steps: [...steps] })) | |
| 146 | } | |
| 147 | else if (url.pathname === '/report') { | |
| 148 | const error = url.searchParams.get('error') | |
| 149 | const step = url.searchParams.get('step') | |
| 150 | if (error) rejectDone(new Error(error)) | |
| 151 | if (step) steps.add(step) | |
| 152 | response.end('ok') | |
| 153 | if (step === 'done') { | |
| 154 | const required = ['main', 'child-1', 'child-queue-a', 'child-queue-b', 'queued-closed', 'closed', 'child-2', 'done'] | |
| 155 | if (required.every(name => steps.has(name))) resolveDone() | |
| 156 | else rejectDone(new Error(`incomplete smoke steps: ${[...steps].join(', ')}`)) | |
| 157 | } | |
| 158 | } | |
| 159 | else { | |
| 160 | response.writeHead(404).end() | |
| 161 | } | |
| 162 | }) | |
| 163 | ||
| 164 | await new Promise<void>((resolve, reject) => { | |
| 165 | server.once('error', reject) | |
| 166 | server.listen(0, '127.0.0.1', resolve) | |
| 167 | }) | |
| 168 | const port = (server.address() as AddressInfo).port | |
| 169 | const startedAt = Date.now() | |
| 170 | const child = spawn(isWindows ? binary : 'timeout', isWindows | |
| 171 | ? ['--url', `http://127.0.0.1:${port}/main`] | |
| 172 | : ['110s', 'xvfb-run', '-a', binary, '--url', `http://127.0.0.1:${port}/main`], { | |
| 173 | detached: !isWindows, | |
| 174 | stdio: ['ignore', 'pipe', 'pipe'], | |
| 175 | }) | |
| 176 | let output = '' | |
| 177 | for (const stream of [child.stdout, child.stderr]) { | |
| 178 | stream.on('data', chunk => { output = (output + chunk.toString()).slice(-16_000) }) | |
| 179 | } | |
| 180 | child.once('error', error => rejectDone(error)) | |
| 181 | child.once('exit', (code, signal) => rejectDone(new Error(`Craft exited before smoke completed (${code ?? signal}, ${Date.now() - startedAt} ms)`))) | |
| 182 | const timer = setTimeout(() => rejectDone(new Error(`${platformName} multi-window smoke timed out`)), 100_000) | |
| 183 | ||
| 184 | try { | |
| 185 | await done | |
| 186 | console.log(`${platformName} multi-window smoke passed: ${[...steps].join(', ')}`) | |
| 187 | } | |
| 188 | catch (error) { | |
| 189 | throw new Error(`${String(error)}\nElapsed: ${Date.now() - startedAt} ms\nHTTP requests: ${requests.join(', ')}\nCraft output:\n${output}`) | |
| 190 | } | |
| 191 | finally { | |
| 192 | clearTimeout(timer) | |
| 193 | if (child.pid) { | |
| 194 | try { | |
| 195 | if (isWindows) child.kill() | |
| 196 | else process.kill(-child.pid, 'SIGTERM') | |
| 197 | } | |
| 198 | catch { /* The process group may have already exited. */ } | |
| 199 | } | |
| 200 | await new Promise<void>(resolve => server.close(() => resolve())) | |
| 201 | } | |
| @@ -2,7 +2,7 @@ import { afterEach, expect, test } from 'bun:test' | ||
| 2 | 2 | import { chmodSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | import { pinnedZigVersion, resolveZig, runZig } from './zig' | |
| 5 | import { pinnedPantryZigPath, pinnedZigVersion, resolveZig, runZig } from './zig' | |
| 6 | 6 | |
| 7 | 7 | const roots: string[] = [] |
| 8 | 8 | afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) |
| @@ -28,6 +28,25 @@ test('accepts only the locked snapshot by default, even when PATH shadows it', ( | ||
| 28 | 28 | expect(resolveZig(f.root, { ...f.env, CRAFT_ZIG: pinned.path })).toEqual({ path: pinned.path, version: '0.17.0-dev.1963+e00c6c439', overridden: false }) |
| 29 | 29 | }) |
| 30 | 30 | |
| 31 | test('Windows uses the pinned Pantry installation instead of its .bin shim', () => { | |
| 32 | const f = fixture() | |
| 33 | const version = pinnedZigVersion(f.root) | |
| 34 | const packageDir = join(f.root, 'pantry/ziglang-org/0.17.0-dev.1963_e00c6c439') | |
| 35 | const installed = join(packageDir, 'zig.exe') | |
| 36 | expect(pinnedPantryZigPath(f.root, version, f.env, 'win32')).toBeNull() | |
| 37 | mkdirSync(packageDir, { recursive: true }) | |
| 38 | writeFileSync(installed, '') | |
| 39 | expect(pinnedPantryZigPath(f.root, version, f.env, 'win32')).toBe(installed) | |
| 40 | const mirrored = join(packageDir, 'bin/zig.exe') | |
| 41 | mkdirSync(join(packageDir, 'bin')) | |
| 42 | writeFileSync(mirrored, '') | |
| 43 | expect(pinnedPantryZigPath(f.root, version, f.env, 'win32')).toBe(installed) | |
| 44 | rmSync(installed) | |
| 45 | expect(pinnedPantryZigPath(f.root, version, f.env, 'win32')).toBe(mirrored) | |
| 46 | expect(pinnedPantryZigPath(f.root, version, { ...f.env, CRAFT_ZIG: f.path }, 'win32')).toBeNull() | |
| 47 | expect(pinnedPantryZigPath(f.root, version, f.env, 'darwin')).toBeNull() | |
| 48 | }) | |
| 49 | ||
| 31 | 50 | test('requires an explicit opt-in for unpinned snapshots', () => { |
| 32 | 51 | const f = fixture('0.17.0-dev.2163+89ff10d56') |
| 33 | 52 | expect(() => resolveZig(f.root, { ...f.env, CRAFT_ALLOW_UNPINNED_ZIG: 'true' })).toThrow('version mismatch') |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { readFileSync } from 'node:fs' | |
| 1 | import { existsSync, readFileSync } from 'node:fs' | |
| 2 | 2 | import { join, resolve } from 'node:path' |
| 3 | 3 | |
| 4 | 4 | export function pinnedZigVersion(root: string): string { |
| @@ -9,10 +9,24 @@ export function pinnedZigVersion(root: string): string { | ||
| 9 | 9 | return version.replace('_', '+') |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | export function resolveZig(root: string, env: NodeJS.ProcessEnv = process.env): { path: string, version: string, overridden: boolean } { | |
| 12 | export function pinnedPantryZigPath(root: string, expected: string, env: NodeJS.ProcessEnv, hostPlatform: string): string | null { | |
| 13 | if (hostPlatform !== 'win32' || env.CRAFT_ZIG) return null | |
| 14 | const packageDir = join(root, 'pantry', 'ziglang-org', expected.replace('+', '_')) | |
| 15 | // The official Windows ZIP has zig.exe at its root; the Pantry registry | |
| 16 | // mirror can package it under bin/. Both keep Zig's lib directory nearby. | |
| 17 | for (const installed of [join(packageDir, 'zig.exe'), join(packageDir, 'bin', 'zig.exe')]) { | |
| 18 | if (existsSync(installed)) return installed | |
| 19 | } | |
| 20 | return null | |
| 21 | } | |
| 22 | ||
| 23 | export function resolveZig(root: string, env: NodeJS.ProcessEnv = process.env, hostPlatform = process.platform): { path: string, version: string, overridden: boolean } { | |
| 13 | 24 | const expected = pinnedZigVersion(root) |
| 14 | 25 | const executable = env.CRAFT_ZIG || 'zig' |
| 15 | const path = Bun.which(executable, { PATH: env.PATH, cwd: root }) | |
| 26 | // Pantry's Windows .bin/zig.exe shim reports a version, but Zig resolves | |
| 27 | // its lib directory relative to argv[0] and cannot build through the shim. | |
| 28 | // Invoke the locked package's actual binary whenever Pantry installed it. | |
| 29 | const path = pinnedPantryZigPath(root, expected, env, hostPlatform) ?? Bun.which(executable, { PATH: env.PATH, cwd: root }) | |
| 16 | 30 | const remedy = 'Activate the pinned Pantry toolchain, or set CRAFT_ZIG to its executable. To intentionally test a different snapshot, also set CRAFT_ALLOW_UNPINNED_ZIG=1.' |
| 17 | 31 | if (!path) |
| 18 | 32 | throw new Error(`Zig ${expected} was not found (${executable}). ${remedy}`) |