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.
| @@ -79,6 +79,17 @@ jobs: | ||
| 79 | 79 | ls -lh packages/zig/zig-out/bin/ |
| 80 | 80 | size packages/zig/zig-out/bin/craft |
| 81 | 81 | |
| 82 | # The host matrix never analyzes windows.zig. Exercise the same target | |
| 83 | # as release.yml here so multi-window WebView2 changes cannot wait until | |
| 84 | # a tag to reveal a cross-compilation failure. | |
| 85 | - name: Cross-build Windows CLI | |
| 86 | if: runner.os == 'Linux' | |
| 87 | working-directory: packages/zig | |
| 88 | run: | | |
| 89 | VERSION="$(bun -p "require('../../package.json').version")" | |
| 90 | zig build -Doptimize=ReleaseSafe -Dversion="$VERSION" -Dtarget=x86_64-windows | |
| 91 | test -f zig-out/bin/craft.exe | |
| 92 | ||
| 82 | 93 | typescript-sdk: |
| 83 | 94 | runs-on: ubuntu-latest |
| 84 | 95 | |
| @@ -11,6 +11,8 @@ pub const Entry = struct { | ||
| 11 | 11 | id: u32, |
| 12 | 12 | window: usize, |
| 13 | 13 | webview: usize, |
| 14 | /// Platform-specific control object (WebView2 controller on Windows). | |
| 15 | context: usize = 0, | |
| 14 | 16 | }; |
| 15 | 17 | |
| 16 | 18 | pub const Registry = struct { |
| @@ -18,13 +20,17 @@ pub const Registry = struct { | ||
| 18 | 20 | next_id: u32 = 1, |
| 19 | 21 | |
| 20 | 22 | pub fn remember(self: *Registry, window: usize, webview: usize) ?u32 { |
| 23 | return self.rememberWithContext(window, webview, 0); | |
| 24 | } | |
| 25 | ||
| 26 | pub fn rememberWithContext(self: *Registry, window: usize, webview: usize, context: usize) ?u32 { | |
| 21 | 27 | if (window == 0 or webview == 0 or self.byWindow(window) != null or self.next_id == 0) return null; |
| 22 | 28 | for (&self.entries) |*slot| { |
| 23 | 29 | if (slot.* == null) { |
| 24 | 30 | const id = self.next_id; |
| 25 | 31 | // Exhaust rather than reuse an id that a caller may still hold. |
| 26 | 32 | self.next_id = if (id == std.math.maxInt(u32)) 0 else id + 1; |
| 27 | slot.* = .{ .id = id, .window = window, .webview = webview }; | |
| 33 | slot.* = .{ .id = id, .window = window, .webview = webview, .context = context }; | |
| 28 | 34 | return id; |
| 29 | 35 | } |
| 30 | 36 | } |
| @@ -140,3 +146,13 @@ test "reply candidates contain only live webviews" { | ||
| 140 | 146 | _ = registry.forgetWindow(0x2000); |
| 141 | 147 | try std.testing.expectEqual(@as(usize, 0), registry.liveWebviews(&handles).len); |
| 142 | 148 | } |
| 149 | ||
| 150 | test "a window keeps its own platform control object" { | |
| 151 | var registry: Registry = .{}; | |
| 152 | const first = registry.rememberWithContext(0x1000, 0x1001, 0x1002).?; | |
| 153 | const second = registry.rememberWithContext(0x2000, 0x2001, 0x2002).?; | |
| 154 | try std.testing.expectEqual(@as(usize, 0x1002), registry.byId(first).?.context); | |
| 155 | try std.testing.expectEqual(@as(usize, 0x2002), registry.byId(second).?.context); | |
| 156 | _ = registry.forgetWindow(0x1000); | |
| 157 | try std.testing.expectEqual(@as(usize, 0x2002), registry.byWindow(0x2000).?.context); | |
| 158 | } | |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | const std = ; |
| 2 | const desktop_window_registry = ; | |
| 3 | const window_context = ; | |
| 4 | const window_reply_target = ; | |
| 2 | 5 | |
| 3 | 6 | // Windows implementation using Win32 API and WebView2 |
| 4 | 7 | // Requires: Microsoft.Web.WebView2 NuGet package |
| @@ -699,11 +702,9 @@ var app_running = false; | ||
| 699 | 702 | var window_class_registered = false; |
| 700 | 703 | const CLASS_NAME: [:0]const u16 = &[_:0]u16{ 'Z', 'y', 't', 'e', 'W', 'i', 'n', 'd', 'o', 'w' }; |
| 701 | 704 | |
| 702 | // WebView2 owns these COM interfaces independently of the stack value returned | |
| 703 | // from Window.create. Keep only stable COM pointers in global callback state. | |
| 704 | var g_active_hwnd: ?HWND = null; | |
| 705 | var g_active_controller: ?*ICoreWebView2Controller = null; | |
| 706 | var g_active_webview: ?*ICoreWebView2 = null; | |
| 705 | // Native callbacks arrive with an HWND, not the stack value returned by | |
| 706 | // Window.create. Keep stable handles for every live window here. | |
| 707 | var desktop_windows: desktop_window_registry.Registry = .{}; | |
| 707 | 708 | |
| 708 | 709 | pub const WindowStyle = struct { |
| 709 | 710 | frameless: bool = false, |
| @@ -721,6 +722,7 @@ pub const WindowStyle = struct { | ||
| 721 | 722 | }; |
| 722 | 723 | |
| 723 | 724 | pub const Window = struct { |
| 725 | id: u32, | |
| 724 | 726 | hwnd: HWND, |
| 725 | 727 | controller: ?*ICoreWebView2Controller, |
| 726 | 728 | webview: ?*ICoreWebView2, |
| @@ -790,6 +792,7 @@ pub const Window = struct { | ||
| 790 | 792 | hInstance, |
| 791 | 793 | null, |
| 792 | 794 | ) orelse return error.WindowCreationFailed; |
| 795 | errdefer _ = DestroyWindow(hwnd); | |
| 793 | 796 | |
| 794 | 797 | // ---------------------------------------------------------------- |
| 795 | 798 | // Async WebView2 initialization |
| @@ -851,10 +854,20 @@ pub const Window = struct { | ||
| 851 | 854 | |
| 852 | 855 | std.debug.print("[Media] Windows WebView2 configured for camera/microphone access\n", .{}); |
| 853 | 856 | |
| 857 | const controller = init_ctx.controller orelse return error.WebView2InitFailed; | |
| 858 | const webview = init_ctx.webview orelse return error.WebView2InitFailed; | |
| 859 | const window_id = desktop_windows.rememberWithContext(@intFromPtr(hwnd), @intFromPtr(webview), @intFromPtr(controller)) orelse { | |
| 860 | _ = controller.lpVtbl.Close(controller); | |
| 861 | _ = webview.lpVtbl.Release(webview); | |
| 862 | _ = controller.lpVtbl.Release(controller); | |
| 863 | return error.TooManyWindows; | |
| 864 | }; | |
| 865 | ||
| 854 | 866 | const window = Window{ |
| 867 | .id = window_id, | |
| 855 | 868 | .hwnd = hwnd, |
| 856 | .controller = init_ctx.controller, | |
| 857 | .webview = init_ctx.webview, | |
| 869 | .controller = controller, | |
| 870 | .webview = webview, | |
| 858 | 871 | .title = options.title, |
| 859 | 872 | .width = options.width, |
| 860 | 873 | .height = options.height, |
| @@ -862,35 +875,39 @@ pub const Window = struct { | ||
| 862 | 875 | .y = y, |
| 863 | 876 | }; |
| 864 | 877 | |
| 865 | g_active_hwnd = hwnd; | |
| 866 | g_active_controller = window.controller; | |
| 867 | g_active_webview = window.webview; | |
| 868 | ||
| 869 | 878 | return window; |
| 870 | 879 | } |
| 871 | 880 | |
| 881 | fn liveEntry(self: *const Window) ?desktop_window_registry.Entry { | |
| 882 | const entry = desktop_windows.byId(self.id) orelse return null; | |
| 883 | return if (entry.window == @intFromPtr(self.hwnd)) entry else null; | |
| 884 | } | |
| 885 | ||
| 872 | 886 | pub fn show(self: *Window) void { |
| 887 | if (self.liveEntry() == null) return; | |
| 873 | 888 | _ = ShowWindow(self.hwnd, SW_SHOW); |
| 874 | 889 | _ = UpdateWindow(self.hwnd); |
| 875 | 890 | } |
| 876 | 891 | |
| 877 | 892 | pub fn hide(self: *Window) void { |
| 893 | if (self.liveEntry() == null) return; | |
| 878 | 894 | _ = ShowWindow(self.hwnd, SW_HIDE); |
| 879 | 895 | } |
| 880 | 896 | |
| 881 | 897 | pub fn close(self: *Window) void { |
| 882 | if (self.controller) |ctrl| { | |
| 883 | _ = ctrl.lpVtbl.Close(ctrl); | |
| 898 | if (self.liveEntry() == null) { | |
| 899 | self.controller = null; | |
| 900 | self.webview = null; | |
| 901 | return; | |
| 902 | } | |
| 903 | if (DestroyWindow(self.hwnd) != 0) { | |
| 904 | self.controller = null; | |
| 905 | self.webview = null; | |
| 884 | 906 | } |
| 885 | self.controller = null; | |
| 886 | self.webview = null; | |
| 887 | g_active_hwnd = null; | |
| 888 | g_active_controller = null; | |
| 889 | g_active_webview = null; | |
| 890 | _ = DestroyWindow(self.hwnd); | |
| 891 | 907 | } |
| 892 | 908 | |
| 893 | 909 | pub fn setSize(self: *Window, width: u32, height: u32) void { |
| 910 | if (self.liveEntry() == null) return; | |
| 894 | 911 | _ = SetWindowPos(self.hwnd, null, 0, 0, @intCast(width), @intCast(height), 0x0002); // SWP_NOMOVE |
| 895 | 912 | self.width = width; |
| 896 | 913 | self.height = height; |
| @@ -898,12 +915,14 @@ pub const Window = struct { | ||
| 898 | 915 | } |
| 899 | 916 | |
| 900 | 917 | pub fn setPosition(self: *Window, x_pos: i32, y_pos: i32) void { |
| 918 | if (self.liveEntry() == null) return; | |
| 901 | 919 | _ = SetWindowPos(self.hwnd, null, @intCast(x_pos), @intCast(y_pos), 0, 0, 0x0001); // SWP_NOSIZE |
| 902 | 920 | self.x = x_pos; |
| 903 | 921 | self.y = y_pos; |
| 904 | 922 | } |
| 905 | 923 | |
| 906 | 924 | pub fn setTitle(self: *Window, title: []const u8) void { |
| 925 | if (self.liveEntry() == null) return; | |
| 907 | 926 | var title_wide_buf: [256]u16 = undefined; |
| 908 | 927 | const len = std.unicode.utf8ToUtf16Le(&title_wide_buf, title) catch return; |
| 909 | 928 | title_wide_buf[len] = 0; |
| @@ -912,7 +931,8 @@ pub const Window = struct { | ||
| 912 | 931 | } |
| 913 | 932 | |
| 914 | 933 | pub fn loadURL(self: *Window, url: []const u8) !void { |
| 915 | const webview = self.webview orelse return error.WebView2NotInitialized; | |
| 934 | const entry = self.liveEntry() orelse return error.NoWebView; | |
| 935 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 916 | 936 | var url_wide = try utf8ToUtf16Z(4096, url); |
| 917 | 937 | const url_ptr: LPCWSTR = @ptrCast(&url_wide); |
| 918 | 938 | const hr = webview.lpVtbl.Navigate(webview, url_ptr); |
| @@ -923,7 +943,8 @@ pub const Window = struct { | ||
| 923 | 943 | } |
| 924 | 944 | |
| 925 | 945 | pub fn loadHTML(self: *Window, html: []const u8) !void { |
| 926 | const webview = self.webview orelse return error.WebView2NotInitialized; | |
| 946 | const entry = self.liveEntry() orelse return error.NoWebView; | |
| 947 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 927 | 948 | // NavigateToString needs null-terminated UTF-16. |
| 928 | 949 | // For large HTML we allocate on the heap. |
| 929 | 950 | const wide_len = html.len + 1; // rough upper bound for ASCII-heavy content |
| @@ -942,10 +963,12 @@ pub const Window = struct { | ||
| 942 | 963 | } |
| 943 | 964 | |
| 944 | 965 | pub fn maximize(self: *Window) void { |
| 966 | if (self.liveEntry() == null) return; | |
| 945 | 967 | _ = ShowWindow(self.hwnd, SW_MAXIMIZE); |
| 946 | 968 | } |
| 947 | 969 | |
| 948 | 970 | pub fn minimize(self: *Window) void { |
| 971 | if (self.liveEntry() == null) return; | |
| 949 | 972 | _ = ShowWindow(self.hwnd, SW_MINIMIZE); |
| 950 | 973 | } |
| 951 | 974 | |
| @@ -958,7 +981,8 @@ pub const Window = struct { | ||
| 958 | 981 | } |
| 959 | 982 | |
| 960 | 983 | pub fn executeJavaScript(self: *Window, script: []const u8) !void { |
| 961 | const webview = self.webview orelse return error.WebView2NotInitialized; | |
| 984 | const entry = self.liveEntry() orelse return error.NoWebView; | |
| 985 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 962 | 986 | var script_wide = try utf8ToUtf16Z(16384, script); |
| 963 | 987 | const script_ptr: LPCWSTR = @ptrCast(&script_wide); |
| 964 | 988 | |
| @@ -978,7 +1002,8 @@ pub const Window = struct { | ||
| 978 | 1002 | } |
| 979 | 1003 | |
| 980 | 1004 | pub fn injectScript(self: *Window, script: []const u8) !void { |
| 981 | const webview = self.webview orelse return error.WebView2NotInitialized; | |
| 1005 | const entry = self.liveEntry() orelse return error.NoWebView; | |
| 1006 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 982 | 1007 | var script_wide = try utf8ToUtf16Z(16384, script); |
| 983 | 1008 | const script_ptr: LPCWSTR = @ptrCast(&script_wide); |
| 984 | 1009 | |
| @@ -997,13 +1022,15 @@ pub const Window = struct { | ||
| 997 | 1022 | } |
| 998 | 1023 | |
| 999 | 1024 | pub fn openDevTools(self: *Window) void { |
| 1000 | const webview = self.webview orelse return; | |
| 1025 | const entry = self.liveEntry() orelse return; | |
| 1026 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 1001 | 1027 | _ = webview.lpVtbl.OpenDevToolsWindow(webview); |
| 1002 | 1028 | } |
| 1003 | 1029 | |
| 1004 | 1030 | // Resize the WebView2 control to match the current client area |
| 1005 | 1031 | fn resizeWebView(self: *Window) void { |
| 1006 | const controller = self.controller orelse return; | |
| 1032 | const entry = self.liveEntry() orelse return; | |
| 1033 | const controller: *ICoreWebView2Controller = @ptrFromInt(entry.context); | |
| 1007 | 1034 | var bounds: RECT = undefined; |
| 1008 | 1035 | _ = GetClientRect(self.hwnd, &bounds); |
| 1009 | 1036 | _ = controller.lpVtbl.put_Bounds(controller, bounds); |
| @@ -1013,18 +1040,29 @@ pub const Window = struct { | ||
| 1013 | 1040 | fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c) LRESULT { |
| 1014 | 1041 | switch (msg) { |
| 1015 | 1042 | 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); | |
| 1022 | } | |
| 1043 | // A resize belongs to this HWND, not whichever window was created last. | |
| 1044 | if (desktop_windows.byWindow(@intFromPtr(hwnd))) |entry| { | |
| 1045 | const controller: *ICoreWebView2Controller = @ptrFromInt(entry.context); | |
| 1046 | var bounds: RECT = undefined; | |
| 1047 | _ = GetClientRect(hwnd, &bounds); | |
| 1048 | _ = controller.lpVtbl.put_Bounds(controller, bounds); | |
| 1023 | 1049 | } |
| 1024 | 1050 | return 0; |
| 1025 | 1051 | }, |
| 1026 | WM_DESTROY, WM_CLOSE => { | |
| 1027 | PostQuitMessage(0); | |
| 1052 | WM_CLOSE => { | |
| 1053 | _ = DestroyWindow(hwnd); | |
| 1054 | return 0; | |
| 1055 | }, | |
| 1056 | WM_DESTROY => { | |
| 1057 | if (desktop_windows.forgetWindow(@intFromPtr(hwnd))) |entry| { | |
| 1058 | const controller: *ICoreWebView2Controller = @ptrFromInt(entry.context); | |
| 1059 | const webview: *ICoreWebView2 = @ptrFromInt(entry.webview); | |
| 1060 | _ = controller.lpVtbl.Close(controller); | |
| 1061 | _ = webview.lpVtbl.Release(webview); | |
| 1062 | _ = controller.lpVtbl.Release(controller); | |
| 1063 | // Closing a child window must not end every window's event loop. | |
| 1064 | if (app_running and desktop_windows.count() == 0) PostQuitMessage(0); | |
| 1065 | } | |
| 1028 | 1066 | return 0; |
| 1029 | 1067 | }, |
| 1030 | 1068 | else => {}, |
| @@ -1035,6 +1073,7 @@ fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c | ||
| 1035 | 1073 | pub const App = struct { |
| 1036 | 1074 | pub fn run() !void { |
| 1037 | 1075 | app_running = true; |
| 1076 | defer app_running = false; | |
| 1038 | 1077 | var msg: MSG = undefined; |
| 1039 | 1078 | |
| 1040 | 1079 | while (GetMessageW(&msg, null, 0, 0) != 0) { |
| @@ -1049,10 +1088,15 @@ pub const App = struct { | ||
| 1049 | 1088 | } |
| 1050 | 1089 | }; |
| 1051 | 1090 | |
| 1052 | /// Evaluate JavaScript in the current webview (cross-platform bridge helper). | |
| 1053 | /// Uses the active window's WebView2 instance. | |
| 1091 | /// Evaluate a reply in the authenticated sender, if that webview is still live. | |
| 1092 | /// Native callers without a sender use the most recently created live window. | |
| 1054 | 1093 | pub fn evalJS(script: []const u8) !void { |
| 1055 | const webview = g_active_webview orelse return error.NoWebView; | |
| 1094 | var handles: [desktop_window_registry.capacity]usize = undefined; | |
| 1095 | const live = desktop_windows.liveWebviews(&handles); | |
| 1096 | const latest = desktop_windows.latest(); | |
| 1097 | const target = window_reply_target.select(live, window_context.currentWebView(), if (latest) |entry| entry.webview else null) orelse | |
| 1098 | return error.NoWebView; | |
| 1099 | const webview: *ICoreWebView2 = @ptrFromInt(target); | |
| 1056 | 1100 | var script_wide = try utf8ToUtf16Z(16384, script); |
| 1057 | 1101 | const handler = try std.heap.c_allocator.create(ExecuteScriptCompletedHandler); |
| 1058 | 1102 | handler.* = .{ .lpVtbl = &ExecuteScriptCompletedHandler.vtbl_instance, .ref_count = 1 }; |