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.
| @@ -1255,6 +1255,14 @@ pub fn build(b: *std.Build) void { | ||
| 1255 | 1255 | }), |
| 1256 | 1256 | }); |
| 1257 | 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 | ||
| 1258 | 1266 | // The shell bridge's argv construction: what a `spawn` actually runs. |
| 1259 | 1267 | // Untested until now, which is how it shipped parsing an `args` array and |
| 1260 | 1268 | // throwing it away. |
| @@ -1709,6 +1717,7 @@ pub fn build(b: *std.Build) void { | ||
| 1709 | 1717 | const run_window_registry_tests = b.addRunArtifact(window_registry_tests); |
| 1710 | 1718 | const run_window_context_tests = b.addRunArtifact(window_context_tests); |
| 1711 | 1719 | const run_window_reply_target_tests = b.addRunArtifact(window_reply_target_tests); |
| 1720 | const run_desktop_window_registry_tests = b.addRunArtifact(desktop_window_registry_tests); | |
| 1712 | 1721 | const run_external_link_tests = b.addRunArtifact(external_link_tests); |
| 1713 | 1722 | const run_webview_recovery_tests = b.addRunArtifact(webview_recovery_tests); |
| 1714 | 1723 | const run_request_context_tests = b.addRunArtifact(request_context_tests); |
| @@ -1858,6 +1867,7 @@ pub fn build(b: *std.Build) void { | ||
| 1858 | 1867 | test_step.dependOn(&run_window_registry_tests.step); |
| 1859 | 1868 | test_step.dependOn(&run_window_context_tests.step); |
| 1860 | 1869 | test_step.dependOn(&run_window_reply_target_tests.step); |
| 1870 | test_step.dependOn(&run_desktop_window_registry_tests.step); | |
| 1861 | 1871 | test_step.dependOn(&run_external_link_tests.step); |
| 1862 | 1872 | test_step.dependOn(&run_webview_recovery_tests.step); |
| 1863 | 1873 | test_step.dependOn(&run_request_context_tests.step); |
| @@ -0,0 +1,142 @@ | ||
| 1 | //! Platform-neutral live desktop window handles. | |
| 2 | //! | |
| 3 | //! A native close can arrive without going through `Window.close` (the titlebar | |
| 4 | //! close button, for example). The platform callback must forget that window | |
| 5 | //! before any later bridge reply or resize selects its destroyed webview. | |
| 6 | ||
| 7 | const std = ; | |
| 8 | ||
| 9 | pub const capacity = 32; | |
| 10 | pub const Entry = struct { | |
| 11 | id: u32, | |
| 12 | window: usize, | |
| 13 | webview: usize, | |
| 14 | }; | |
| 15 | ||
| 16 | pub const Registry = struct { | |
| 17 | entries: [capacity]?Entry = @splat(null), | |
| 18 | next_id: u32 = 1, | |
| 19 | ||
| 20 | pub fn remember(self: *Registry, window: usize, webview: usize) ?u32 { | |
| 21 | if (window == 0 or webview == 0 or self.byWindow(window) != null or self.next_id == 0) return null; | |
| 22 | for (&self.entries) |*slot| { | |
| 23 | if (slot.* == null) { | |
| 24 | const id = self.next_id; | |
| 25 | // Exhaust rather than reuse an id that a caller may still hold. | |
| 26 | self.next_id = if (id == std.math.maxInt(u32)) 0 else id + 1; | |
| 27 | slot.* = .{ .id = id, .window = window, .webview = webview }; | |
| 28 | return id; | |
| 29 | } | |
| 30 | } | |
| 31 | return null; | |
| 32 | } | |
| 33 | ||
| 34 | pub fn byId(self: *const Registry, id: u32) ?Entry { | |
| 35 | for (self.entries) |slot| { | |
| 36 | if (slot) |entry| { | |
| 37 | if (entry.id == id) return entry; | |
| 38 | } | |
| 39 | } | |
| 40 | return null; | |
| 41 | } | |
| 42 | ||
| 43 | pub fn byWindow(self: *const Registry, window: usize) ?Entry { | |
| 44 | if (window == 0) return null; | |
| 45 | for (self.entries) |slot| { | |
| 46 | if (slot) |entry| { | |
| 47 | if (entry.window == window) return entry; | |
| 48 | } | |
| 49 | } | |
| 50 | return null; | |
| 51 | } | |
| 52 | ||
| 53 | pub fn latest(self: *const Registry) ?Entry { | |
| 54 | var result: ?Entry = null; | |
| 55 | for (self.entries) |slot| { | |
| 56 | if (slot) |entry| { | |
| 57 | if (result == null or entry.id > result.?.id) result = entry; | |
| 58 | } | |
| 59 | } | |
| 60 | return result; | |
| 61 | } | |
| 62 | ||
| 63 | pub fn forgetWindow(self: *Registry, window: usize) ?Entry { | |
| 64 | if (window == 0) return null; | |
| 65 | for (&self.entries) |*slot| { | |
| 66 | if (slot.*) |entry| { | |
| 67 | if (entry.window == window) { | |
| 68 | slot.* = null; | |
| 69 | return entry; | |
| 70 | } | |
| 71 | } | |
| 72 | } | |
| 73 | return null; | |
| 74 | } | |
| 75 | ||
| 76 | pub fn count(self: *const Registry) usize { | |
| 77 | var total: usize = 0; | |
| 78 | for (self.entries) |slot| { | |
| 79 | if (slot != null) total += 1; | |
| 80 | } | |
| 81 | return total; | |
| 82 | } | |
| 83 | ||
| 84 | pub fn liveWebviews(self: *const Registry, out: *[capacity]usize) []const usize { | |
| 85 | var total: usize = 0; | |
| 86 | for (self.entries) |slot| { | |
| 87 | if (slot) |entry| { | |
| 88 | out[total] = entry.webview; | |
| 89 | total += 1; | |
| 90 | } | |
| 91 | } | |
| 92 | return out[0..total]; | |
| 93 | } | |
| 94 | }; | |
| 95 | ||
| 96 | test "closing one window retains the other and reopen gets a new identity" { | |
| 97 | var registry: Registry = .{}; | |
| 98 | const first = registry.remember(0x1000, 0x1001).?; | |
| 99 | const second = registry.remember(0x2000, 0x2001).?; | |
| 100 | try std.testing.expectEqual(@as(usize, 2), registry.count()); | |
| 101 | try std.testing.expectEqual(first, registry.forgetWindow(0x1000).?.id); | |
| 102 | try std.testing.expectEqual(@as(usize, 1), registry.count()); | |
| 103 | try std.testing.expectEqual(second, registry.latest().?.id); | |
| 104 | try std.testing.expect(registry.byId(first) == null); | |
| 105 | ||
| 106 | const reopened = registry.remember(0x3000, 0x3001).?; | |
| 107 | try std.testing.expect(reopened != first and reopened != second); | |
| 108 | try std.testing.expectEqual(reopened, registry.latest().?.id); | |
| 109 | try std.testing.expectEqual(@as(usize, 2), registry.count()); | |
| 110 | } | |
| 111 | ||
| 112 | test "invalid and duplicate native handles never occupy a slot" { | |
| 113 | var registry: Registry = .{}; | |
| 114 | try std.testing.expect(registry.remember(0, 0x1001) == null); | |
| 115 | try std.testing.expect(registry.remember(0x1000, 0) == null); | |
| 116 | try std.testing.expect(registry.remember(0x1000, 0x1001) != null); | |
| 117 | try std.testing.expect(registry.remember(0x1000, 0x2001) == null); | |
| 118 | try std.testing.expectEqual(@as(usize, 1), registry.count()); | |
| 119 | try std.testing.expect(registry.forgetWindow(0x2000) == null); | |
| 120 | try std.testing.expectEqual(@as(usize, 1), registry.count()); | |
| 121 | } | |
| 122 | ||
| 123 | test "a full registry rejects creation without displacing a live window" { | |
| 124 | var registry: Registry = .{}; | |
| 125 | for (0..capacity) |i| { | |
| 126 | try std.testing.expect(registry.remember(i + 1, i + 100) != null); | |
| 127 | } | |
| 128 | try std.testing.expect(registry.remember(0x9000, 0x9001) == null); | |
| 129 | try std.testing.expectEqual(@as(usize, capacity), registry.count()); | |
| 130 | try std.testing.expect(registry.byWindow(1) != null); | |
| 131 | } | |
| 132 | ||
| 133 | test "reply candidates contain only live webviews" { | |
| 134 | var registry: Registry = .{}; | |
| 135 | _ = registry.remember(0x1000, 0x1001); | |
| 136 | _ = registry.remember(0x2000, 0x2001); | |
| 137 | _ = registry.forgetWindow(0x1000); | |
| 138 | var handles: [capacity]usize = undefined; | |
| 139 | try std.testing.expectEqualSlices(usize, &.{0x2001}, registry.liveWebviews(&handles)); | |
| 140 | _ = registry.forgetWindow(0x2000); | |
| 141 | try std.testing.expectEqual(@as(usize, 0), registry.liveWebviews(&handles).len); | |
| 142 | } | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | const std = ; |
| 2 | const desktop_window_registry = ; | |
| 2 | 3 | const window_context = ; |
| 3 | 4 | const window_reply_target = ; |
| 4 | 5 | |
| @@ -200,55 +201,30 @@ pub const WindowEntry = struct { | ||
| 200 | 201 | webview: *anyopaque, |
| 201 | 202 | }; |
| 202 | 203 | |
| 203 | var window_registry: [32]?WindowEntry = std.mem.zeroes([32]?WindowEntry); | |
| 204 | var window_count: u32 = 0; | |
| 205 | var next_window_id: u32 = 1; | |
| 204 | var desktop_windows: desktop_window_registry.Registry = .{}; | |
| 206 | 205 | |
| 207 | 206 | pub fn getWindowById(id: u32) ?WindowEntry { |
| 208 | for (window_registry) |entry| { | |
| 209 | if (entry) |e| { | |
| 210 | if (e.id == id) return e; | |
| 211 | } | |
| 212 | } | |
| 213 | return null; | |
| 207 | const entry = desktop_windows.byId(id) orelse return null; | |
| 208 | return .{ .id = entry.id, .gtk_window = @ptrFromInt(entry.window), .webview = @ptrFromInt(entry.webview) }; | |
| 214 | 209 | } |
| 215 | 210 | |
| 216 | 211 | pub fn getWindowCount() u32 { |
| 217 | return window_count; | |
| 212 | return @intCast(desktop_windows.count()); | |
| 218 | 213 | } |
| 219 | 214 | |
| 220 | 215 | fn registerWindow(gtk_window: *anyopaque, webview: *anyopaque) ?u32 { |
| 221 | for (&window_registry) |*slot| { | |
| 222 | if (slot.* == null) { | |
| 223 | const id = next_window_id; | |
| 224 | next_window_id += 1; | |
| 225 | slot.* = WindowEntry{ | |
| 226 | .id = id, | |
| 227 | .gtk_window = gtk_window, | |
| 228 | .webview = webview, | |
| 229 | }; | |
| 230 | window_count += 1; | |
| 231 | return id; | |
| 232 | } | |
| 233 | } | |
| 234 | return null; // registry full | |
| 216 | return desktop_windows.remember(@intFromPtr(gtk_window), @intFromPtr(webview)); | |
| 235 | 217 | } |
| 236 | 218 | |
| 237 | fn unregisterWindow(id: u32) void { | |
| 238 | for (&window_registry) |*slot| { | |
| 239 | if (slot.*) |e| { | |
| 240 | if (e.id == id) { | |
| 241 | slot.* = null; | |
| 242 | window_count -= 1; | |
| 243 | break; | |
| 244 | } | |
| 245 | } | |
| 246 | } | |
| 219 | /// GTK emits destroy for titlebar closes as well as programmatic closes. | |
| 220 | /// Forget the handle at actual teardown, so neither path leaves a stale | |
| 221 | /// webview eligible for bridge replies. | |
| 222 | fn onWindowDestroyed(widget: *anyopaque, _: ?*anyopaque) callconv(.c) void { | |
| 223 | _ = desktop_windows.forgetWindow(@intFromPtr(widget)); | |
| 247 | 224 | } |
| 248 | 225 | |
| 249 | 226 | // Application state |
| 250 | 227 | var app_instance: ?*anyopaque = null; |
| 251 | var current_window: ?*anyopaque = null; | |
| 252 | 228 | |
| 253 | 229 | pub const WindowStyle = struct { |
| 254 | 230 | frameless: bool = false, |
| @@ -289,7 +265,6 @@ pub const Window = struct { | ||
| 289 | 265 | } |
| 290 | 266 | |
| 291 | 267 | const window = gtk_application_window_new(app_instance.?); |
| 292 | current_window = window; | |
| 293 | 268 | |
| 294 | 269 | // Create WebView |
| 295 | 270 | const webview = webkit_web_view_new(); |
| @@ -362,7 +337,15 @@ pub const Window = struct { | ||
| 362 | 337 | gtk_container_add(window, webview); |
| 363 | 338 | |
| 364 | 339 | // Register window in the multi-window registry |
| 365 | const window_id = registerWindow(window, webview) orelse return error.TooManyWindows; | |
| 340 | const window_id = registerWindow(window, webview) orelse { | |
| 341 | gtk_window_close(window); | |
| 342 | return error.TooManyWindows; | |
| 343 | }; | |
| 344 | if (g_signal_connect_data(window, "destroy", @ptrCast(&onWindowDestroyed), null, null, 0) == 0) { | |
| 345 | _ = desktop_windows.forgetWindow(@intFromPtr(window)); | |
| 346 | gtk_window_close(window); | |
| 347 | return error.SignalConnectionFailed; | |
| 348 | } | |
| 366 | 349 | |
| 367 | 350 | return Window{ |
| 368 | 351 | .id = window_id, |
| @@ -386,24 +369,9 @@ pub const Window = struct { | ||
| 386 | 369 | } |
| 387 | 370 | |
| 388 | 371 | pub fn close(self: *Window) void { |
| 389 | // Remove from window registry | |
| 390 | unregisterWindow(self.id); | |
| 391 | ||
| 392 | // Update current_window if this was the active one | |
| 393 | if (current_window == self.gtk_window) { | |
| 394 | current_window = null; | |
| 395 | // Set current_window to the most recently registered window, if any | |
| 396 | var latest_id: u32 = 0; | |
| 397 | for (window_registry) |entry| { | |
| 398 | if (entry) |e| { | |
| 399 | if (e.id > latest_id) { | |
| 400 | latest_id = e.id; | |
| 401 | current_window = e.gtk_window; | |
| 402 | } | |
| 403 | } | |
| 404 | } | |
| 405 | } | |
| 406 | ||
| 372 | // The destroy signal unregisters only after GTK actually closes it. | |
| 373 | const live = desktop_windows.byId(self.id) orelse return; | |
| 374 | if (live.window != @intFromPtr(self.gtk_window)) return; | |
| 407 | 375 | gtk_window_close(self.gtk_window); |
| 408 | 376 | } |
| 409 | 377 | |
| @@ -494,22 +462,10 @@ pub const App = struct { | ||
| 494 | 462 | /// Evaluate JavaScript in the page that sent the current bridge message. |
| 495 | 463 | /// Native callers without a sender continue to use the latest live window. |
| 496 | 464 | pub fn evalJS(script: []const u8) !void { |
| 497 | var live: [window_registry.len]usize = undefined; | |
| 498 | var live_len: usize = 0; | |
| 499 | var latest_id: u32 = 0; | |
| 500 | var fallback: ?usize = null; | |
| 501 | for (window_registry) |entry| { | |
| 502 | if (entry) |e| { | |
| 503 | live[live_len] = @intFromPtr(e.webview); | |
| 504 | live_len += 1; | |
| 505 | if (e.id > latest_id) { | |
| 506 | latest_id = e.id; | |
| 507 | fallback = @intFromPtr(e.webview); | |
| 508 | } | |
| 509 | } | |
| 510 | } | |
| 511 | ||
| 512 | const target = window_reply_target.select(live[0..live_len], window_context.currentWebView(), fallback) orelse | |
| 465 | var handles: [desktop_window_registry.capacity]usize = undefined; | |
| 466 | const live = desktop_windows.liveWebviews(&handles); | |
| 467 | const latest = desktop_windows.latest(); | |
| 468 | const target = window_reply_target.select(live, window_context.currentWebView(), if (latest) |entry| entry.webview else null) orelse | |
| 513 | 469 | return error.NoWebView; |
| 514 | 470 | const script_z = try .dupeZ(std.heap.c_allocator, u8, script); |
| 515 | 471 | defer std.heap.c_allocator.free(script_z); |