ReviewOS

also looking at this

craft-native/craft

feat: add Linux and Windows named-window routing

#325
Merged glennmichael123 wants to merge codex/multi-window-linux-windows into main
29 files +2250 -200
.github/workflows/ci.ymlmodified+62-2
Changes to .github/workflows/ci.yml
@@ -51,7 +51,7 @@ jobs:
5151 if: runner.os == 'Linux'
5252 run: |
5353 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
5555
5656 - name: First-party Zig dependencies
5757 uses: ./.github/actions/first-party-zig-deps
@@ -79,6 +79,66 @@ jobs:
7979 ls -lh packages/zig/zig-out/bin/
8080 size packages/zig/zig-out/bin/craft
8181
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
82142 typescript-sdk:
83143 runs-on: ubuntu-latest
84144
@@ -375,7 +435,7 @@ jobs:
375435 run: bun run verify
376436
377437 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]
379439 runs-on: ubuntu-latest
380440
381441 steps:
.github/workflows/release.ymlmodified+17-0
Changes to .github/workflows/release.yml
@@ -179,6 +179,23 @@ jobs:
179179 rm -rf zig-out/bin
180180 zig build -Doptimize=ReleaseSafe -Dversion="$VERSION"
181181
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
182199 # ── macOS Code Signing & Notarization ──────────────────────────
183200 # The signing credentials live encrypted in `.env.production`, not as
184201 # seven repository secrets.
docs/PLATFORMS.mdmodified+11-3
Changes to docs/PLATFORMS.md
@@ -155,7 +155,11 @@ Currently implemented foundations (experimental):
155155- �� Notifications, tray, clipboard, and dialogs require further integration testing
156156- ✅ Hot reload via WebSocket
157157- ✅ 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
161The Xvfb smoke covers named create, addressed replies, close, and reopen; it
162does not establish full desktop bridge parity for a particular application.
159163
160164### Known Limitations
161165
@@ -247,11 +251,15 @@ Currently implemented foundations (experimental):
247251- �� Notifications, tray, clipboard, and dialogs require further integration testing
248252- ✅ Hot reload via WebSocket
249253- ✅ 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
257The native WebView2 smoke also exercises concurrent child creation and cleanup;
258it does not establish full desktop bridge parity for a particular application.
251259
252260### Known Limitations
253261
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
255263- **Transparency**: Requires DWM (Desktop Window Manager) enabled
256264- **System Tray**: Icon must be .ico format
257265- **UWP Apps**: Limited support for UWP features
docs/architecture/multi-window-ownership.mdmodified+41-21
Changes to docs/architecture/multi-window-ownership.md
@@ -1,7 +1,7 @@
11# Multi-window ownership
22
33This document records Craft's current multi-window ownership contract. It is
4both a map of the implemented macOS behavior and a boundary around the choices
4both a map of the implemented desktop behavior and a boundary around the choices
55that are still open. A process-global Zig object is not automatically a
66single-window bug: the important question is whether it is a shared service or
77whether it stores a window-specific target.
@@ -10,17 +10,35 @@ whether it stores a window-specific target.
1010
1111| Area | macOS | Linux | Windows |
1212| --- | --- | --- | --- |
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 |
1919| Modal and parent relationships | Unspecified | Unspecified | Unspecified |
2020
21Issue #67 therefore remains open. Its macOS core is substantially implemented,
22but closing it would imply cross-platform and modal/parent semantics that Craft
23does not yet provide.
21The runtime-creation, stable-handle, reply-routing, and event-scoping contract
22from issue #67 is covered here. Modal/parent semantics remain separate future
23decisions. The Windows release archive stages an app-local WebView2 loader;
24users still need the Microsoft WebView2 Runtime.
25
26Linux and Windows keep their live native window/webview pairs in
27`desktop_window_registry.zig`. Page messages are authenticated by the sending
28WebKitGTK view or the per-WebView2 event subscription; names select a target
29only after that native source is known.
30Replies go back to the requesting view, and lifecycle events go to the changed
31window plus its named handle's creator. Linux must register its `GtkApplication`
32before constructing the first `GtkApplicationWindow`, because the CLI creates
33that window before entering `g_application_run()`. On Windows, the controller
34passed to the asynchronous WebView2 completion callback is borrowed. Each live
35window retains its own controller reference until native destruction; otherwise
36WebView2 can close before the page bridge registers or navigation begins.
37WebView2 also forbids entering a nested message loop from its message callback.
38Windows therefore queues page-requested child creation onto the UI message
39queue, preserving the authenticated owner and reply ID until that callback
40returns. Concurrent opens are drained serially and abandoned if their creator
41window is destroyed before dispatch.
2442
2543## Per-window state
2644
@@ -57,9 +75,10 @@ back to whichever window was created most recently:
5775- The local scroll monitor is installed once, but reads the event's `NSWindow`,
5876 advances only that window's accumulator and emits only to its webview.
5977
60`close()` preserves these resources and the SDK's native-event subscriptions
61so the same page can reopen with its DOM and JavaScript state intact, including
62when the primary window is reopened by the Dock rather than `createWindow()`.
78On macOS, `close()` preserves these resources and the SDK's native-event
79subscriptions so the same page can reopen with its DOM and JavaScript state
80intact, including when the primary window is reopened by the Dock rather than
81`createWindow()`.
6382`destroy()` removes the named registry entry, Native UI graph, gesture and
6483material slots, recovery state, event ownership and retained AppKit objects
6584before 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:
118137 application-level asynchronous source above.
1191383. Decide whether Touch Bar should stay primary-window scoped or follow the
120139 key window with separately owned item definitions and callbacks.
1214. 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.
1255. 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.
1404. 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.
1435. 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.
128146
129147## Review checklist
130148
@@ -134,6 +152,8 @@ For every new bridge or callback that touches a window, verify:
134152- An explicit window ID is resolved through the registry and unknown IDs fail.
135153- An asynchronous reply retains both its target and its requesting webview.
136154- 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.
138158- A process-global event sink is documented as primary, broadcast or
139159 subscriber-owned rather than inheriting accidental last-window behavior.
docs/features/window-management.mdmodified+14-8
Changes to docs/features/window-management.md
@@ -29,8 +29,11 @@ await show(html, {
2929### Runtime Window Creation
3030
3131The `craft-native` SDK returns a typed `Window` handle. Runtime-created
32windows are currently implemented by the macOS host and must provide either
33`html` or `url` content.
32windows are available on macOS, Linux, and Windows and must provide either
33`html` or `url` content. The common create, close, title, geometry, and event
34routes are cross-platform; some advanced window controls remain macOS-only.
35On Linux, the window manager may ignore position requests, especially before
36the window is shown.
3437
3538```typescript
3639import { createWindow } from 'craft-native'
@@ -477,12 +480,15 @@ that created it sees the same transitions through the `inspector` handle.
477480Other open windows do not receive them. Call the unsubscribe function returned
478481by `on`, such as `stopWatching()` above, when the listener is no longer needed.
479482
480Closing a window keeps its native page alive so the macOS reopen lifecycle can
481restore its DOM and JavaScript state. Call `destroy()` on a runtime-created
482handle to permanently release its native window, webview, recovery state and
483event subscriptions; opening the same ID afterwards creates a fresh native
484page behind the same stable SDK handle. The unnamed primary window cannot be
485force-destroyed through this API.
483On macOS, closing a window keeps its native page alive so reopening can
484restore its DOM and JavaScript state. On Linux and Windows, closing releases
485the native window and webview; reopening the same ID creates a fresh page
486behind the same stable SDK handle. `destroy()` releases a named window's native
487resources and detaches its SDK DOM listeners. A later `createWindow()` with the
488same ID reuses the typed handle and reattaches those listeners. The unnamed
489primary window cannot be force-destroyed through this API. CI exercises Linux
490create/close/reopen under Xvfb and the equivalent Windows WebView2 path,
491including concurrent child creation and stale-handle cleanup.
486492Modal/parent semantics are not part of the runtime-created-window contract yet.
487493See [Multi-window ownership](../architecture/multi-window-ownership.md) for the
488494implemented routing guarantees, application-level event sinks and remaining
docs/usage.mdmodified+7-6
Changes to docs/usage.md
@@ -330,15 +330,16 @@ await settingsWindow.focus()
330330```
331331
332332Calling `createWindow` again with the same `id` brings the retained native
333window forward and returns the same typed handle. Runtime creation currently
334uses the macOS host. `close()` retains that page for a later reopen, while
335`destroy()` permanently releases a runtime-created window; the unnamed primary
336window is not destroyable through this API. Modal and parent-window
337relationships are not supported.
333window forward and returns the same typed handle on macOS, Linux, and Windows.
334On macOS, `close()` retains the page for a later reopen. On Linux and Windows,
335closing releases the native page; reopening the same ID creates a fresh one
336behind the stable typed handle. `destroy()` permanently releases a
337runtime-created window; the unnamed primary window is not destroyable through
338this API. Modal and parent-window relationships are not supported.
338339
339340The [multi-window ownership contract](architecture/multi-window-ownership.md)
340341tracks which state is window-scoped, which events intentionally target the
341primary page and what remains before the feature is cross-platform.
342primary page, and which advanced behaviors remain outside the contract.
342343
343344### Window Lifecycle Events
344345
packages/typescript/src/__tests__/bridge-request-id.test.tsmodified+25-6
Changes to packages/typescript/src/__tests__/bridge-request-id.test.ts
@@ -37,20 +37,21 @@ interface Harness {
3737 * they can simply be passed in as parameters — no DOM, no jsdom, and nothing
3838 * shared between tests.
3939 */
40function loadBridge(): Harness {
40function loadBridge(platform: 'webkit' | 'webview2' = 'webkit'): Harness {
4141 const sent: Envelope[] = []
4242 const errors: unknown[][] = []
4343 const win: any = {
44 webkit: {
45 messageHandlers: {
46 craft: { postMessage: (m: Envelope) => { sent.push(m) } },
47 },
48 },
4944 addEventListener: () => {},
5045 removeEventListener: () => {},
5146 // The script fires `craft:ready` on load.
5247 dispatchEvent: () => true,
5348 }
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 }
5455 const doc: any = { readyState: 'complete', addEventListener: () => {} }
5556 const console_: any = { ...console, error: (...a: unknown[]) => { errors.push(a) }, warn: () => {} }
5657
@@ -84,6 +85,24 @@ function settled(p: Promise<unknown>): Promise<unknown> {
8485}
8586
8687describe('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
87106 it('gives each caller its own answer when two bridges share an action name', async () => {
88107 // `get` is served by both keychain and tags. Before request ids, both
89108 // callers queued under the string "get" and were matched by arrival order,
packages/typescript/src/__tests__/window-routing.test.tsmodified+18-0
Changes to packages/typescript/src/__tests__/window-routing.test.ts
@@ -140,6 +140,24 @@ describe('typed window-handle routing', () => {
140140 expect(created.id).toBe(id)
141141 })
142142
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
143161 it('refuses the local main alias before opening a child window', async () => {
144162 await expect(windowManager.create({ id: 'main', html: '<h1>Orphan</h1>' }))
145163 .rejects.toThrow('reserved for the current window')
packages/typescript/src/api/window.tsmodified+4-4
Changes to packages/typescript/src/api/window.ts
@@ -5,7 +5,7 @@
55 */
66
77import { getBridge } from '../bridge/core.js'
8import { isWebKitHost } from '../bridge/webkit-pending.js'
8import { isDesktopWebViewHost } from '../bridge/webkit-pending.js'
99
1010interface InjectedWindowBridge {
1111 _call<T>(action: string, data: Record<string, any> | undefined, windowId: string): Promise<T>
@@ -740,7 +740,7 @@ export class Window {
740740 // ==========================================================================
741741
742742 private async _call<T = void>(action: string, data?: Record<string, any>): Promise<T> {
743 if (isWebKitHost()) {
743 if (isDesktopWebViewHost()) {
744744 const injected = getInjectedWindowBridge()
745745 if (!injected?._call) {
746746 throw new Error('Craft window bridge is unavailable')
@@ -813,7 +813,7 @@ class WindowManager {
813813 }
814814 const existing = this._windows.get(id)
815815
816 if (isWebKitHost()) {
816 if (isDesktopWebViewHost()) {
817817 const injected = getInjectedWindowBridge()
818818 if (!injected?.open) {
819819 throw new Error('Craft window bridge is unavailable')
@@ -844,7 +844,7 @@ class WindowManager {
844844 * Get focused window
845845 */
846846 async getFocused(): Promise<Window | null> {
847 if (isWebKitHost()) {
847 if (isDesktopWebViewHost()) {
848848 const injected = getInjectedWindowBridge()
849849 if (!injected?._call) {
850850 throw new Error('Craft window bridge is unavailable')
packages/typescript/src/bridge/webkit-pending.tsmodified+8-0
Changes to packages/typescript/src/bridge/webkit-pending.ts
@@ -73,6 +73,14 @@ export function isWebKitHost(): boolean {
7373 return typeof window !== 'undefined' && !!window.webkit?.messageHandlers?.craft
7474}
7575
76/** Desktop window handles use the injected Craft envelope on both WebKit and WebView2. */
77export 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
7684/**
7785 * Send a fire-and-forget message into the WKWebView host. Returns true if
7886 * the message was delivered, false otherwise.
packages/zig/build.zigmodified+71-0
Changes to packages/zig/build.zig
@@ -1247,6 +1247,62 @@ pub fn build(b: *std.Build) void {
12471247 }),
12481248 });
12491249
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
12501306 // The shell bridge's argv construction: what a `spawn` actually runs.
12511307 // Untested until now, which is how it shipped parsing an `args` array and
12521308 // throwing it away.
@@ -1700,6 +1756,13 @@ pub fn build(b: *std.Build) void {
17001756 const run_window_lifecycle_tests = b.addRunArtifact(window_lifecycle_tests);
17011757 const run_window_registry_tests = b.addRunArtifact(window_registry_tests);
17021758 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);
17031766 const run_external_link_tests = b.addRunArtifact(external_link_tests);
17041767 const run_webview_recovery_tests = b.addRunArtifact(webview_recovery_tests);
17051768 const run_request_context_tests = b.addRunArtifact(request_context_tests);
@@ -1848,6 +1911,13 @@ pub fn build(b: *std.Build) void {
18481911 test_step.dependOn(&run_window_lifecycle_tests.step);
18491912 test_step.dependOn(&run_window_registry_tests.step);
18501913 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);
18511921 test_step.dependOn(&run_external_link_tests.step);
18521922 test_step.dependOn(&run_webview_recovery_tests.step);
18531923 test_step.dependOn(&run_request_context_tests.step);
@@ -2817,6 +2887,7 @@ fn linkPlatformLibraries(b: *std.Build, module: *std.Build.Module, target_os: st
28172887 .linux => {
28182888 module.linkSystemLibrary("gtk+-3.0", .{});
28192889 module.linkSystemLibrary("webkit2gtk-4.1", .{});
2890 module.linkSystemLibrary("javascriptcoregtk-4.1", .{});
28202891 },
28212892 .windows => {
28222893 module.linkSystemLibrary("ole32", .{});
packages/zig/src/desktop_bridge_envelope.zigadded+77-0
Changes to packages/zig/src/desktop_bridge_envelope.zig
@@ -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
6const std = ;
7const request_context = ;
8
9pub const max_message_bytes = 1024 * 1024;
10
11pub 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
23pub 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
51test "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
62test "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
72test "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}
packages/zig/src/desktop_bridge_text.zigadded+25-0
Changes to packages/zig/src/desktop_bridge_text.zig
@@ -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
4const std = ;
5const desktop_bridge_envelope = ;
6
7pub 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
15test "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
21test "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}
packages/zig/src/desktop_script_encoding.zigadded+55-0
Changes to packages/zig/src/desktop_script_encoding.zig
@@ -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
7const std = ;
8
9pub 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
22pub 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
33test "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
46test "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
53test "an embedded NUL cannot silently truncate the installed script" {
54 try std.testing.expectError(error.InvalidScript, encode(std.testing.allocator, "one\x00two"));
55}
packages/zig/src/desktop_window_events.zigadded+38-0
Changes to packages/zig/src/desktop_window_events.zig
@@ -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
4const std = ;
5const bridge_error = ;
6
7pub 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
28test "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
34test "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}
packages/zig/src/desktop_window_reads.zigadded+70-0
Changes to packages/zig/src/desktop_window_reads.zig
@@ -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
4const std = ;
5const bridge_error = ;
6const desktop_window_registry = ;
7
8pub 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
18pub 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`.
30pub 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
41test "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
61test "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}
packages/zig/src/desktop_window_registry.zigadded+294-0
Changes to packages/zig/src/desktop_window_registry.zig
@@ -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
7const std = ;
8
9pub const capacity = 32;
10pub const Geometry = struct {
11 x: i32,
12 y: i32,
13 width: u32,
14 height: u32,
15};
16
17pub const GeometryChange = struct {
18 moved: bool,
19 resized: bool,
20};
21
22pub const StateChange = struct {
23 minimized: ?bool,
24 fullscreen: ?bool,
25};
26
27pub 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
40pub 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
183test "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
199test "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
211test "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
221test "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
232test "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
244test "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
260test "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
276test "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
286test "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}
packages/zig/src/js/craft-bridge.jsmodified+7-3
Changes to packages/zig/src/js/craft-bridge.js
@@ -8,8 +8,8 @@
88// 1. **Self-contained** — no imports, no transpiler, ES5-friendly
99// (avoid arrow functions, const/let are fine in modern WebKit).
1010// 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.
1313//
1414// The native side dispatches messages by `t` (type) and answers async ones
1515// via `window.__craftBridgeResult(action, payload, id)`, where `id` is the
@@ -171,7 +171,11 @@
171171 try {
172172 const msg = { t: t, a: a, d: d || '' }
173173 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')
175179 return true
176180 }
177181 catch (e) {
packages/zig/src/linux.zigmodified+377-80
Changes to packages/zig/src/linux.zig
@@ -1,4 +1,14 @@
11const std = ;
2const bridge_error = ;
3const desktop_bridge_envelope = ;
4const desktop_window_registry = ;
5const desktop_window_events = ;
6const desktop_window_reads = ;
7const json_utils = ;
8const request_context = ;
9const window_context = ;
10const window_registry = ;
11const window_reply_target = ;
212
313// Linux implementation using GTK3 and WebKit2GTK 4.1.
414// Requires: libgtk-3-dev, libwebkit2gtk-4.1-dev
@@ -6,10 +16,13 @@ const std = ;
616// GTK and WebKit C bindings
717pub extern "c" fn gtk_init(argc: ?*c_int, argv: ?*anyopaque) void;
818pub extern "c" fn gtk_application_new(application_id: [*:0]const u8, flags: c_int) ?*anyopaque;
19pub extern "c" fn g_application_register(app: *anyopaque, cancellable: ?*anyopaque, err: ?*?*anyopaque) c_int;
920pub extern "c" fn g_application_run(app: *anyopaque, argc: c_int, argv: [*c][*c]u8) c_int;
21pub extern "c" fn g_object_unref(object: *anyopaque) void;
1022pub extern "c" fn gtk_application_window_new(app: *anyopaque) *anyopaque;
1123pub extern "c" fn gtk_window_set_title(window: *anyopaque, title: [*:0]const u8) void;
1224pub extern "c" fn gtk_window_set_default_size(window: *anyopaque, width: c_int, height: c_int) void;
25pub extern "c" fn gtk_window_resize(window: *anyopaque, width: c_int, height: c_int) void;
1326pub extern "c" fn gtk_window_present(window: *anyopaque) void;
1427pub extern "c" fn gtk_window_close(window: *anyopaque) void;
1528pub 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;
1831pub extern "c" fn gtk_window_unfullscreen(window: *anyopaque) void;
1932pub extern "c" fn gtk_window_maximize(window: *anyopaque) void;
2033pub extern "c" fn gtk_window_unmaximize(window: *anyopaque) void;
21pub extern "c" fn gtk_window_minimize(window: *anyopaque) void;
34pub extern "c" fn gtk_window_iconify(window: *anyopaque) void;
2235pub extern "c" fn gtk_widget_hide(widget: *anyopaque) void;
2336pub extern "c" fn gtk_widget_show(widget: *anyopaque) void;
24pub extern "c" fn gtk_window_set_position(window: *anyopaque, x: c_int, y: c_int) void;
37pub extern "c" fn gtk_window_move(window: *anyopaque, x: c_int, y: c_int) void;
38pub extern "c" fn gtk_window_get_position(window: *anyopaque, x: *c_int, y: *c_int) void;
39pub extern "c" fn gtk_window_get_size(window: *anyopaque, width: *c_int, height: *c_int) void;
40pub extern "c" fn gtk_window_get_title(window: *anyopaque) ?[*:0]const u8;
41pub extern "c" fn gtk_window_is_active(window: *anyopaque) c_int;
42pub extern "c" fn gtk_widget_get_window(widget: *anyopaque) ?*anyopaque;
43pub extern "c" fn gdk_window_get_state(window: *anyopaque) c_uint;
2544
2645pub extern "c" fn webkit_web_view_new() *anyopaque;
2746pub extern "c" fn webkit_web_view_load_uri(webview: *anyopaque, uri: [*:0]const u8) void;
2847pub extern "c" fn webkit_web_view_load_html(webview: *anyopaque, html: [*:0]const u8, base_uri: [*:0]const u8) void;
48pub extern "c" fn webkit_web_view_reload(webview: *anyopaque) void;
2949pub extern "c" fn webkit_web_view_get_settings(webview: *anyopaque) *anyopaque;
3050pub extern "c" fn webkit_settings_set_enable_developer_extras(settings: *anyopaque, enabled: c_int) void;
3151pub 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(
7696) *anyopaque;
7797pub extern "c" fn webkit_user_content_manager_add_script(manager: *anyopaque, script: *anyopaque) void;
7898pub extern "c" fn webkit_user_content_manager_remove_all_scripts(manager: *anyopaque) void;
99pub extern "c" fn webkit_user_content_manager_register_script_message_handler(manager: *anyopaque, name: [*:0]const u8) c_int;
100pub extern "c" fn webkit_javascript_result_get_js_value(result: *anyopaque) *anyopaque;
101pub extern "c" fn jsc_value_to_json(value: *anyopaque, indent: c_uint) ?[*:0]u8;
79102
80103pub extern "c" fn gtk_container_add(container: *anyopaque, widget: *anyopaque) void;
81104
@@ -198,55 +221,310 @@ pub const WindowEntry = struct {
198221 webview: *anyopaque,
199222};
200223
201var window_registry: [32]?WindowEntry = std.mem.zeroes([32]?WindowEntry);
202var window_count: u32 = 0;
203var next_window_id: u32 = 1;
224var desktop_windows: desktop_window_registry.Registry = .{};
204225
205226pub 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) };
212229}
213230
214231pub fn getWindowCount() u32 {
215 return window_count;
232 return @intCast(desktop_windows.count());
216233}
217234
218235fn 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.
242fn 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
251fn 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
259fn 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
267fn 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
272fn 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
277fn 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
304fn 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.
316fn 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
339fn 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
348fn 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
411fn 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 }
230420 }
231421 }
232 return null; // registry full
422 return desktop_windows.byWindow(window_context.current() orelse return error.WindowHandleNotSet) orelse error.WindowHandleNotSet;
233423}
234424
235fn 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;
425fn 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
436fn 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 }
242451 }
243452 }
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
460fn 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;
244516 }
245517}
246518
247519// Application state
248520var app_instance: ?*anyopaque = null;
249var current_window: ?*anyopaque = null;
521
522fn 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}
250528
251529pub const WindowStyle = struct {
252530 frameless: bool = false,
@@ -283,14 +561,32 @@ pub const Window = struct {
283561 // Initialize GTK if not already done
284562 if (app_instance == null) {
285563 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;
287577 }
288578
289579 const window = gtk_application_window_new(app_instance.?);
290 current_window = window;
580 errdefer gtk_window_close(window);
291581
292582 // Create WebView
293583 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;
294590
295591 // Configure WebView settings
296592 const settings = webkit_web_view_get_settings(webview);
@@ -353,16 +649,28 @@ pub const Window = struct {
353649 const x: i32 = options.x orelse 100;
354650 const y: i32 = options.y orelse 100;
355651 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));
357653 }
358654
359655 // Add WebView to window
360656 gtk_container_add(window, webview);
361657
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
362664 // 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 }
364672
365 return Window{
673 var created = Window{
366674 .id = window_id,
367675 .gtk_window = window,
368676 .webview = webview,
@@ -372,6 +680,10 @@ pub const Window = struct {
372680 .x = x,
373681 .y = y,
374682 };
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;
375687 }
376688
377689 pub fn show(self: *Window) void {
@@ -384,33 +696,19 @@ pub const Window = struct {
384696 }
385697
386698 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;
405702 gtk_window_close(self.gtk_window);
406703 }
407704
408705 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));
410708 }
411709
412710 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);
414712 }
415713
416714 pub fn setTitle(self: *Window, title: []const u8) void {
@@ -436,7 +734,7 @@ pub const Window = struct {
436734 }
437735
438736 pub fn minimize(self: *Window) void {
439 gtk_window_minimize(self.gtk_window);
737 gtk_window_iconify(self.gtk_window);
440738 }
441739
442740 pub fn setFullscreen(self: *Window, fullscreen: bool) void {
@@ -454,6 +752,7 @@ pub const Window = struct {
454752 }
455753
456754 pub fn injectScript(self: *Window, script: []const u8) !void {
755 if (std.mem.indexOfScalar(u8, script, 0) != null) return error.InvalidScript;
457756 const script_z = try .dupeZ(std.heap.c_allocator, u8, script);
458757 defer std.heap.c_allocator.free(script_z);
459758
@@ -480,7 +779,7 @@ pub const Window = struct {
480779pub const App = struct {
481780 pub fn run() !void {
482781 if (app_instance) |app| {
483 _ = g_application_run(app, 0, undefined);
782 _ = g_application_run(app, 0, null);
484783 }
485784 }
486785
@@ -489,28 +788,17 @@ pub const App = struct {
489788 }
490789};
491790
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.
494793pub 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
512798 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);
514802}
515803
516804// Legacy API compatibility
@@ -526,6 +814,14 @@ pub fn createWindow(title: []const u8, width: u32, height: u32, html: []const u8
526814}
527815
528816pub 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
820pub 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
824fn createStyledWindow(title: []const u8, width: u32, height: u32, content: []const u8, style: WindowStyle, comptime is_url: bool) !*anyopaque {
529825 var window = try Window.create(.{
530826 .title = title,
531827 .width = width,
@@ -544,7 +840,8 @@ pub fn createWindowWithURL(title: []const u8, width: u32, height: u32, url: []co
544840 .allow_notifications = style.allow_notifications,
545841 .allow_clipboard = style.allow_clipboard,
546842 });
547 try window.loadURL(url);
843 errdefer window.close();
844 if (is_url) try window.loadURL(content) else try window.loadHTML(content);
548845 window.show();
549846 return window.gtk_window;
550847}
packages/zig/src/main.zigmodified+57-2
Changes to packages/zig/src/main.zig
@@ -170,6 +170,9 @@ pub const Window = struct {
170170 }
171171
172172 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;
173176 switch (builtin.os.tag) {
174177 .macos => try self.showMacOS(),
175178 .linux => try self.showLinux(),
@@ -314,7 +317,7 @@ pub const App = struct {
314317 try self.windows.append(self.allocator, window);
315318 return window;
316319 } else {
317 return error.UnsupportedPlatform;
320 return self.createPortableWindow(title, width, height, html, style, false);
318321 }
319322 }
320323
@@ -328,8 +331,60 @@ pub const App = struct {
328331 try self.windows.append(self.allocator, window);
329332 return window;
330333 } 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);
332377 }
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;
333388 }
334389
335390 /// Create a window with a native macOS sidebar (Finder-style with vibrancy) - HTML mode
packages/zig/src/window_reply_target.zigadded+35-0
Changes to packages/zig/src/window_reply_target.zig
@@ -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
8const std = ;
9
10pub 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
19test "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
24test "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
29test "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
Changes to packages/zig/src/windows.zig
@@ -1,4 +1,16 @@
11const std = ;
2const bridge_error = ;
3const desktop_bridge_envelope = ;
4const desktop_bridge_text = ;
5const desktop_script_encoding = ;
6const desktop_window_events = ;
7const desktop_window_reads = ;
8const desktop_window_registry = ;
9const json_utils = ;
10const request_context = ;
11const window_context = ;
12const window_registry = ;
13const window_reply_target = ;
214
315// Windows implementation using Win32 API and WebView2
416// Requires: Microsoft.Web.WebView2 NuGet package
@@ -78,8 +90,12 @@ pub const SW_HIDE: c_int = 0;
7890pub const SW_MAXIMIZE: c_int = 3;
7991pub const SW_MINIMIZE: c_int = 6;
8092pub const WM_DESTROY: UINT = 0x0002;
93pub const WM_MOVE: UINT = 0x0003;
8194pub const WM_SIZE: UINT = 0x0005;
95pub const WM_ACTIVATE: UINT = 0x0006;
8296pub const WM_CLOSE: UINT = 0x0010;
97pub const WM_QUIT: UINT = 0x0012;
98const WM_CRAFT_OPEN_WINDOW: UINT = 0x8001; // WM_APP + 1
8399pub const PM_REMOVE: UINT = 0x0001;
84100pub const GWLP_USERDATA: c_int = -21;
85101
@@ -107,15 +123,21 @@ pub extern "user32" fn TranslateMessage(lpMsg: *const MSG) callconv(.c) BOOL;
107123pub extern "user32" fn DispatchMessageW(lpMsg: *const MSG) callconv(.c) LRESULT;
108124pub extern "user32" fn DefWindowProcW(hWnd: HWND, Msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c) LRESULT;
109125pub extern "user32" fn PostQuitMessage(nExitCode: c_int) callconv(.c) void;
126pub extern "user32" fn PostMessageW(hWnd: ?HWND, Msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c) BOOL;
110127pub extern "user32" fn DestroyWindow(hWnd: HWND) callconv(.c) BOOL;
111128pub extern "user32" fn SetWindowTextW(hWnd: HWND, lpString: LPCWSTR) callconv(.c) BOOL;
129pub extern "user32" fn GetWindowTextLengthW(hWnd: HWND) callconv(.c) c_int;
130pub extern "user32" fn GetWindowTextW(hWnd: HWND, lpString: LPWSTR, nMaxCount: c_int) callconv(.c) c_int;
131pub extern "user32" fn GetForegroundWindow() callconv(.c) ?HWND;
112132pub 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;
113133pub extern "user32" fn LoadCursorW(hInstance: ?HINSTANCE, lpCursorName: LPCWSTR) callconv(.c) ?*anyopaque;
114134pub extern "user32" fn GetClientRect(hWnd: HWND, lpRect: *RECT) callconv(.c) BOOL;
135pub extern "user32" fn GetWindowRect(hWnd: HWND, lpRect: *RECT) callconv(.c) BOOL;
115136pub extern "user32" fn SetWindowLongPtrW(hWnd: HWND, nIndex: c_int, dwNewLong: isize) callconv(.c) isize;
116137pub extern "user32" fn GetWindowLongPtrW(hWnd: HWND, nIndex: c_int) callconv(.c) isize;
117138pub extern "kernel32" fn GetModuleHandleW(lpModuleName: ?LPCWSTR) callconv(.c) ?HINSTANCE;
118139pub extern "kernel32" fn Sleep(dwMilliseconds: DWORD) callconv(.c) void;
140pub extern "ole32" fn CoTaskMemFree(pv: ?*anyopaque) callconv(.c) void;
119141
120142// ============================================================================
121143// WebView2 COM vtable interfaces
@@ -265,6 +287,19 @@ pub const ICoreWebView2 = extern struct {
265287 lpVtbl: *ICoreWebView2Vtbl,
266288};
267289
290pub 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
299pub const ICoreWebView2WebMessageReceivedEventArgs = extern struct {
300 lpVtbl: *ICoreWebView2WebMessageReceivedEventArgsVtbl,
301};
302
268303// -- ICoreWebView2Settings ---------------------------------------------------
269304
270305pub const ICoreWebView2SettingsVtbl = extern struct {
@@ -534,6 +569,10 @@ const ControllerCompletedHandler = extern struct {
534569 return perm_hr;
535570 }
536571
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
537576 // Store results
538577 self.ctx.controller = controller;
539578 self.ctx.webview = webview;
@@ -599,6 +638,85 @@ const PermissionRequestedHandler = extern struct {
599638
600639pub const ICoreWebView2PermissionRequestedEventHandler = PermissionRequestedHandler;
601640
641// -- Page-to-host message handler --------------------------------------------
642
643const 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
602720// -- ExecuteScript completed handler (fire-and-forget) -----------------------
603721
604722const ExecuteScriptCompletedHandler = extern struct {
@@ -647,6 +765,57 @@ const ExecuteScriptCompletedHandler = extern struct {
647765
648766pub const ICoreWebView2ExecuteScriptCompletedHandler = ExecuteScriptCompletedHandler;
649767
768// -- Document-start script completed handler ---------------------------------
769
770const ScriptInstallContext = struct {
771 done: bool = false,
772 result: HRESULT = -1,
773};
774
775const 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
650819// ============================================================================
651820// WebView2Loader — loaded dynamically at runtime to avoid link-time dependency
652821// ============================================================================
@@ -699,11 +868,316 @@ var app_running = false;
699868var window_class_registered = false;
700869const CLASS_NAME: [:0]const u16 = &[_:0]u16{ 'Z', 'y', 't', 'e', 'W', 'i', 'n', 'd', 'o', 'w' };
701870
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.
704var g_active_hwnd: ?HWND = null;
705var g_active_controller: ?*ICoreWebView2Controller = null;
706var 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.
873var desktop_windows: desktop_window_registry.Registry = .{};
874
875const PendingWindowOpen = struct {
876 next: ?*PendingWindowOpen = null,
877 owner_id: u32,
878 request_id: ?u64,
879 action: []const u8,
880 data: ?[]u8,
881};
882
883var pending_open_head: ?*PendingWindowOpen = null;
884var pending_open_tail: ?*PendingWindowOpen = null;
885var pending_open_count: usize = 0;
886var processing_open = false;
887
888fn freePendingOpen(task: *PendingWindowOpen) void {
889 if (task.data) |data| std.heap.c_allocator.free(data);
890 std.heap.c_allocator.destroy(task);
891}
892
893fn 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
913fn 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
930fn 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
954fn 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
966fn 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
974fn 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
997fn 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
1006fn 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
1070fn 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
1084fn 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
1094fn 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
1107fn 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
1124fn 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}
7071181
7081182pub const WindowStyle = struct {
7091183 frameless: bool = false,
@@ -721,6 +1195,7 @@ pub const WindowStyle = struct {
7211195};
7221196
7231197pub const Window = struct {
1198 id: u32,
7241199 hwnd: HWND,
7251200 controller: ?*ICoreWebView2Controller,
7261201 webview: ?*ICoreWebView2,
@@ -766,10 +1241,8 @@ pub const Window = struct {
7661241 const ex_style: DWORD = if (options.always_on_top) WS_EX_TOPMOST else 0;
7671242
7681243 // 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);
7731246
7741247 // Calculate window position
7751248 const x = options.x orelse CW_USEDEFAULT;
@@ -790,6 +1263,7 @@ pub const Window = struct {
7901263 hInstance,
7911264 null,
7921265 ) orelse return error.WindowCreationFailed;
1266 errdefer _ = DestroyWindow(hwnd);
7931267
7941268 // ----------------------------------------------------------------
7951269 // Async WebView2 initialization
@@ -851,10 +1325,35 @@ pub const Window = struct {
8511325
8521326 std.debug.print("[Media] Windows WebView2 configured for camera/microphone access\n", .{});
8531327
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,
8551354 .hwnd = hwnd,
856 .controller = init_ctx.controller,
857 .webview = init_ctx.webview,
1355 .controller = controller,
1356 .webview = webview,
8581357 .title = options.title,
8591358 .width = options.width,
8601359 .height = options.height,
@@ -862,35 +1361,42 @@ pub const Window = struct {
8621361 .y = y,
8631362 };
8641363
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"));
8691367 return window;
8701368 }
8711369
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
8721375 pub fn show(self: *Window) void {
1376 if (self.liveEntry() == null) return;
8731377 _ = ShowWindow(self.hwnd, SW_SHOW);
8741378 _ = UpdateWindow(self.hwnd);
8751379 }
8761380
8771381 pub fn hide(self: *Window) void {
1382 if (self.liveEntry() == null) return;
8781383 _ = ShowWindow(self.hwnd, SW_HIDE);
8791384 }
8801385
8811386 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;
8841395 }
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);
8911396 }
8921397
8931398 pub fn setSize(self: *Window, width: u32, height: u32) void {
1399 if (self.liveEntry() == null) return;
8941400 _ = SetWindowPos(self.hwnd, null, 0, 0, @intCast(width), @intCast(height), 0x0002); // SWP_NOMOVE
8951401 self.width = width;
8961402 self.height = height;
@@ -898,21 +1404,22 @@ pub const Window = struct {
8981404 }
8991405
9001406 pub fn setPosition(self: *Window, x_pos: i32, y_pos: i32) void {
1407 if (self.liveEntry() == null) return;
9011408 _ = SetWindowPos(self.hwnd, null, @intCast(x_pos), @intCast(y_pos), 0, 0, 0x0001); // SWP_NOSIZE
9021409 self.x = x_pos;
9031410 self.y = y_pos;
9041411 }
9051412
9061413 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));
9111417 self.title = title;
9121418 }
9131419
9141420 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);
9161423 var url_wide = try utf8ToUtf16Z(4096, url);
9171424 const url_ptr: LPCWSTR = @ptrCast(&url_wide);
9181425 const hr = webview.lpVtbl.Navigate(webview, url_ptr);
@@ -923,7 +1430,8 @@ pub const Window = struct {
9231430 }
9241431
9251432 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);
9271435 // NavigateToString needs null-terminated UTF-16.
9281436 // For large HTML we allocate on the heap.
9291437 const wide_len = html.len + 1; // rough upper bound for ASCII-heavy content
@@ -942,10 +1450,12 @@ pub const Window = struct {
9421450 }
9431451
9441452 pub fn maximize(self: *Window) void {
1453 if (self.liveEntry() == null) return;
9451454 _ = ShowWindow(self.hwnd, SW_MAXIMIZE);
9461455 }
9471456
9481457 pub fn minimize(self: *Window) void {
1458 if (self.liveEntry() == null) return;
9491459 _ = ShowWindow(self.hwnd, SW_MINIMIZE);
9501460 }
9511461
@@ -958,7 +1468,8 @@ pub const Window = struct {
9581468 }
9591469
9601470 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);
9621473 var script_wide = try utf8ToUtf16Z(16384, script);
9631474 const script_ptr: LPCWSTR = @ptrCast(&script_wide);
9641475
@@ -978,15 +1489,39 @@ pub const Window = struct {
9781489 }
9791490
9801491 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);
9861502 if (!succeeded(hr)) {
9871503 std.debug.print("[WebView2] AddScriptToExecuteOnDocumentCreated failed: 0x{x}\n", .{@as(u32, @bitCast(hr))});
9881504 return error.ScriptInjectionFailed;
9891505 }
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;
9901525 }
9911526
9921527 pub fn enableGPUAcceleration(self: *Window, enable: bool) !void {
@@ -997,13 +1532,15 @@ pub const Window = struct {
9971532 }
9981533
9991534 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);
10011537 _ = webview.lpVtbl.OpenDevToolsWindow(webview);
10021538 }
10031539
10041540 // Resize the WebView2 control to match the current client area
10051541 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);
10071544 var bounds: RECT = undefined;
10081545 _ = GetClientRect(self.hwnd, &bounds);
10091546 _ = controller.lpVtbl.put_Bounds(controller, bounds);
@@ -1012,19 +1549,57 @@ pub const Window = struct {
10121549
10131550fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c) LRESULT {
10141551 switch (msg) {
1552 WM_CRAFT_OPEN_WINDOW => {
1553 processPendingOpens();
1554 return 0;
1555 },
10151556 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", "");
10221567 }
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);
10231575 }
10241576 return 0;
10251577 },
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 }
10281603 return 0;
10291604 },
10301605 else => {},
@@ -1035,6 +1610,7 @@ fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c
10351610pub const App = struct {
10361611 pub fn run() !void {
10371612 app_running = true;
1613 defer app_running = false;
10381614 var msg: MSG = undefined;
10391615
10401616 while (GetMessageW(&msg, null, 0, 0) != 0) {
@@ -1049,10 +1625,15 @@ pub const App = struct {
10491625 }
10501626};
10511627
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.
10541630pub 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);
10561637 var script_wide = try utf8ToUtf16Z(16384, script);
10571638 const handler = try std.heap.c_allocator.create(ExecuteScriptCompletedHandler);
10581639 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
10741655}
10751656
10761657pub 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
1661pub 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
1665fn createStyledWindow(title: []const u8, width: u32, height: u32, content: []const u8, style: WindowStyle, comptime is_url: bool) !*anyopaque {
10771666 var window = try Window.create(.{
10781667 .title = title,
10791668 .width = width,
@@ -1083,11 +1672,13 @@ pub fn createWindowWithURL(title: []const u8, width: u32, height: u32, url: []co
10831672 .resizable = style.resizable,
10841673 .frameless = style.frameless,
10851674 .transparent = style.transparent,
1675 .always_on_top = style.always_on_top,
10861676 .fullscreen = style.fullscreen,
10871677 .dark_mode = style.dark_mode,
10881678 .dev_tools = style.dev_tools,
10891679 });
1090 try window.loadURL(url);
1680 errdefer window.close();
1681 if (is_url) try window.loadURL(content) else try window.loadHTML(content);
10911682 window.show();
10921683 return window.hwnd;
10931684}
@@ -1158,6 +1749,6 @@ pub fn getClipboard(allocator: std.mem.Allocator) ![]u8 {
11581749 const text_len = std.mem.indexOfSentinel(u16, 0, text_wide);
11591750
11601751 // 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 "";
11621753 return utf8_len;
11631754}
packages/zig/test/injected_js_test.zigmodified+19-0
Changes to packages/zig/test/injected_js_test.zig
@@ -222,6 +222,25 @@ const WEBVIEW_HOST =
222222 \\globalThis.setTimeout = function () { return 0 };
223223 \\globalThis.clearTimeout = function () {};
224224;
225
226test "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}
225244// The timer stubs are load-bearing rather than cosmetic. `_req` arms a
226245// 30-second reaping timeout inside its Promise executor, so without a
227246// `setTimeout` the executor throws a ReferenceError and every request-shaped
scripts/release-workflow.test.tsmodified+14-0
Changes to scripts/release-workflow.test.ts
@@ -65,6 +65,20 @@ test('npm publication follows artifact validation, and macOS downloads run on bo
6565 expect(steps(release.jobs['attach-release-sbom'])).toContain('cmp "sbom/$document" "$VERIFY_DIR/$document"')
6666})
6767
68test('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
6882test('a registry network failure remains a visible best-effort warning', () => {
6983 const command = steps(release.jobs['notify-registry'])
7084 expect(command).not.toBe('')
scripts/scan-release-artifacts.test.tsmodified+8-1
Changes to scripts/scan-release-artifacts.test.ts
@@ -70,13 +70,20 @@ test('native inventory binds exact binaries and Zig runtime version', () => {
7070 const root = fixture()
7171 binary(root, 'bin/craft', '7f454c46')
7272 binary(root, 'cross/windows-x64/craft.exe', '4d5a')
73 binary(root, 'cross/windows-x64/WebView2Loader.dll', '4d5a')
7374 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'])
7576 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'] })
7680 writeFileSync(join(root, 'cross/windows-x64/craft.exe'), 'not a binary')
7781 expect(() => nativeInventory(root, 'linux', '1.2.3', '0.17.0-dev.1963')).toThrow('missing or empty')
7882 binary(root, 'cross/windows-x64/craft.exe', '7f454c46')
7983 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')
8087})
8188
8289test('publisher uses only hash-verified archives and skips an existing version', () => {
scripts/scan-release-artifacts.tsmodified+16-9
Changes to scripts/scan-release-artifacts.ts
@@ -7,6 +7,7 @@ import { enforceScanPolicy, scanSbom } from './scan-sbom'
77type Archive = { name: string, version: string, file: string, sha256: string }
88type Component = { name: string, version?: string, 'bom-ref'?: string, [key: string]: unknown }
99type Inventory = { bomFormat: string, components: Component[], [key: string]: unknown }
10type NativeArtifact = { path: string, name: string, magic: string[], type: 'application' | 'library', version?: string }
1011
1112const root = resolve(import.meta.dir, '..')
1213const sha256 = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex')
@@ -108,18 +109,21 @@ function scanNpm(directory: string, output: string, syft: string, grype: string)
108109 finally { rmSync(consumer, { recursive: true, force: true }) }
109110}
110111
112function 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
111118export 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 }) => {
116120 const absolute = join(binaryRoot, path)
117121 if (!existsSync(absolute) || !statSync(absolute).isFile() || statSync(absolute).size < 1024)
118122 throw new Error(`${name}: missing or empty release binary`)
119123 const bytes = readFileSync(absolute)
120124 if (!magic.some(prefix => bytes.subarray(0, prefix.length / 2).toString('hex') === prefix))
121125 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,
123127 hashes: [{ alg: 'SHA-256', content: createHash('sha256').update(bytes).digest('hex') }],
124128 properties: [{ name: 'craft:artifact-path', value: path }],
125129 }
@@ -128,8 +132,11 @@ export function nativeInventory(binaryRoot: string, platform: 'macos' | 'linux',
128132 return {
129133 bomFormat: 'CycloneDX', specVersion: '1.6', version: 1,
130134 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 })),
133140 }
134141}
135142
@@ -137,7 +144,7 @@ function scanNative(binaryRoot: string, platform: 'macos' | 'linux', output: str
137144 mkdirSync(output, { recursive: true })
138145 const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }
139146 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)
141148 const discovered = paths.map((path, index) => {
142149 const report = join(output, `native-${index}.cyclonedx.json`)
143150 run([syft, join(binaryRoot, path), '-o', `cyclonedx-json=${report}`])
@@ -152,7 +159,7 @@ function scanNative(binaryRoot: string, platform: 'macos' | 'linux', output: str
152159 const summary = scanSbom(sbom, join(output, 'grype'), grype)
153160 enforceScanPolicy(summary.counts, true)
154161 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()) {
156163 if (JSON.stringify(binary.hashes) !== JSON.stringify(inventory.components[index]!.hashes))
157164 throw new Error(`${binary.name}: release binary changed after scanning`)
158165 }
scripts/verify-desktop-multi-window.tsadded+201-0
Changes to scripts/verify-desktop-multi-window.ts
@@ -0,0 +1,201 @@
1/** Exercise the shipped Linux or Windows binary's page-to-native multi-window bridge. */
2import { spawn } from 'node:child_process'
3import { createServer } from 'node:http'
4import type { AddressInfo } from 'node:net'
5import { platform } from 'node:os'
6
7const binary = process.argv[2]
8if (!binary) throw new Error('usage: bun scripts/verify-desktop-multi-window.ts <craft-binary>')
9const isWindows = platform() === 'win32'
10const platformName = isWindows ? 'Windows' : 'Linux'
11
12const 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
112const 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
128const steps = new Set<string>()
129const requests: string[] = []
130let resolveDone!: () => void
131let rejectDone!: (error: Error) => void
132const done = new Promise<void>((resolve, reject) => {
133 resolveDone = resolve
134 rejectDone = reject
135})
136const 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
164await new Promise<void>((resolve, reject) => {
165 server.once('error', reject)
166 server.listen(0, '127.0.0.1', resolve)
167})
168const port = (server.address() as AddressInfo).port
169const startedAt = Date.now()
170const 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})
176let output = ''
177for (const stream of [child.stdout, child.stderr]) {
178 stream.on('data', chunk => { output = (output + chunk.toString()).slice(-16_000) })
179}
180child.once('error', error => rejectDone(error))
181child.once('exit', (code, signal) => rejectDone(new Error(`Craft exited before smoke completed (${code ?? signal}, ${Date.now() - startedAt} ms)`)))
182const timer = setTimeout(() => rejectDone(new Error(`${platformName} multi-window smoke timed out`)), 100_000)
183
184try {
185 await done
186 console.log(`${platformName} multi-window smoke passed: ${[...steps].join(', ')}`)
187}
188catch (error) {
189 throw new Error(`${String(error)}\nElapsed: ${Date.now() - startedAt} ms\nHTTP requests: ${requests.join(', ')}\nCraft output:\n${output}`)
190}
191finally {
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}
scripts/zig.test.tsmodified+20-1
Changes to scripts/zig.test.ts
@@ -2,7 +2,7 @@ import { afterEach, expect, test } from 'bun:test'
22import { chmodSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
5import { pinnedZigVersion, resolveZig, runZig } from './zig'
5import { pinnedPantryZigPath, pinnedZigVersion, resolveZig, runZig } from './zig'
66
77const roots: string[] = []
88afterEach(() => { 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', (
2828 expect(resolveZig(f.root, { ...f.env, CRAFT_ZIG: pinned.path })).toEqual({ path: pinned.path, version: '0.17.0-dev.1963+e00c6c439', overridden: false })
2929})
3030
31test('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
3150test('requires an explicit opt-in for unpinned snapshots', () => {
3251 const f = fixture('0.17.0-dev.2163+89ff10d56')
3352 expect(() => resolveZig(f.root, { ...f.env, CRAFT_ALLOW_UNPINNED_ZIG: 'true' })).toThrow('version mismatch')
scripts/zig.tsmodified+17-3
Changes to scripts/zig.ts
@@ -1,4 +1,4 @@
1import { readFileSync } from 'node:fs'
1import { existsSync, readFileSync } from 'node:fs'
22import { join, resolve } from 'node:path'
33
44export function pinnedZigVersion(root: string): string {
@@ -9,10 +9,24 @@ export function pinnedZigVersion(root: string): string {
99 return version.replace('_', '+')
1010}
1111
12export function resolveZig(root: string, env: NodeJS.ProcessEnv = process.env): { path: string, version: string, overridden: boolean } {
12export 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
23export function resolveZig(root: string, env: NodeJS.ProcessEnv = process.env, hostPlatform = process.platform): { path: string, version: string, overridden: boolean } {
1324 const expected = pinnedZigVersion(root)
1425 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 })
1630 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.'
1731 if (!path)
1832 throw new Error(`Zig ${expected} was not found (${executable}). ${remedy}`)