also looking at this
feat: add Linux and Windows named-window routing
#325
29 files
+2250
-200
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # Multi-window ownership |
| 2 | 2 | |
| 3 | 3 | This document records Craft's current multi-window ownership contract. It is |
| 4 | both a map of the implemented macOS behavior and a boundary around the choices | |
| 4 | both a map of the implemented desktop behavior and a boundary around the choices | |
| 5 | 5 | that are still open. A process-global Zig object is not automatically a |
| 6 | 6 | single-window bug: the important question is whether it is a shared service or |
| 7 | 7 | whether it stores a window-specific target. |
| @@ -10,17 +10,25 @@ whether it stores a window-specific target. | ||
| 10 | 10 | |
| 11 | 11 | | Area | macOS | Linux | Windows | |
| 12 | 12 | | --- | --- | --- | --- | |
| 13 | | Runtime creation from `createWindow()` | Implemented | Not wired to the SDK handle contract | Not wired to the SDK handle contract | | |
| 14 | | Stable named handles | Implemented | Not implemented | Not implemented | | |
| 15 | | Sender-authenticated local actions | Implemented | Not implemented | Not implemented | | |
| 16 | | Named cross-window actions | Implemented | Not implemented | Not implemented | | |
| 17 | | Per-window lifecycle events | Implemented | Not implemented | Not implemented | | |
| 18 | | Permanent destroy and cleanup | Implemented for named windows | Not implemented | Not implemented | | |
| 13 | | Runtime creation from `createWindow()` | Implemented | Implemented; GUI smoke in CI | Implemented; cross-build in CI | | |
| 14 | | Stable named handles | Implemented | Implemented | Implemented | | |
| 15 | | Sender-authenticated local actions | Implemented | Implemented through WebKitGTK | Implemented through WebView2 | | |
| 16 | | Named cross-window actions | Implemented | Implemented for core controls | Implemented for core controls | | |
| 17 | | Per-window lifecycle events | Implemented | Implemented | Implemented | | |
| 18 | | Permanent destroy and cleanup | Implemented for named windows | Implemented on close/destroy | Implemented on close/destroy | | |
| 19 | 19 | | Modal and parent relationships | Unspecified | Unspecified | Unspecified | |
| 20 | 20 | |
| 21 | Issue #67 therefore remains open. Its macOS core is substantially implemented, | |
| 22 | but closing it would imply cross-platform and modal/parent semantics that Craft | |
| 23 | does not yet provide. | |
| 21 | Issue #67 remains open while the portable runtime smoke and the still-unspecified | |
| 22 | modal/parent semantics are reviewed. A green cross-build alone does not prove | |
| 23 | that WebView2 opens and tears down a real window on Windows. | |
| 24 | ||
| 25 | Linux and Windows keep their live native window/webview pairs in | |
| 26 | `desktop_window_registry.zig`. Page messages are authenticated by the sending | |
| 27 | WebKitGTK/WebView2 view; names select a target only after that sender is known. | |
| 28 | Replies go back to the requesting view, and lifecycle events go to the changed | |
| 29 | window plus its named handle's creator. Linux must register its `GtkApplication` | |
| 30 | before constructing the first `GtkApplicationWindow`, because the CLI creates | |
| 31 | that window before entering `g_application_run()`. | |
| 24 | 32 | |
| 25 | 33 | ## Per-window state |
| 26 | 34 | |
| @@ -118,13 +126,12 @@ The next multi-window milestone needs product decisions in addition to code: | ||
| 118 | 126 | application-level asynchronous source above. |
| 119 | 127 | 3. Decide whether Touch Bar should stay primary-window scoped or follow the |
| 120 | 128 | key window with separately owned item definitions and callbacks. |
| 121 | 4. Bring Linux and Windows runtime creation, stable handles, sender routing, | |
| 122 | lifecycle events and destroy semantics up to the macOS contract. Their | |
| 123 | platform backends can create native windows today, but the TypeScript bridge | |
| 124 | deliberately reports runtime creation as unsupported outside macOS. | |
| 125 | 5. Add platform-native integration coverage. Source conformance and pure state | |
| 126 | tests defend macOS invariants without requiring a GUI runner, but they do | |
| 127 | not substitute for real Windows WebView2 and Linux WebKitGTK lifecycle tests. | |
| 129 | 4. Finish advanced Linux/Windows window actions and clarify which are portable | |
| 130 | versus platform-specific, without conflating close semantics: macOS retains | |
| 131 | a closed page, while Linux and Windows release it. | |
| 132 | 5. Complete platform-native integration coverage. Linux WebKitGTK runs under | |
| 133 | Xvfb in CI; Windows currently has a cross-build but still needs a real | |
| 134 | WebView2 lifecycle smoke on a capable Windows runner. | |
| 128 | 135 | |
| 129 | 136 | ## Review checklist |
| 130 | 137 | |
| @@ -16,7 +16,9 @@ const window_reply_target = ; | ||
| 16 | 16 | // GTK and WebKit C bindings |
| 17 | 17 | pub extern "c" fn gtk_init(argc: ?*c_int, argv: ?*anyopaque) void; |
| 18 | 18 | pub extern "c" fn gtk_application_new(application_id: [*:0]const u8, flags: c_int) ?*anyopaque; |
| 19 | pub extern "c" fn g_application_register(app: *anyopaque, cancellable: ?*anyopaque, err: ?*?*anyopaque) c_int; | |
| 19 | 20 | pub extern "c" fn g_application_run(app: *anyopaque, argc: c_int, argv: [*c][*c]u8) c_int; |
| 21 | pub extern "c" fn g_object_unref(object: *anyopaque) void; | |
| 20 | 22 | pub extern "c" fn gtk_application_window_new(app: *anyopaque) *anyopaque; |
| 21 | 23 | pub extern "c" fn gtk_window_set_title(window: *anyopaque, title: [*:0]const u8) void; |
| 22 | 24 | pub extern "c" fn gtk_window_set_default_size(window: *anyopaque, width: c_int, height: c_int) void; |
| @@ -517,6 +519,13 @@ fn handleWindowAction(action: []const u8, data: ?[]const u8) !void { | ||
| 517 | 519 | // Application state |
| 518 | 520 | var app_instance: ?*anyopaque = null; |
| 519 | 521 | |
| 522 | fn onApplicationActivated(_: *anyopaque, _: ?*anyopaque) callconv(.c) void { | |
| 523 | // The CLI creates and shows its first window before g_application_run(). | |
| 524 | // Activation presents that already-owned window instead of constructing | |
| 525 | // another one. | |
| 526 | if (desktop_windows.latest()) |entry| gtk_window_present(@ptrFromInt(entry.window)); | |
| 527 | } | |
| 528 | ||
| 520 | 529 | pub const WindowStyle = struct { |
| 521 | 530 | frameless: bool = false, |
| 522 | 531 | transparent: bool = false, |
| @@ -552,7 +561,19 @@ pub const Window = struct { | ||
| 552 | 561 | // Initialize GTK if not already done |
| 553 | 562 | if (app_instance == null) { |
| 554 | 563 | gtk_init(null, null); |
| 555 | 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; | |
| 556 | 577 | } |
| 557 | 578 | |
| 558 | 579 | const window = gtk_application_window_new(app_instance.?); |
| @@ -758,7 +779,7 @@ pub const Window = struct { | ||
| 758 | 779 | pub const App = struct { |
| 759 | 780 | pub fn run() !void { |
| 760 | 781 | if (app_instance) |app| { |
| 761 | _ = g_application_run(app, 0, undefined); | |
| 782 | _ = g_application_run(app, 0, null); | |
| 762 | 783 | } |
| 763 | 784 | } |
| 764 | 785 | |
| @@ -90,6 +90,7 @@ const childPage = `<!doctype html><script> | ||
| 90 | 90 | </script>` |
| 91 | 91 | |
| 92 | 92 | const steps = new Set<string>() |
| 93 | const requests: string[] = [] | |
| 93 | 94 | let resolveDone!: () => void |
| 94 | 95 | let rejectDone!: (error: Error) => void |
| 95 | 96 | const done = new Promise<void>((resolve, reject) => { |
| @@ -98,6 +99,7 @@ const done = new Promise<void>((resolve, reject) => { | ||
| 98 | 99 | }) |
| 99 | 100 | const server = createServer((request, response) => { |
| 100 | 101 | const url = new URL(request.url ?? '/', 'http://127.0.0.1') |
| 102 | requests.push(url.pathname) | |
| 101 | 103 | if (url.pathname === '/main' || url.pathname === '/child') { |
| 102 | 104 | response.setHeader('content-type', 'text/html; charset=utf-8') |
| 103 | 105 | response.end(url.pathname === '/main' ? mainPage : childPage) |
| @@ -145,7 +147,7 @@ try { | ||
| 145 | 147 | console.log(`Linux multi-window smoke passed: ${[...steps].join(', ')}`) |
| 146 | 148 | } |
| 147 | 149 | catch (error) { |
| 148 | throw new Error(`${String(error)}\nCraft output:\n${output}`) | |
| 150 | throw new Error(`${String(error)}\nHTTP requests: ${requests.join(', ')}\nCraft output:\n${output}`) | |
| 149 | 151 | } |
| 150 | 152 | finally { |
| 151 | 153 | clearTimeout(timer) |