ReviewOS

also looking at this

craft-native/craft

feat: portable typed window controls on Linux and Windows

#326
Merged glennmichael123 wants to merge codex/portable-window-parity into main
9 files +652 -32
docs/architecture/multi-window-ownership.mdmodified+6-6
Changes to docs/architecture/multi-window-ownership.md
@@ -137,12 +137,12 @@ The next multi-window milestone needs product decisions in addition to code:
137137 application-level asynchronous source above.
1381383. Decide whether Touch Bar should stay primary-window scoped or follow the
139139 key window with separately owned item definitions and callbacks.
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.
1404. Decide the portability contract for advanced actions beyond bounds,
141 centering, resizability, size limits and fullscreen. Closing still differs:
142 macOS retains a closed page, while Linux and Windows release it.
1435. Expand platform-native integration beyond named-window lifecycle and core
144 geometry/control smokes. CI runs Linux WebKitGTK under Xvfb and Windows
145 WebView2 on a native runner; packaged-app behavior still needs coverage.
146146
147147## Review checklist
148148
docs/features/window-management.mdmodified+19-3
Changes to docs/features/window-management.md
@@ -32,8 +32,10 @@ The `craft-native` SDK returns a typed `Window` handle. Runtime-created
3232windows are available on macOS, Linux, and Windows and must provide either
3333`html` or `url` content. The common create, close, title, geometry, and event
3434routes 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.
35Bounds, centering, resizing constraints, resizability and fullscreen controls
36also work on the typed handle on all three desktop platforms. On Linux,
37position and fullscreen requests are advisory to the window manager (and
38global positioning can be unavailable under Wayland).
3739
3840```typescript
3941import { createWindow } from 'craft-native'
@@ -62,7 +64,21 @@ const settings = await createWindow({
6264})
6365
6466await settings.setTitle('Settings — Account')
65```
67await settings.setBounds({ x: 120, y: 80, width: 800 })
68await settings.setMinimumSize(480, 360)
69await settings.setMaximumSize(1600, 1200)
70await settings.setResizable(false)
71const canResize = await settings.isResizable()
72await settings.center()
73await settings.setFullscreen(true)
74await settings.toggleFullscreen()
75```
76
77The bounds update preserves omitted coordinates and dimensions. Native window
78managers can adjust requested geometry for decorations or screen constraints;
79read `getBounds()` after a resize event if you need the actual result.
80Creation-time `minWidth`/`minHeight` and `maxWidth`/`maxHeight` may constrain
81either axis independently; the native window system's own minimum still applies.
6682
6783The older `createWindow(html, options)` overload remains available when the
6884content is already in a string.
packages/typescript/src/__tests__/window-routing.test.tsmodified+24-0
Changes to packages/typescript/src/__tests__/window-routing.test.ts
@@ -86,6 +86,30 @@ describe('typed window-handle routing', () => {
8686 ])
8787 })
8888
89 it('routes portable controls and their readbacks to the retained child', async () => {
90 const settings = new Window('settings')
91
92 await settings.setBounds({ x: -20, width: 700 })
93 await settings.center()
94 await settings.setResizable(false)
95 callResult = false
96 expect(await settings.isResizable()).toBe(false)
97 await settings.setFullscreen(true)
98 await settings.toggleFullscreen()
99
100 expect(call.mock.calls.map(args => [args[0], args[2]])).toEqual([
101 ['setBounds', 'settings'],
102 ['center', 'settings'],
103 ['setResizable', 'settings'],
104 ['isResizable', 'settings'],
105 ['setFullscreen', 'settings'],
106 ['toggleFullscreen', 'settings'],
107 ])
108 expect(call.mock.calls[0]?.[1]).toEqual({ x: -20, width: 700, animate: undefined })
109 expect(call.mock.calls[2]?.[1]).toEqual({ resizable: false })
110 expect(call.mock.calls[4]?.[1]).toEqual({ fullscreen: true })
111 })
112
89113 it('loads content in the retained handle instead of the calling page', async () => {
90114 const settings = new Window('settings')
91115
packages/zig/build.zigmodified+10-0
Changes to packages/zig/build.zig
@@ -1273,6 +1273,14 @@ pub fn build(b: *std.Build) void {
12731273 }),
12741274 });
12751275
1276 const desktop_window_controls_tests = b.addTest(.{
1277 .root_module = b.createModule(.{
1278 .root_source_file = b.path("src/desktop_window_controls.zig"),
1279 .target = target,
1280 .optimize = optimize,
1281 }),
1282 });
1283
12761284 const desktop_bridge_envelope_tests = b.addTest(.{
12771285 .root_module = b.createModule(.{
12781286 .root_source_file = b.path("src/desktop_bridge_envelope.zig"),
@@ -1768,6 +1776,7 @@ pub fn build(b: *std.Build) void {
17681776 const run_window_context_tests = b.addRunArtifact(window_context_tests);
17691777 const run_window_reply_target_tests = b.addRunArtifact(window_reply_target_tests);
17701778 const run_desktop_window_registry_tests = b.addRunArtifact(desktop_window_registry_tests);
1779 const run_desktop_window_controls_tests = b.addRunArtifact(desktop_window_controls_tests);
17711780 const run_desktop_bridge_envelope_tests = b.addRunArtifact(desktop_bridge_envelope_tests);
17721781 const run_desktop_bridge_text_tests = b.addRunArtifact(desktop_bridge_text_tests);
17731782 const run_desktop_window_events_tests = b.addRunArtifact(desktop_window_events_tests);
@@ -1924,6 +1933,7 @@ pub fn build(b: *std.Build) void {
19241933 test_step.dependOn(&run_window_context_tests.step);
19251934 test_step.dependOn(&run_window_reply_target_tests.step);
19261935 test_step.dependOn(&run_desktop_window_registry_tests.step);
1936 test_step.dependOn(&run_desktop_window_controls_tests.step);
19271937 test_step.dependOn(&run_desktop_bridge_envelope_tests.step);
19281938 test_step.dependOn(&run_desktop_bridge_text_tests.step);
19291939 test_step.dependOn(&run_desktop_window_events_tests.step);
packages/zig/src/desktop_window_controls.zigadded+190-0
Changes to packages/zig/src/desktop_window_controls.zig
@@ -0,0 +1,190 @@
1//! Validated, platform-neutral inputs for the portable window controls.
2const std = ;
3
4pub const Bounds = struct {
5 x: ?i32 = null,
6 y: ?i32 = null,
7 width: ?u32 = null,
8 height: ?u32 = null,
9};
10
11pub const Size = struct { width: u32, height: u32 };
12pub const Position = struct { x: i32, y: i32 };
13pub const Workarea = struct { x: i32, y: i32, width: u32, height: u32 };
14
15pub fn centerIn(area: Workarea, size: Size) !Position {
16 const x = @as(i64, area.x) + @divTrunc(@as(i64, area.width) - @as(i64, size.width), 2);
17 const y = @as(i64, area.y) + @divTrunc(@as(i64, area.height) - @as(i64, size.height), 2);
18 return .{
19 .x = std.math.cast(i32, x) orelse return error.InvalidParameter,
20 .y = std.math.cast(i32, y) orelse return error.InvalidParameter,
21 };
22}
23
24pub const Limits = struct {
25 minimum: ?Size = null,
26 maximum: ?Size = null,
27
28 pub fn withMinimum(self: Limits, size: Size) !Limits {
29 if (self.maximum) |max| {
30 if (size.width > max.width or size.height > max.height) return error.InvalidParameter;
31 }
32 var next = self;
33 next.minimum = size;
34 return next;
35 }
36
37 pub fn withMaximum(self: Limits, size: Size) !Limits {
38 if (self.minimum) |min| {
39 if (size.width < min.width or size.height < min.height) return error.InvalidParameter;
40 }
41 var next = self;
42 next.maximum = size;
43 return next;
44 }
45
46 pub fn clamp(self: Limits, size: Size) Size {
47 var result = size;
48 if (self.minimum) |min| {
49 result.width = @max(result.width, min.width);
50 result.height = @max(result.height, min.height);
51 }
52 if (self.maximum) |max| {
53 result.width = @min(result.width, max.width);
54 result.height = @min(result.height, max.height);
55 }
56 return result;
57 }
58};
59
60fn object(data: []const u8) !std.json.Parsed(std.json.Value) {
61 var parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, data, .{}) catch return error.InvalidParameter;
62 if (parsed.value != .object) {
63 parsed.deinit();
64 return error.InvalidParameter;
65 }
66 return parsed;
67}
68
69fn integer(comptime T: type, value: std.json.Value) !T {
70 if (value != .integer) return error.InvalidParameter;
71 return std.math.cast(T, value.integer) orelse error.InvalidParameter;
72}
73
74fn optionalInteger(comptime T: type, value: std.json.Value, key: []const u8) !?T {
75 const field = value.object.get(key) orelse return null;
76 return try integer(T, field);
77}
78
79pub fn parseBounds(data: ?[]const u8) !Bounds {
80 var parsed = try object(data orelse return error.MissingData);
81 defer parsed.deinit();
82 const value = parsed.value;
83 const result: Bounds = .{
84 .x = try optionalInteger(i32, value, "x"),
85 .y = try optionalInteger(i32, value, "y"),
86 .width = try optionalInteger(u32, value, "width"),
87 .height = try optionalInteger(u32, value, "height"),
88 };
89 if (result.width == 0 or result.height == 0) return error.InvalidParameter;
90 if (result.width) |width| {
91 if (width > std.math.maxInt(c_int)) return error.InvalidParameter;
92 }
93 if (result.height) |height| {
94 if (height > std.math.maxInt(c_int)) return error.InvalidParameter;
95 }
96 return result;
97}
98
99pub fn parseSize(data: ?[]const u8) !Size {
100 var parsed = try object(data orelse return error.MissingData);
101 defer parsed.deinit();
102 const value = parsed.value;
103 const width = try integer(u32, value.object.get("width") orelse return error.InvalidParameter);
104 const height = try integer(u32, value.object.get("height") orelse return error.InvalidParameter);
105 if (width == 0 or height == 0 or width > std.math.maxInt(c_int) or height > std.math.maxInt(c_int))
106 return error.InvalidParameter;
107 return .{ .width = width, .height = height };
108}
109
110/// Creation options may constrain one axis without constraining the other.
111/// Use effective no-op limits on omitted axes so both GTK and Win32 can keep
112/// a single pair of minimum/maximum dimensions per native window.
113pub fn parseCreateLimits(data: ?[]const u8) !Limits {
114 var parsed = try object(data orelse return error.MissingData);
115 defer parsed.deinit();
116 const value = parsed.value;
117 const min_width = try optionalInteger(u32, value, "minWidth");
118 const min_height = try optionalInteger(u32, value, "minHeight");
119 const max_width = try optionalInteger(u32, value, "maxWidth");
120 const max_height = try optionalInteger(u32, value, "maxHeight");
121 const limit: u32 = std.math.maxInt(c_int);
122 var limits: Limits = .{};
123 if (min_width != null or min_height != null) {
124 const size: Size = .{ .width = min_width orelse 1, .height = min_height orelse 1 };
125 if (size.width == 0 or size.height == 0 or size.width > limit or size.height > limit) return error.InvalidParameter;
126 limits = try limits.withMinimum(size);
127 }
128 if (max_width != null or max_height != null) {
129 const size: Size = .{ .width = max_width orelse limit, .height = max_height orelse limit };
130 if (size.width == 0 or size.height == 0 or size.width > limit or size.height > limit) return error.InvalidParameter;
131 limits = try limits.withMaximum(size);
132 }
133 return limits;
134}
135
136pub fn parseBool(data: ?[]const u8, key: []const u8) !bool {
137 var parsed = try object(data orelse return error.MissingData);
138 defer parsed.deinit();
139 const value = parsed.value.object.get(key) orelse return error.InvalidParameter;
140 if (value != .bool) return error.InvalidParameter;
141 return value.bool;
142}
143
144test "partial bounds preserve missing fields and reject invalid dimensions" {
145 const only_x = try parseBounds("{\"x\":-12,\"animate\":true}");
146 try std.testing.expectEqual(@as(?i32, -12), only_x.x);
147 try std.testing.expect(only_x.y == null and only_x.width == null and only_x.height == null);
148 try std.testing.expectError(error.InvalidParameter, parseBounds("{\"width\":0}"));
149 try std.testing.expectError(error.InvalidParameter, parseBounds("{\"width\":1.5}"));
150 try std.testing.expectError(error.InvalidParameter, parseBounds("{\"x\":2147483648}"));
151}
152
153test "size limits reject crossing and clamp programmatic sizes" {
154 var limits: Limits = .{};
155 limits = try limits.withMinimum(.{ .width = 320, .height = 240 });
156 limits = try limits.withMaximum(.{ .width = 1024, .height = 768 });
157 try std.testing.expectError(error.InvalidParameter, limits.withMinimum(.{ .width = 1200, .height = 240 }));
158 try std.testing.expectError(error.InvalidParameter, limits.withMaximum(.{ .width = 100, .height = 768 }));
159 try std.testing.expectEqualDeep(Size{ .width = 320, .height = 768 }, limits.clamp(.{ .width = 100, .height = 900 }));
160 try std.testing.expectError(error.InvalidParameter, parseSize("{\"width\":-1,\"height\":200}"));
161}
162
163test "portable control inputs require actual JSON booleans and integers" {
164 try std.testing.expect(try parseBool("{\"fullscreen\":true}", "fullscreen"));
165 try std.testing.expect(!(try parseBool("{\"resizable\":false}", "resizable")));
166 try std.testing.expectError(error.InvalidParameter, parseBool("{\"fullscreen\":1}", "fullscreen"));
167 try std.testing.expectError(error.InvalidParameter, parseBool("{\"fullscreen\":null}", "fullscreen"));
168 try std.testing.expectError(error.InvalidParameter, parseSize("{\"width\":640.5,\"height\":480}"));
169 try std.testing.expectError(error.InvalidParameter, parseSize("{\"width\":640,\"height\":0}"));
170}
171
172test "centering uses the selected monitor workarea including negative origins" {
173 try std.testing.expectEqualDeep(Position{ .x = -1360, .y = 300 }, try centerIn(
174 .{ .x = -1920, .y = 0, .width = 1920, .height = 1080 },
175 .{ .width = 800, .height = 480 },
176 ));
177 try std.testing.expectEqualDeep(Position{ .x = 200, .y = 150 }, try centerIn(
178 .{ .x = 0, .y = 0, .width = 1200, .height = 900 },
179 .{ .width = 800, .height = 600 },
180 ));
181}
182
183test "creation limits preserve unconstrained axes and reject conflicts" {
184 const width_only = try parseCreateLimits("{\"minWidth\":320,\"maxWidth\":1024}");
185 try std.testing.expectEqualDeep(Size{ .width = 320, .height = 1 }, width_only.minimum.?);
186 try std.testing.expectEqualDeep(Size{ .width = 1024, .height = std.math.maxInt(c_int) }, width_only.maximum.?);
187 try std.testing.expectError(error.InvalidParameter, parseCreateLimits("{\"minWidth\":900,\"maxWidth\":800}"));
188 try std.testing.expectError(error.InvalidParameter, parseCreateLimits("{\"maxHeight\":0}"));
189 try std.testing.expectError(error.InvalidParameter, parseCreateLimits("{\"minWidth\":1.5}"));
190}
packages/zig/src/desktop_window_registry.zigmodified+49-0
Changes to packages/zig/src/desktop_window_registry.zig
@@ -5,6 +5,7 @@
55//! before any later bridge reply or resize selects its destroyed webview.
66
77const std = ;
8const desktop_window_controls = ;
89
910pub const capacity = 32;
1011pub const Geometry = struct {
@@ -35,6 +36,10 @@ pub const Entry = struct {
3536 geometry: ?Geometry = null,
3637 minimized: bool = false,
3738 fullscreen: bool = false,
39 limits: desktop_window_controls.Limits = .{},
40 /// Windows restores these when leaving borderless fullscreen.
41 windowed_geometry: ?Geometry = null,
42 windowed_style: ?isize = null,
3843};
3944
4045pub const Registry = struct {
@@ -101,6 +106,33 @@ pub const Registry = struct {
101106 return false;
102107 }
103108
109 pub fn setLimits(self: *Registry, window: usize, limits: desktop_window_controls.Limits) bool {
110 if (window == 0) return false;
111 for (&self.entries) |*slot| {
112 if (slot.*) |*entry| {
113 if (entry.window == window) {
114 entry.limits = limits;
115 return true;
116 }
117 }
118 }
119 return false;
120 }
121
122 pub fn setWindowedState(self: *Registry, window: usize, geometry: ?Geometry, style: ?isize) bool {
123 if (window == 0) return false;
124 for (&self.entries) |*slot| {
125 if (slot.*) |*entry| {
126 if (entry.window == window) {
127 entry.windowed_geometry = geometry;
128 entry.windowed_style = style;
129 return true;
130 }
131 }
132 }
133 return false;
134 }
135
104136 pub fn observeGeometry(self: *Registry, window: usize, next: Geometry) ?GeometryChange {
105137 if (window == 0) return null;
106138 for (&self.entries) |*slot| {
@@ -241,6 +273,23 @@ test "a web message subscription belongs only to its live window" {
241273 try std.testing.expect(registry.byWebview(0x1001) == null);
242274}
243275
276test "portable controls belong to one live window and reset on reopen" {
277 var registry: Registry = .{};
278 _ = registry.remember(0x1000, 0x1001);
279 _ = registry.remember(0x2000, 0x2001);
280 const limits: desktop_window_controls.Limits = .{ .minimum = .{ .width = 320, .height = 240 } };
281 try std.testing.expect(registry.setLimits(0x1000, limits));
282 try std.testing.expect(registry.setWindowedState(0x1000, .{ .x = 10, .y = 20, .width = 800, .height = 600 }, 0x55));
283 try std.testing.expect(registry.byWindow(0x2000).?.limits.minimum == null);
284 try std.testing.expect(registry.byWindow(0x2000).?.windowed_geometry == null);
285 try std.testing.expectEqual(@as(u32, 320), registry.byWindow(0x1000).?.limits.minimum.?.width);
286 _ = registry.forgetWindow(0x1000);
287 try std.testing.expect(!registry.setLimits(0x1000, limits));
288 _ = registry.remember(0x1000, 0x3001);
289 try std.testing.expect(registry.byWindow(0x1000).?.limits.minimum == null);
290 try std.testing.expect(registry.byWindow(0x1000).?.windowed_style == null);
291}
292
244293test "geometry changes belong to one live window and reset on reopen" {
245294 var registry: Registry = .{};
246295 _ = registry.remember(0x1000, 0x1001);
packages/zig/src/linux.zigmodified+92-6
Changes to packages/zig/src/linux.zig
@@ -1,6 +1,7 @@
11const std = ;
22const bridge_error = ;
33const desktop_bridge_envelope = ;
4const desktop_window_controls = ;
45const desktop_window_registry = ;
56const desktop_window_events = ;
67const desktop_window_reads = ;
@@ -27,6 +28,8 @@ pub extern "c" fn gtk_window_present(window: *anyopaque) void;
2728pub extern "c" fn gtk_window_close(window: *anyopaque) void;
2829pub extern "c" fn gtk_window_set_decorated(window: *anyopaque, decorated: c_int) void;
2930pub extern "c" fn gtk_window_set_resizable(window: *anyopaque, resizable: c_int) void;
31pub extern "c" fn gtk_window_get_resizable(window: *anyopaque) c_int;
32pub extern "c" fn gtk_window_set_geometry_hints(window: *anyopaque, geometry_widget: ?*anyopaque, geometry: ?*const GdkGeometry, geom_mask: c_int) void;
3033pub extern "c" fn gtk_window_fullscreen(window: *anyopaque) void;
3134pub extern "c" fn gtk_window_unfullscreen(window: *anyopaque) void;
3235pub extern "c" fn gtk_window_maximize(window: *anyopaque) void;
@@ -41,6 +44,23 @@ pub extern "c" fn gtk_window_get_title(window: *anyopaque) ?[*:0]const u8;
4144pub extern "c" fn gtk_window_is_active(window: *anyopaque) c_int;
4245pub extern "c" fn gtk_widget_get_window(widget: *anyopaque) ?*anyopaque;
4346pub extern "c" fn gdk_window_get_state(window: *anyopaque) c_uint;
47pub extern "c" fn gdk_display_get_monitor_at_window(display: *anyopaque, window: *anyopaque) ?*anyopaque;
48pub extern "c" fn gdk_monitor_get_workarea(monitor: *anyopaque, dest: *GdkRectangle) void;
49
50const GdkRectangle = extern struct { x: c_int, y: c_int, width: c_int, height: c_int };
51const GdkGeometry = extern struct {
52 min_width: c_int = 0,
53 min_height: c_int = 0,
54 max_width: c_int = 0,
55 max_height: c_int = 0,
56 base_width: c_int = 0,
57 base_height: c_int = 0,
58 width_inc: c_int = 0,
59 height_inc: c_int = 0,
60 min_aspect: f64 = 0,
61 max_aspect: f64 = 0,
62 win_gravity: c_int = 0,
63};
4464
4565pub extern "c" fn webkit_web_view_new() *anyopaque;
4666pub extern "c" fn webkit_web_view_load_uri(webview: *anyopaque, uri: [*:0]const u8) void;
@@ -387,6 +407,7 @@ fn openNamedWindow(action: []const u8, data: ?[]const u8) !void {
387407
388408 const width = json_utils.getInt(u32, json, "width") orelse 800;
389409 const height = json_utils.getInt(u32, json, "height") orelse 600;
410 const limits = try desktop_window_controls.parseCreateLimits(json);
390411 if (width == 0 or height == 0 or width > @as(u32, std.math.maxInt(c_int)) or height > @as(u32, std.math.maxInt(c_int)))
391412 return error.InvalidParameter;
392413 var created = try Window.create(.{
@@ -401,6 +422,8 @@ fn openNamedWindow(action: []const u8, data: ?[]const u8) !void {
401422 .dev_tools = json_utils.getBool(json, "devTools") orelse false,
402423 });
403424 errdefer created.close();
425 if (limits.minimum != null or limits.maximum != null)
426 try setWindowLimits(desktop_windows.byWindow(@intFromPtr(created.gtk_window)) orelse return error.WindowHandleNotSet, limits);
404427 if (url) |text| try created.loadURL(text) else if (html) |text| try created.loadHTML(text);
405428 if (!window_registry.rememberNamedOwned(@intFromPtr(created.gtk_window), name, owner))
406429 return error.TooManyWindows;
@@ -433,6 +456,47 @@ fn windowGeometry(window: *anyopaque) !desktop_window_registry.Geometry {
433456 return .{ .x = x, .y = y, .width = @intCast(width), .height = @intCast(height) };
434457}
435458
459fn applyWindowLimits(window: *anyopaque, limits: desktop_window_controls.Limits) void {
460 var geometry: GdkGeometry = .{};
461 var mask: c_int = 0;
462 if (limits.minimum) |min| {
463 geometry.min_width = @intCast(min.width);
464 geometry.min_height = @intCast(min.height);
465 mask |= 2; // GDK_HINT_MIN_SIZE
466 }
467 if (limits.maximum) |max| {
468 geometry.max_width = @intCast(max.width);
469 geometry.max_height = @intCast(max.height);
470 mask |= 4; // GDK_HINT_MAX_SIZE
471 }
472 gtk_window_set_geometry_hints(window, null, if (mask == 0) null else &geometry, mask);
473}
474
475fn setWindowLimits(entry: desktop_window_registry.Entry, limits: desktop_window_controls.Limits) !void {
476 const window: *anyopaque = @ptrFromInt(entry.window);
477 if (!desktop_windows.setLimits(entry.window, limits)) return error.WindowHandleNotSet;
478 applyWindowLimits(window, limits);
479 const current = try windowGeometry(window);
480 const clamped = limits.clamp(.{ .width = current.width, .height = current.height });
481 if (clamped.width != current.width or clamped.height != current.height)
482 gtk_window_resize(window, @intCast(clamped.width), @intCast(clamped.height));
483}
484
485fn centerWindow(window: *anyopaque) !void {
486 const gdk_window = gtk_widget_get_window(window) orelse return error.NativeCallFailed;
487 const display = gdk_display_get_default() orelse return error.NativeCallFailed;
488 const monitor = gdk_display_get_monitor_at_window(display, gdk_window) orelse return error.NativeCallFailed;
489 var workarea: GdkRectangle = undefined;
490 gdk_monitor_get_workarea(monitor, &workarea);
491 const bounds = try windowGeometry(window);
492 if (workarea.width <= 0 or workarea.height <= 0) return error.NativeCallFailed;
493 const position = try desktop_window_controls.centerIn(
494 .{ .x = workarea.x, .y = workarea.y, .width = @intCast(workarea.width), .height = @intCast(workarea.height) },
495 .{ .width = bounds.width, .height = bounds.height },
496 );
497 gtk_window_move(window, position.x, position.y);
498}
499
436500fn sendWindowRead(action: []const u8, data: ?[]const u8) !void {
437501 const allocator = std.heap.c_allocator;
438502 const entry = try targetWindow(data);
@@ -491,17 +555,35 @@ fn handleWindowAction(action: []const u8, data: ?[]const u8) !void {
491555 defer std.heap.c_allocator.free(title_z);
492556 gtk_window_set_title(window, title_z);
493557 } 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));
558 const size = entry.limits.clamp(try desktop_window_controls.parseSize(data));
559 gtk_window_resize(window, @intCast(size.width), @intCast(size.height));
500560 } else if (std.mem.eql(u8, action, "setPosition")) {
501561 const json = data orelse return error.MissingData;
502562 const x = json_utils.getInt(i32, json, "x") orelse return error.InvalidParameter;
503563 const y = json_utils.getInt(i32, json, "y") orelse return error.InvalidParameter;
504564 gtk_window_move(window, x, y);
565 } else if (std.mem.eql(u8, action, "setBounds")) {
566 const update = try desktop_window_controls.parseBounds(data);
567 const current = try windowGeometry(window);
568 if (update.width != null or update.height != null) {
569 const size = entry.limits.clamp(.{ .width = update.width orelse current.width, .height = update.height orelse current.height });
570 gtk_window_resize(window, @intCast(size.width), @intCast(size.height));
571 }
572 if (update.x != null or update.y != null)
573 gtk_window_move(window, update.x orelse current.x, update.y orelse current.y);
574 } else if (std.mem.eql(u8, action, "center")) {
575 try centerWindow(window);
576 } else if (std.mem.eql(u8, action, "setResizable")) {
577 gtk_window_set_resizable(window, if (try desktop_window_controls.parseBool(data, "resizable")) 1 else 0);
578 } else if (std.mem.eql(u8, action, "isResizable")) {
579 bridge_error.sendResultToJS(std.heap.c_allocator, action, if (gtk_window_get_resizable(window) != 0) "true" else "false");
580 } else if (std.mem.eql(u8, action, "setFullscreen") or std.mem.eql(u8, action, "toggleFullscreen")) {
581 const fullscreen = if (std.mem.eql(u8, action, "toggleFullscreen")) !entry.fullscreen else try desktop_window_controls.parseBool(data, "fullscreen");
582 if (fullscreen) gtk_window_fullscreen(window) else gtk_window_unfullscreen(window);
583 } else if (std.mem.eql(u8, action, "setMinimumSize") or std.mem.eql(u8, action, "setMaximumSize")) {
584 const size = try desktop_window_controls.parseSize(data);
585 const limits = if (std.mem.eql(u8, action, "setMinimumSize")) try entry.limits.withMinimum(size) else try entry.limits.withMaximum(size);
586 try setWindowLimits(entry, limits);
505587 } else if (std.mem.eql(u8, action, "loadURL") or std.mem.eql(u8, action, "loadHTML")) {
506588 const json = data orelse return error.MissingData;
507589 const key: []const u8 = if (std.mem.eql(u8, action, "loadURL")) "url" else "html";
@@ -516,6 +598,10 @@ fn handleWindowAction(action: []const u8, data: ?[]const u8) !void {
516598 }
517599}
518600
601test "Linux portable controls require an authenticated live sender" {
602 try std.testing.expectError(error.WindowHandleNotSet, handleWindowAction("setBounds", "{}"));
603}
604
519605// Application state
520606var app_instance: ?*anyopaque = null;
521607
packages/zig/src/windows.zigmodified+197-16
Changes to packages/zig/src/windows.zig
@@ -2,6 +2,7 @@ const std = ;
22const bridge_error = ;
33const desktop_bridge_envelope = ;
44const desktop_bridge_text = ;
5const desktop_window_controls = ;
56const desktop_script_encoding = ;
67const desktop_window_events = ;
78const desktop_window_reads = ;
@@ -63,6 +64,20 @@ pub const RECT = extern struct {
6364 right: c_long,
6465 bottom: c_long,
6566};
67pub const POINT = extern struct { x: c_long, y: c_long };
68pub const MINMAXINFO = extern struct {
69 ptReserved: POINT,
70 ptMaxSize: POINT,
71 ptMaxPosition: POINT,
72 ptMinTrackSize: POINT,
73 ptMaxTrackSize: POINT,
74};
75pub const MONITORINFO = extern struct {
76 cbSize: DWORD,
77 rcMonitor: RECT,
78 rcWork: RECT,
79 dwFlags: DWORD,
80};
6681
6782// COM base type
6883pub const GUID = extern struct {
@@ -82,6 +97,7 @@ pub const WS_OVERLAPPEDWINDOW: DWORD = 0x00CF0000;
8297pub const WS_VISIBLE: DWORD = 0x10000000;
8398pub const WS_POPUP: DWORD = 0x80000000;
8499pub const WS_THICKFRAME: DWORD = 0x00040000;
100pub const WS_MAXIMIZEBOX: DWORD = 0x00010000;
85101pub const WS_EX_TOPMOST: DWORD = 0x00000008;
86102pub const WS_EX_LAYERED: DWORD = 0x00080000;
87103pub const CW_USEDEFAULT: c_int = @bitCast(@as(c_uint, 0x80000000));
@@ -94,10 +110,18 @@ pub const WM_MOVE: UINT = 0x0003;
94110pub const WM_SIZE: UINT = 0x0005;
95111pub const WM_ACTIVATE: UINT = 0x0006;
96112pub const WM_CLOSE: UINT = 0x0010;
113pub const WM_GETMINMAXINFO: UINT = 0x0024;
97114pub const WM_QUIT: UINT = 0x0012;
98115const WM_CRAFT_OPEN_WINDOW: UINT = 0x8001; // WM_APP + 1
99116pub const PM_REMOVE: UINT = 0x0001;
100117pub const GWLP_USERDATA: c_int = -21;
118const GWL_STYLE: c_int = -16;
119const SWP_NOSIZE: UINT = 0x0001;
120const SWP_NOMOVE: UINT = 0x0002;
121const SWP_NOZORDER: UINT = 0x0004;
122const SWP_FRAMECHANGED: UINT = 0x0020;
123const SWP_NOACTIVATE: UINT = 0x0010;
124const MONITOR_DEFAULTTONEAREST: DWORD = 2;
101125
102126// Win32 API functions
103127pub extern "user32" fn RegisterClassExW(*const WNDCLASSEXW) callconv(.c) u16;
@@ -135,6 +159,8 @@ pub extern "user32" fn GetClientRect(hWnd: HWND, lpRect: *RECT) callconv(.c) BOO
135159pub extern "user32" fn GetWindowRect(hWnd: HWND, lpRect: *RECT) callconv(.c) BOOL;
136160pub extern "user32" fn SetWindowLongPtrW(hWnd: HWND, nIndex: c_int, dwNewLong: isize) callconv(.c) isize;
137161pub extern "user32" fn GetWindowLongPtrW(hWnd: HWND, nIndex: c_int) callconv(.c) isize;
162pub extern "user32" fn MonitorFromWindow(hWnd: HWND, dwFlags: DWORD) callconv(.c) ?*anyopaque;
163pub extern "user32" fn GetMonitorInfoW(hMonitor: *anyopaque, lpmi: *MONITORINFO) callconv(.c) BOOL;
138164pub extern "kernel32" fn GetModuleHandleW(lpModuleName: ?LPCWSTR) callconv(.c) ?HINSTANCE;
139165pub extern "kernel32" fn Sleep(dwMilliseconds: DWORD) callconv(.c) void;
140166pub extern "ole32" fn CoTaskMemFree(pv: ?*anyopaque) callconv(.c) void;
@@ -1044,6 +1070,7 @@ fn openNamedWindow(action: []const u8, data: ?[]const u8) !void {
10441070
10451071 const width = json_utils.getInt(u32, json, "width") orelse 800;
10461072 const height = json_utils.getInt(u32, json, "height") orelse 600;
1073 const limits = try desktop_window_controls.parseCreateLimits(json);
10471074 if (width == 0 or height == 0 or width > @as(u32, std.math.maxInt(c_int)) or height > @as(u32, std.math.maxInt(c_int)))
10481075 return error.InvalidParameter;
10491076 var created = try Window.create(.{
@@ -1059,6 +1086,8 @@ fn openNamedWindow(action: []const u8, data: ?[]const u8) !void {
10591086 .dev_tools = json_utils.getBool(json, "devTools") orelse false,
10601087 });
10611088 errdefer created.close();
1089 if (limits.minimum != null or limits.maximum != null)
1090 try setWindowLimits(desktop_windows.byWindow(@intFromPtr(created.hwnd)) orelse return error.WindowHandleNotSet, limits);
10621091 if (url) |text| try created.loadURL(text) else if (html) |text| try created.loadHTML(text);
10631092 if (desktop_windows.byWebview(owner) == null) return error.WindowHandleNotSet;
10641093 if (!window_registry.rememberNamedOwned(@intFromPtr(created.hwnd), name, owner))
@@ -1091,6 +1120,130 @@ fn windowGeometry(hwnd: HWND) !desktop_window_registry.Geometry {
10911120 return .{ .x = @intCast(rect.left), .y = @intCast(rect.top), .width = @intCast(width), .height = @intCast(height) };
10921121}
10931122
1123fn windowStyle(hwnd: HWND) DWORD {
1124 return @truncate(@as(usize, @bitCast(GetWindowLongPtrW(hwnd, GWL_STYLE))));
1125}
1126
1127fn setWindowStyle(hwnd: HWND, style: DWORD) void {
1128 _ = SetWindowLongPtrW(hwnd, GWL_STYLE, @bitCast(@as(usize, style)));
1129}
1130
1131fn monitorInfo(hwnd: HWND) !MONITORINFO {
1132 const monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) orelse return error.NativeCallFailed;
1133 var info: MONITORINFO = .{ .cbSize = @sizeOf(MONITORINFO), .rcMonitor = undefined, .rcWork = undefined, .dwFlags = 0 };
1134 if (GetMonitorInfoW(monitor, &info) == 0) return error.NativeCallFailed;
1135 return info;
1136}
1137
1138fn centerWindow(hwnd: HWND) !void {
1139 const info = try monitorInfo(hwnd);
1140 const bounds = try windowGeometry(hwnd);
1141 const work_width = @as(i64, info.rcWork.right) - @as(i64, info.rcWork.left);
1142 const work_height = @as(i64, info.rcWork.bottom) - @as(i64, info.rcWork.top);
1143 if (work_width <= 0 or work_height <= 0) return error.NativeCallFailed;
1144 const position = try desktop_window_controls.centerIn(
1145 .{ .x = @intCast(info.rcWork.left), .y = @intCast(info.rcWork.top), .width = @intCast(work_width), .height = @intCast(work_height) },
1146 .{ .width = bounds.width, .height = bounds.height },
1147 );
1148 if (SetWindowPos(hwnd, null, position.x, position.y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE) == 0)
1149 return error.NativeCallFailed;
1150}
1151
1152fn setWindowLimits(entry: desktop_window_registry.Entry, limits: desktop_window_controls.Limits) !void {
1153 if (!desktop_windows.setLimits(entry.window, limits)) return error.WindowHandleNotSet;
1154 const hwnd: HWND = @ptrFromInt(entry.window);
1155 const current = try windowGeometry(hwnd);
1156 const clamped = limits.clamp(.{ .width = current.width, .height = current.height });
1157 if (clamped.width != current.width or clamped.height != current.height) {
1158 if (SetWindowPos(hwnd, null, 0, 0, @intCast(clamped.width), @intCast(clamped.height), SWP_NOMOVE | SWP_NOZORDER) == 0)
1159 return error.NativeCallFailed;
1160 }
1161}
1162
1163fn setWindowResizable(entry: desktop_window_registry.Entry, resizable: bool) !void {
1164 const hwnd: HWND = @ptrFromInt(entry.window);
1165 const current = entry.windowed_style orelse @as(isize, @bitCast(@as(usize, windowStyle(hwnd))));
1166 const previous: DWORD = @truncate(@as(usize, @bitCast(current)));
1167 var style: DWORD = @truncate(@as(usize, @bitCast(current)));
1168 if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX else style &= ~(WS_THICKFRAME | WS_MAXIMIZEBOX);
1169 if (entry.fullscreen) {
1170 if (!desktop_windows.setWindowedState(entry.window, entry.windowed_geometry, @bitCast(@as(usize, style)))) return error.WindowHandleNotSet;
1171 return;
1172 }
1173 setWindowStyle(hwnd, style);
1174 if (SetWindowPos(hwnd, null, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED | SWP_NOACTIVATE) == 0) {
1175 setWindowStyle(hwnd, previous);
1176 _ = SetWindowPos(hwnd, null, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED | SWP_NOACTIVATE);
1177 return error.NativeCallFailed;
1178 }
1179}
1180
1181fn setWindowFullscreen(entry: desktop_window_registry.Entry, fullscreen: bool) !void {
1182 if (entry.fullscreen == fullscreen) return;
1183 const hwnd: HWND = @ptrFromInt(entry.window);
1184 if (fullscreen) {
1185 const bounds = try windowGeometry(hwnd);
1186 const info = try monitorInfo(hwnd);
1187 const style = windowStyle(hwnd);
1188 if (!desktop_windows.setWindowedState(entry.window, bounds, @bitCast(@as(usize, style)))) return error.WindowHandleNotSet;
1189 setWindowStyle(hwnd, (style & ~WS_OVERLAPPEDWINDOW) | WS_POPUP);
1190 const width = @as(i64, info.rcMonitor.right) - @as(i64, info.rcMonitor.left);
1191 const height = @as(i64, info.rcMonitor.bottom) - @as(i64, info.rcMonitor.top);
1192 // HWND_TOP (null) raises a child requested by another page. Keeping
1193 // its old Z-order can leave a borderless "fullscreen" behind that page.
1194 if (width <= 0 or height <= 0 or width > std.math.maxInt(c_int) or height > std.math.maxInt(c_int) or
1195 SetWindowPos(hwnd, null, @intCast(info.rcMonitor.left), @intCast(info.rcMonitor.top), @intCast(width), @intCast(height), SWP_FRAMECHANGED | SWP_NOACTIVATE) == 0)
1196 {
1197 setWindowStyle(hwnd, style);
1198 _ = SetWindowPos(hwnd, null, bounds.x, bounds.y, @intCast(bounds.width), @intCast(bounds.height), SWP_NOZORDER | SWP_FRAMECHANGED | SWP_NOACTIVATE);
1199 _ = desktop_windows.setWindowedState(entry.window, null, null);
1200 return error.NativeCallFailed;
1201 }
1202 } else {
1203 const style = entry.windowed_style orelse return error.NativeCallFailed;
1204 const bounds = entry.windowed_geometry orelse return error.NativeCallFailed;
1205 const fullscreen_style = windowStyle(hwnd);
1206 setWindowStyle(hwnd, @truncate(@as(usize, @bitCast(style))));
1207 const size = entry.limits.clamp(.{ .width = bounds.width, .height = bounds.height });
1208 if (SetWindowPos(hwnd, null, bounds.x, bounds.y, @intCast(size.width), @intCast(size.height), SWP_NOZORDER | SWP_FRAMECHANGED | SWP_NOACTIVATE) == 0) {
1209 setWindowStyle(hwnd, fullscreen_style);
1210 _ = SetWindowPos(hwnd, null, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED | SWP_NOACTIVATE);
1211 return error.NativeCallFailed;
1212 }
1213 _ = desktop_windows.setWindowedState(entry.window, null, null);
1214 }
1215 if (desktop_windows.observeState(entry.window, entry.minimized, fullscreen)) |change| {
1216 if (change.fullscreen) |now_fullscreen|
1217 deliverWindowEvent(entry, if (now_fullscreen) "enter-fullscreen" else "leave-fullscreen", "");
1218 }
1219}
1220
1221fn applyTrackingLimits(info: *MINMAXINFO, limits: desktop_window_controls.Limits) void {
1222 if (limits.minimum) |min| {
1223 info.ptMinTrackSize.x = @max(info.ptMinTrackSize.x, @as(c_long, @intCast(min.width)));
1224 info.ptMinTrackSize.y = @max(info.ptMinTrackSize.y, @as(c_long, @intCast(min.height)));
1225 }
1226 if (limits.maximum) |max| {
1227 info.ptMaxTrackSize.x = @max(info.ptMinTrackSize.x, @min(info.ptMaxTrackSize.x, @as(c_long, @intCast(max.width))));
1228 info.ptMaxTrackSize.y = @max(info.ptMinTrackSize.y, @min(info.ptMaxTrackSize.y, @as(c_long, @intCast(max.height))));
1229 }
1230}
1231
1232test "Windows tracking limits retain native defaults on an omitted axis" {
1233 var info: MINMAXINFO = .{
1234 .ptReserved = .{ .x = 0, .y = 0 },
1235 .ptMaxSize = .{ .x = 0, .y = 0 },
1236 .ptMaxPosition = .{ .x = 0, .y = 0 },
1237 .ptMinTrackSize = .{ .x = 120, .y = 80 },
1238 .ptMaxTrackSize = .{ .x = 2000, .y = 1300 },
1239 };
1240 applyTrackingLimits(&info, try desktop_window_controls.parseCreateLimits("{\"minWidth\":320,\"maxWidth\":1024}"));
1241 try std.testing.expectEqual(@as(c_long, 320), info.ptMinTrackSize.x);
1242 try std.testing.expectEqual(@as(c_long, 80), info.ptMinTrackSize.y);
1243 try std.testing.expectEqual(@as(c_long, 1024), info.ptMaxTrackSize.x);
1244 try std.testing.expectEqual(@as(c_long, 1300), info.ptMaxTrackSize.y);
1245}
1246
10941247fn windowTitle(allocator: std.mem.Allocator, hwnd: HWND) ![:0]u8 {
10951248 const length = GetWindowTextLengthW(hwnd);
10961249 if (length < 0) return error.NativeCallFailed;
@@ -1155,17 +1308,36 @@ fn handleWindowAction(action: []const u8, data: ?[]const u8) !void {
11551308 const title_wide = utf8ToUtf16Z(256, title) catch return error.InvalidParameter;
11561309 if (SetWindowTextW(hwnd, @ptrCast(&title_wide)) == 0) return error.NativeCallFailed;
11571310 } 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;
1311 const size = entry.limits.clamp(try desktop_window_controls.parseSize(data));
1312 if (SetWindowPos(hwnd, null, 0, 0, @intCast(size.width), @intCast(size.height), SWP_NOMOVE | SWP_NOZORDER) == 0) return error.NativeCallFailed;
11641313 } else if (std.mem.eql(u8, action, "setPosition")) {
11651314 const json = data orelse return error.MissingData;
11661315 const x = json_utils.getInt(i32, json, "x") orelse return error.InvalidParameter;
11671316 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;
1317 if (SetWindowPos(hwnd, null, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER) == 0) return error.NativeCallFailed;
1318 } else if (std.mem.eql(u8, action, "setBounds")) {
1319 const update = try desktop_window_controls.parseBounds(data);
1320 const current = try windowGeometry(hwnd);
1321 const size = entry.limits.clamp(.{ .width = update.width orelse current.width, .height = update.height orelse current.height });
1322 const flags: UINT = SWP_NOZORDER | SWP_NOACTIVATE |
1323 (if (update.x == null and update.y == null) SWP_NOMOVE else @as(UINT, 0)) |
1324 (if (update.width == null and update.height == null) SWP_NOSIZE else @as(UINT, 0));
1325 if (SetWindowPos(hwnd, null, update.x orelse current.x, update.y orelse current.y, @intCast(size.width), @intCast(size.height), flags) == 0)
1326 return error.NativeCallFailed;
1327 } else if (std.mem.eql(u8, action, "center")) {
1328 try centerWindow(hwnd);
1329 } else if (std.mem.eql(u8, action, "setResizable")) {
1330 try setWindowResizable(entry, try desktop_window_controls.parseBool(data, "resizable"));
1331 } else if (std.mem.eql(u8, action, "isResizable")) {
1332 const style: DWORD = if (entry.windowed_style) |saved| @truncate(@as(usize, @bitCast(saved))) else windowStyle(hwnd);
1333 bridge_error.sendResultToJS(std.heap.c_allocator, action, if ((style & WS_THICKFRAME) != 0) "true" else "false");
1334 } else if (std.mem.eql(u8, action, "setFullscreen") or std.mem.eql(u8, action, "toggleFullscreen")) {
1335 const fullscreen = if (std.mem.eql(u8, action, "toggleFullscreen")) !entry.fullscreen else try desktop_window_controls.parseBool(data, "fullscreen");
1336 try setWindowFullscreen(entry, fullscreen);
1337 } else if (std.mem.eql(u8, action, "setMinimumSize") or std.mem.eql(u8, action, "setMaximumSize")) {
1338 const size = try desktop_window_controls.parseSize(data);
1339 const limits = if (std.mem.eql(u8, action, "setMinimumSize")) try entry.limits.withMinimum(size) else try entry.limits.withMaximum(size);
1340 try setWindowLimits(entry, limits);
11691341 } else if (std.mem.eql(u8, action, "loadURL") or std.mem.eql(u8, action, "loadHTML")) {
11701342 const json = data orelse return error.MissingData;
11711343 const key: []const u8 = if (std.mem.eql(u8, action, "loadURL")) "url" else "html";
@@ -1179,6 +1351,10 @@ fn handleWindowAction(action: []const u8, data: ?[]const u8) !void {
11791351 }
11801352}
11811353
1354test "Windows portable controls require an authenticated live sender" {
1355 try std.testing.expectError(error.WindowHandleNotSet, handleWindowAction("setBounds", "{}"));
1356}
1357
11821358pub const WindowStyle = struct {
11831359 frameless: bool = false,
11841360 transparent: bool = false,
@@ -1232,9 +1408,9 @@ pub const Window = struct {
12321408 }
12331409
12341410 // Determine window style
1235 var style: DWORD = if (options.frameless) WS_POPUP else WS_OVERLAPPEDWINDOW;
1236 if (!options.resizable and !options.frameless) {
1237 style &= ~WS_THICKFRAME;
1411 var style: DWORD = if (options.frameless) WS_POPUP | WS_THICKFRAME else WS_OVERLAPPEDWINDOW;
1412 if (!options.resizable) {
1413 style &= ~(WS_THICKFRAME | WS_MAXIMIZEBOX);
12381414 }
12391415 style |= WS_VISIBLE;
12401416
@@ -1348,6 +1524,7 @@ pub const Window = struct {
13481524 return error.WebMessageRegistrationFailed;
13491525 }
13501526 observeWindowGeometry(desktop_windows.byId(window_id).?);
1527 if (options.fullscreen) try setWindowFullscreen(desktop_windows.byId(window_id).?, true);
13511528
13521529 var window = Window{
13531530 .id = window_id,
@@ -1460,11 +1637,8 @@ pub const Window = struct {
14601637 }
14611638
14621639 pub fn setFullscreen(self: *Window, fullscreen: bool) void {
1463 _ = self;
1464 _ = fullscreen;
1465 // Would modify window style and size
1466 // GetWindowLong/SetWindowLong to change WS_OVERLAPPEDWINDOW style
1467 // SetWindowPos to resize to full screen dimensions
1640 const entry = self.liveEntry() orelse return;
1641 setWindowFullscreen(entry, fullscreen) catch {};
14681642 }
14691643
14701644 pub fn executeJavaScript(self: *Window, script: []const u8) !void {
@@ -1549,6 +1723,13 @@ pub const Window = struct {
15491723
15501724fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c) LRESULT {
15511725 switch (msg) {
1726 WM_GETMINMAXINFO => {
1727 if (desktop_windows.byWindow(@intFromPtr(hwnd))) |entry| {
1728 const info: *MINMAXINFO = @ptrFromInt(@as(usize, @bitCast(lParam)));
1729 applyTrackingLimits(info, entry.limits);
1730 return 0;
1731 }
1732 },
15521733 WM_CRAFT_OPEN_WINDOW => {
15531734 processPendingOpens();
15541735 return 0;
@@ -1561,7 +1742,7 @@ fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c
15611742 _ = GetClientRect(hwnd, &bounds);
15621743 _ = controller.lpVtbl.put_Bounds(controller, bounds);
15631744 const minimized = wParam == 1; // SIZE_MINIMIZED
1564 if (desktop_windows.observeState(entry.window, minimized, false)) |change| {
1745 if (desktop_windows.observeState(entry.window, minimized, entry.fullscreen)) |change| {
15651746 if (change.minimized) |now_minimized|
15661747 deliverWindowEvent(entry, if (now_minimized) "minimize" else "restore", "");
15671748 }
scripts/verify-desktop-multi-window.tsmodified+65-1
Changes to scripts/verify-desktop-multi-window.ts
@@ -11,6 +11,7 @@ const platformName = isWindows ? 'Windows' : 'Linux'
1111
1212const mainPage = `<!doctype html><script>
1313(async () => {
14 const isWindows = ${isWindows}
1415 const report = (step) => fetch('/report?step=' + encodeURIComponent(step), { method: 'POST' })
1516 const waitFor = async (step) => {
1617 for (let attempt = 0; attempt < 200; attempt++) {
@@ -23,6 +24,24 @@ const mainPage = `<!doctype html><script>
2324 const child = (cycle) => window.craft.window.open({
2425 name: 'settings', title: 'Child ' + cycle,
2526 url: location.origin + '/child?cycle=' + cycle,
27 minWidth: 300, maxWidth: 1200,
28 })
29 const call = (action, data = {}, id = 'settings') => window.craft.window._call(action, data, id)
30 const waitSize = async (predicate, label) => {
31 for (let attempt = 0; attempt < 40; attempt++) {
32 const size = await call('getSize')
33 if (predicate(size)) return size
34 await new Promise(resolve => setTimeout(resolve, 100))
35 }
36 throw new Error(label + ': child size never reached expected bounds')
37 }
38 const waitWindowEvent = (name) => new Promise((resolve, reject) => {
39 const timeout = setTimeout(() => reject(new Error(name + ' event missing')), 10000)
40 window.addEventListener('craft:window:' + name, (event) => {
41 if (event.detail.windowId !== 'settings') return
42 clearTimeout(timeout)
43 resolve()
44 }, { once: true })
2645 })
2746 try {
2847 if (!window.craft || !window.craft.window || !window.craft.window.open)
@@ -33,6 +52,8 @@ const mainPage = `<!doctype html><script>
3352 await waitFor('child-1')
3453 const bounds = await window.craft.window._call('getBounds', {}, 'settings')
3554 if (!(bounds.width > 0 && bounds.height > 0)) throw new Error('child bounds not routed to creator')
55 await call('setSize', { width: 100, height: 100 })
56 await waitSize(size => size.width >= 280, 'one-axis creation limits')
3657 await window.craft.window._call('setSize', { width: 640, height: 480 }, 'settings')
3758 let resized = false
3859 for (let attempt = 0; attempt < 40; attempt++) {
@@ -47,6 +68,49 @@ const mainPage = `<!doctype html><script>
4768 const title = await window.craft.window._call('getTitle', {}, 'settings')
4869 if (title !== 'Child 1') throw new Error('child title not routed to creator: ' + title)
4970
71 const mainBefore = await call('getBounds', {}, 'main')
72 await call('setBounds', { x: bounds.x + 20, y: bounds.y + 15, width: 700, height: 500 })
73 await waitSize(size => Math.abs(size.width - 700) <= 40 && Math.abs(size.height - 500) <= 40, 'setBounds')
74 const moved = await call('getBounds')
75 if (isWindows && (Math.abs(moved.x - bounds.x - 20) > 16 || Math.abs(moved.y - bounds.y - 15) > 16))
76 throw new Error('setBounds did not move the addressed Windows child')
77 await call('center')
78 const centered = await call('getBounds')
79 if (!Number.isFinite(centered.x) || !Number.isFinite(centered.y))
80 throw new Error('center returned invalid child coordinates')
81 await call('setResizable', { resizable: false })
82 if (await call('isResizable') !== false) throw new Error('child stayed resizable')
83 await call('setResizable', { resizable: true })
84 if (await call('isResizable') !== true) throw new Error('child stayed fixed-size')
85 await call('setMinimumSize', { width: 600, height: 420 })
86 await call('setMaximumSize', { width: 820, height: 620 })
87 await call('setSize', { width: 300, height: 200 })
88 await waitSize(size => size.width >= 580 && size.height >= 400, 'minimum size')
89 await call('setSize', { width: 1000, height: 800 })
90 await waitSize(size => size.width <= 860 && size.height <= 660, 'maximum size')
91 await call('setMinimumSize', { width: 1, height: 1 })
92 await call('setMaximumSize', { width: 4096, height: 4096 })
93 await call('setSize', { width: 700, height: 500 })
94 await waitSize(size => Math.abs(size.width - 700) <= 40 && Math.abs(size.height - 500) <= 40, 'restored size')
95 if (isWindows) {
96 const entered = waitWindowEvent('enter-fullscreen')
97 await call('setFullscreen', { fullscreen: true })
98 await entered
99 const left = waitWindowEvent('leave-fullscreen')
100 await call('toggleFullscreen')
101 await left
102 }
103 else {
104 // A bare Xvfb server has no window manager to honor fullscreen requests.
105 await call('setFullscreen', { fullscreen: true })
106 await call('toggleFullscreen')
107 await call('setFullscreen', { fullscreen: false })
108 }
109 const mainAfter = await call('getBounds', {}, 'main')
110 if (Math.abs(mainAfter.width - mainBefore.width) > 40 || Math.abs(mainAfter.height - mainBefore.height) > 40)
111 throw new Error('child controls changed the main window size')
112 await report('controls')
113
50114 const [queuedA, queuedB] = await Promise.all([
51115 window.craft.window.open({ name: 'queue-a', title: 'Child queue-a', url: location.origin + '/child?cycle=queue-a' }),
52116 window.craft.window.open({ name: 'queue-b', title: 'Child queue-b', url: location.origin + '/child?cycle=queue-b' }),
@@ -151,7 +215,7 @@ const server = createServer((request, response) => {
151215 if (step) steps.add(step)
152216 response.end('ok')
153217 if (step === 'done') {
154 const required = ['main', 'child-1', 'child-queue-a', 'child-queue-b', 'queued-closed', 'closed', 'child-2', 'done']
218 const required = ['main', 'child-1', 'controls', 'child-queue-a', 'child-queue-b', 'queued-closed', 'closed', 'child-2', 'done']
155219 if (required.every(name => steps.has(name))) resolveDone()
156220 else rejectDone(new Error(`incomplete smoke steps: ${[...steps].join(', ')}`))
157221 }