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.
| @@ -1287,6 +1287,14 @@ pub fn build(b: *std.Build) void { | ||
| 1287 | 1287 | }), |
| 1288 | 1288 | }); |
| 1289 | 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 | ||
| 1290 | 1298 | const desktop_script_encoding_tests = b.addTest(.{ |
| 1291 | 1299 | .root_module = b.createModule(.{ |
| 1292 | 1300 | .root_source_file = b.path("src/desktop_script_encoding.zig"), |
| @@ -1753,6 +1761,7 @@ pub fn build(b: *std.Build) void { | ||
| 1753 | 1761 | const run_desktop_bridge_envelope_tests = b.addRunArtifact(desktop_bridge_envelope_tests); |
| 1754 | 1762 | const run_desktop_bridge_text_tests = b.addRunArtifact(desktop_bridge_text_tests); |
| 1755 | 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); | |
| 1756 | 1765 | const run_desktop_script_encoding_tests = b.addRunArtifact(desktop_script_encoding_tests); |
| 1757 | 1766 | const run_external_link_tests = b.addRunArtifact(external_link_tests); |
| 1758 | 1767 | const run_webview_recovery_tests = b.addRunArtifact(webview_recovery_tests); |
| @@ -1907,6 +1916,7 @@ pub fn build(b: *std.Build) void { | ||
| 1907 | 1916 | test_step.dependOn(&run_desktop_bridge_envelope_tests.step); |
| 1908 | 1917 | test_step.dependOn(&run_desktop_bridge_text_tests.step); |
| 1909 | 1918 | test_step.dependOn(&run_desktop_window_events_tests.step); |
| 1919 | test_step.dependOn(&run_desktop_window_reads_tests.step); | |
| 1910 | 1920 | test_step.dependOn(&run_desktop_script_encoding_tests.step); |
| 1911 | 1921 | test_step.dependOn(&run_external_link_tests.step); |
| 1912 | 1922 | test_step.dependOn(&run_webview_recovery_tests.step); |
| @@ -0,0 +1,70 @@ | ||
| 1 | //! Results for read-only desktop window actions. The native caller supplies | |
| 2 | //! live OS values; this module preserves the SDK's JSON reply shapes. | |
| 3 | ||
| 4 | const std = ; | |
| 5 | const bridge_error = ; | |
| 6 | const desktop_window_registry = ; | |
| 7 | ||
| 8 | pub fn string(allocator: std.mem.Allocator, value: ?[]const u8) ![]u8 { | |
| 9 | const text = value orelse return allocator.dupe(u8, "null"); | |
| 10 | var json: std.ArrayListUnmanaged(u8) = .empty; | |
| 11 | errdefer json.deinit(allocator); | |
| 12 | try json.append(allocator, '"'); | |
| 13 | try bridge_error.appendJsonEscaped(allocator, &json, text); | |
| 14 | try json.append(allocator, '"'); | |
| 15 | return json.toOwnedSlice(allocator); | |
| 16 | } | |
| 17 | ||
| 18 | pub fn geometry(allocator: std.mem.Allocator, action: []const u8, bounds: desktop_window_registry.Geometry) ![]u8 { | |
| 19 | if (std.mem.eql(u8, action, "getSize")) | |
| 20 | return std.fmt.allocPrint(allocator, "", .{ bounds.width, bounds.height }); | |
| 21 | if (std.mem.eql(u8, action, "getPosition")) | |
| 22 | return std.fmt.allocPrint(allocator, "", .{ bounds.x, bounds.y }); | |
| 23 | if (std.mem.eql(u8, action, "getBounds")) | |
| 24 | return std.fmt.allocPrint(allocator, "", .{ bounds.x, bounds.y, bounds.width, bounds.height }); | |
| 25 | return error.UnknownAction; | |
| 26 | } | |
| 27 | ||
| 28 | /// Never return a name for an OS window that has already been destroyed or | |
| 29 | /// belongs to another process. Every page calls its own window `main`. | |
| 30 | pub fn focusedName( | |
| 31 | live: *const desktop_window_registry.Registry, | |
| 32 | sender: usize, | |
| 33 | focused: usize, | |
| 34 | named: ?[]const u8, | |
| 35 | ) ?[]const u8 { | |
| 36 | if (live.byWindow(focused) == null) return null; | |
| 37 | if (sender == focused) return "main"; | |
| 38 | return named; | |
| 39 | } | |
| 40 | ||
| 41 | test "read results preserve SDK shapes and escape names" { | |
| 42 | const allocator = std.testing.allocator; | |
| 43 | const name = try string(allocator, "set\"tings"); | |
| 44 | defer allocator.free(name); | |
| 45 | try std.testing.expectEqualStrings("\"set\\\"tings\"", name); | |
| 46 | const absent = try string(allocator, null); | |
| 47 | defer allocator.free(absent); | |
| 48 | try std.testing.expectEqualStrings("null", absent); | |
| 49 | const bounds: desktop_window_registry.Geometry = .{ .x = -20, .y = 30, .width = 800, .height = 600 }; | |
| 50 | const size = try geometry(allocator, "getSize", bounds); | |
| 51 | defer allocator.free(size); | |
| 52 | try std.testing.expectEqualStrings("{\"width\":800,\"height\":600}", size); | |
| 53 | const position = try geometry(allocator, "getPosition", bounds); | |
| 54 | defer allocator.free(position); | |
| 55 | try std.testing.expectEqualStrings("{\"x\":-20,\"y\":30}", position); | |
| 56 | const full = try geometry(allocator, "getBounds", bounds); | |
| 57 | defer allocator.free(full); | |
| 58 | try std.testing.expectEqualStrings("{\"x\":-20,\"y\":30,\"width\":800,\"height\":600}", full); | |
| 59 | } | |
| 60 | ||
| 61 | test "focused name is scoped to a live window and the sender alias" { | |
| 62 | var live: desktop_window_registry.Registry = .{}; | |
| 63 | _ = live.remember(0x1000, 0x1001); | |
| 64 | _ = live.remember(0x2000, 0x2001); | |
| 65 | try std.testing.expectEqualStrings("main", focusedName(&live, 0x1000, 0x1000, null).?); | |
| 66 | try std.testing.expectEqualStrings("settings", focusedName(&live, 0x1000, 0x2000, "settings").?); | |
| 67 | try std.testing.expect(focusedName(&live, 0x1000, 0x3000, "other") == null); | |
| 68 | _ = live.forgetWindow(0x2000); | |
| 69 | try std.testing.expect(focusedName(&live, 0x1000, 0x2000, "settings") == null); | |
| 70 | } | |
| @@ -3,6 +3,7 @@ const bridge_error = ; | ||
| 3 | 3 | const desktop_bridge_envelope = ; |
| 4 | 4 | const desktop_window_registry = ; |
| 5 | 5 | const desktop_window_events = ; |
| 6 | const desktop_window_reads = ; | |
| 6 | 7 | const json_utils = ; |
| 7 | 8 | const request_context = ; |
| 8 | 9 | const window_context = ; |
| @@ -33,6 +34,8 @@ pub extern "c" fn gtk_widget_show(widget: *anyopaque) void; | ||
| 33 | 34 | pub extern "c" fn gtk_window_set_position(window: *anyopaque, x: c_int, y: c_int) void; |
| 34 | 35 | pub extern "c" fn gtk_window_get_position(window: *anyopaque, x: *c_int, y: *c_int) void; |
| 35 | 36 | pub extern "c" fn gtk_window_get_size(window: *anyopaque, width: *c_int, height: *c_int) void; |
| 37 | pub extern "c" fn gtk_window_get_title(window: *anyopaque) ?[*:0]const u8; | |
| 38 | pub extern "c" fn gtk_window_is_active(window: *anyopaque) c_int; | |
| 36 | 39 | pub extern "c" fn gtk_widget_get_window(widget: *anyopaque) ?*anyopaque; |
| 37 | 40 | pub extern "c" fn gdk_window_get_state(window: *anyopaque) c_uint; |
| 38 | 41 | |
| @@ -416,9 +419,48 @@ fn targetWindow(data: ?[]const u8) !desktop_window_registry.Entry { | ||
| 416 | 419 | return desktop_windows.byWindow(window_context.current() orelse return error.WindowHandleNotSet) orelse error.WindowHandleNotSet; |
| 417 | 420 | } |
| 418 | 421 | |
| 422 | fn windowGeometry(window: *anyopaque) !desktop_window_registry.Geometry { | |
| 423 | var x: c_int = 0; | |
| 424 | var y: c_int = 0; | |
| 425 | var width: c_int = 0; | |
| 426 | var height: c_int = 0; | |
| 427 | gtk_window_get_position(window, &x, &y); | |
| 428 | gtk_window_get_size(window, &width, &height); | |
| 429 | if (width < 0 or height < 0) return error.NativeCallFailed; | |
| 430 | return .{ .x = x, .y = y, .width = @intCast(width), .height = @intCast(height) }; | |
| 431 | } | |
| 432 | ||
| 433 | fn sendWindowRead(action: []const u8, data: ?[]const u8) !void { | |
| 434 | const allocator = std.heap.c_allocator; | |
| 435 | const entry = try targetWindow(data); | |
| 436 | const window: *anyopaque = @ptrFromInt(entry.window); | |
| 437 | const json = if (std.mem.eql(u8, action, "getTitle")) blk: { | |
| 438 | const title = gtk_window_get_title(window); | |
| 439 | break :blk try desktop_window_reads.string(allocator, if (title) |text| std.mem.span(text) else ""); | |
| 440 | } else if (std.mem.eql(u8, action, "getFocused")) blk: { | |
| 441 | var focused: usize = 0; | |
| 442 | for (desktop_windows.entries) |slot| { | |
| 443 | if (slot) |candidate| { | |
| 444 | if (gtk_window_is_active(@ptrFromInt(candidate.window)) != 0) { | |
| 445 | focused = candidate.window; | |
| 446 | break; | |
| 447 | } | |
| 448 | } | |
| 449 | } | |
| 450 | const name = desktop_window_reads.focusedName(&desktop_windows, entry.window, focused, window_registry.nameOf(focused)); | |
| 451 | break :blk try desktop_window_reads.string(allocator, name); | |
| 452 | } else try desktop_window_reads.geometry(allocator, action, try windowGeometry(window)); | |
| 453 | defer allocator.free(json); | |
| 454 | bridge_error.sendResultToJS(allocator, action, json); | |
| 455 | } | |
| 456 | ||
| 419 | 457 | fn handleWindowAction(action: []const u8, data: ?[]const u8) !void { |
| 420 | 458 | if (std.mem.eql(u8, action, "open") or std.mem.eql(u8, action, "create")) |
| 421 | 459 | return openNamedWindow(action, data); |
| 460 | if (std.mem.eql(u8, action, "getTitle") or std.mem.eql(u8, action, "getSize") or | |
| 461 | std.mem.eql(u8, action, "getPosition") or std.mem.eql(u8, action, "getBounds") or | |
| 462 | std.mem.eql(u8, action, "getFocused")) | |
| 463 | return sendWindowRead(action, data); | |
| 422 | 464 | |
| 423 | 465 | const entry = try targetWindow(data); |
| 424 | 466 | const window: *anyopaque = @ptrFromInt(entry.window); |
| @@ -4,6 +4,7 @@ const desktop_bridge_envelope = ; | ||
| 4 | 4 | const desktop_bridge_text = ; |
| 5 | 5 | const desktop_script_encoding = ; |
| 6 | 6 | const desktop_window_events = ; |
| 7 | const desktop_window_reads = ; | |
| 7 | 8 | const desktop_window_registry = ; |
| 8 | 9 | const json_utils = ; |
| 9 | 10 | const request_context = ; |
| @@ -123,6 +124,9 @@ pub extern "user32" fn DefWindowProcW(hWnd: HWND, Msg: UINT, wParam: WPARAM, lPa | ||
| 123 | 124 | pub extern "user32" fn PostQuitMessage(nExitCode: c_int) callconv(.c) void; |
| 124 | 125 | pub extern "user32" fn DestroyWindow(hWnd: HWND) callconv(.c) BOOL; |
| 125 | 126 | pub extern "user32" fn SetWindowTextW(hWnd: HWND, lpString: LPCWSTR) callconv(.c) BOOL; |
| 127 | pub extern "user32" fn GetWindowTextLengthW(hWnd: HWND) callconv(.c) c_int; | |
| 128 | pub extern "user32" fn GetWindowTextW(hWnd: HWND, lpString: LPWSTR, nMaxCount: c_int) callconv(.c) c_int; | |
| 129 | pub extern "user32" fn GetForegroundWindow() callconv(.c) ?HWND; | |
| 126 | 130 | pub extern "user32" fn SetWindowPos(hWnd: HWND, hWndInsertAfter: ?HWND, X: c_int, Y: c_int, cx: c_int, cy: c_int, uFlags: UINT) callconv(.c) BOOL; |
| 127 | 131 | pub extern "user32" fn LoadCursorW(hInstance: ?HINSTANCE, lpCursorName: LPCWSTR) callconv(.c) ?*anyopaque; |
| 128 | 132 | pub extern "user32" fn GetClientRect(hWnd: HWND, lpRect: *RECT) callconv(.c) BOOL; |
| @@ -979,9 +983,53 @@ fn targetWindow(data: ?[]const u8) !desktop_window_registry.Entry { | ||
| 979 | 983 | return desktop_windows.byWindow(window_context.current() orelse return error.WindowHandleNotSet) orelse error.WindowHandleNotSet; |
| 980 | 984 | } |
| 981 | 985 | |
| 986 | fn windowGeometry(hwnd: HWND) !desktop_window_registry.Geometry { | |
| 987 | var rect: RECT = undefined; | |
| 988 | if (GetWindowRect(hwnd, &rect) == 0) return error.NativeCallFailed; | |
| 989 | const width = @as(i64, rect.right) - @as(i64, rect.left); | |
| 990 | const height = @as(i64, rect.bottom) - @as(i64, rect.top); | |
| 991 | if (width < 0 or height < 0 or width > std.math.maxInt(u32) or height > std.math.maxInt(u32)) | |
| 992 | return error.NativeCallFailed; | |
| 993 | return .{ .x = @intCast(rect.left), .y = @intCast(rect.top), .width = @intCast(width), .height = @intCast(height) }; | |
| 994 | } | |
| 995 | ||
| 996 | fn windowTitle(allocator: std.mem.Allocator, hwnd: HWND) ![:0]u8 { | |
| 997 | const length = GetWindowTextLengthW(hwnd); | |
| 998 | if (length < 0) return error.NativeCallFailed; | |
| 999 | if (@as(usize, @intCast(length)) >= desktop_bridge_envelope.max_message_bytes) | |
| 1000 | return error.MessageTooLarge; | |
| 1001 | const wide = try allocator.alloc(u16, @as(usize, @intCast(length)) + 1); | |
| 1002 | defer allocator.free(wide); | |
| 1003 | const copied = GetWindowTextW(hwnd, @ptrCast(wide.ptr), @intCast(wide.len)); | |
| 1004 | if (copied < 0) return error.NativeCallFailed; | |
| 1005 | const utf8 = try desktop_bridge_text.fromUtf16(allocator, wide[0..@intCast(copied)]); | |
| 1006 | return utf8; | |
| 1007 | } | |
| 1008 | ||
| 1009 | fn sendWindowRead(action: []const u8, data: ?[]const u8) !void { | |
| 1010 | const allocator = std.heap.c_allocator; | |
| 1011 | const entry = try targetWindow(data); | |
| 1012 | const hwnd: HWND = @ptrFromInt(entry.window); | |
| 1013 | const json = if (std.mem.eql(u8, action, "getTitle")) blk: { | |
| 1014 | const title = try windowTitle(allocator, hwnd); | |
| 1015 | defer allocator.free(title); | |
| 1016 | break :blk try desktop_window_reads.string(allocator, title); | |
| 1017 | } else if (std.mem.eql(u8, action, "getFocused")) blk: { | |
| 1018 | const focused = if (GetForegroundWindow()) |window| @intFromPtr(window) else 0; | |
| 1019 | const name = desktop_window_reads.focusedName(&desktop_windows, entry.window, focused, window_registry.nameOf(focused)); | |
| 1020 | break :blk try desktop_window_reads.string(allocator, name); | |
| 1021 | } else try desktop_window_reads.geometry(allocator, action, try windowGeometry(hwnd)); | |
| 1022 | defer allocator.free(json); | |
| 1023 | bridge_error.sendResultToJS(allocator, action, json); | |
| 1024 | } | |
| 1025 | ||
| 982 | 1026 | fn handleWindowAction(action: []const u8, data: ?[]const u8) !void { |
| 983 | 1027 | if (std.mem.eql(u8, action, "open") or std.mem.eql(u8, action, "create")) |
| 984 | 1028 | return openNamedWindow(action, data); |
| 1029 | if (std.mem.eql(u8, action, "getTitle") or std.mem.eql(u8, action, "getSize") or | |
| 1030 | std.mem.eql(u8, action, "getPosition") or std.mem.eql(u8, action, "getBounds") or | |
| 1031 | std.mem.eql(u8, action, "getFocused")) | |
| 1032 | return sendWindowRead(action, data); | |
| 985 | 1033 | |
| 986 | 1034 | const entry = try targetWindow(data); |
| 987 | 1035 | const hwnd: HWND = @ptrFromInt(entry.window); |
| @@ -1007,7 +1055,7 @@ fn handleWindowAction(action: []const u8, data: ?[]const u8) !void { | ||
| 1007 | 1055 | defer std.heap.c_allocator.free(title); |
| 1008 | 1056 | if (std.mem.indexOfScalar(u8, title, 0) != null) return error.InvalidParameter; |
| 1009 | 1057 | const title_wide = utf8ToUtf16Z(256, title) catch return error.InvalidParameter; |
| 1010 | if (SetWindowTextW(hwnd, &title_wide) == 0) return error.NativeCallFailed; | |
| 1058 | if (SetWindowTextW(hwnd, @ptrCast(&title_wide)) == 0) return error.NativeCallFailed; | |
| 1011 | 1059 | } else if (std.mem.eql(u8, action, "setSize")) { |
| 1012 | 1060 | const json = data orelse return error.MissingData; |
| 1013 | 1061 | const width = json_utils.getInt(u32, json, "width") orelse return error.InvalidParameter; |
| @@ -1264,7 +1312,7 @@ pub const Window = struct { | ||
| 1264 | 1312 | pub fn setTitle(self: *Window, title: []const u8) void { |
| 1265 | 1313 | if (self.liveEntry() == null) return; |
| 1266 | 1314 | const title_wide_buf = utf8ToUtf16Z(256, title) catch return; |
| 1267 | _ = SetWindowTextW(self.hwnd, &title_wide_buf); | |
| 1315 | _ = SetWindowTextW(self.hwnd, @ptrCast(&title_wide_buf)); | |
| 1268 | 1316 | self.title = title; |
| 1269 | 1317 | } |
| 1270 | 1318 | |