also looking at this
feat: portable typed window controls on Linux and Windows
#326
9 files
+652
-32
| @@ -137,12 +137,12 @@ The next multi-window milestone needs product decisions in addition to code: | ||
| 137 | 137 | application-level asynchronous source above. |
| 138 | 138 | 3. Decide whether Touch Bar should stay primary-window scoped or follow the |
| 139 | 139 | key window with separately owned item definitions and callbacks. |
| 140 | 4. 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. | |
| 143 | 5. 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. | |
| 140 | 4. 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. | |
| 143 | 5. 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. | |
| 146 | 146 | |
| 147 | 147 | ## Review checklist |
| 148 | 148 | |
| @@ -32,8 +32,10 @@ The `craft-native` SDK returns a typed `Window` handle. Runtime-created | ||
| 32 | 32 | windows are available on macOS, Linux, and Windows and must provide either |
| 33 | 33 | `html` or `url` content. The common create, close, title, geometry, and event |
| 34 | 34 | routes are cross-platform; some advanced window controls remain macOS-only. |
| 35 | On Linux, the window manager may ignore position requests, especially before | |
| 36 | the window is shown. | |
| 35 | Bounds, centering, resizing constraints, resizability and fullscreen controls | |
| 36 | also work on the typed handle on all three desktop platforms. On Linux, | |
| 37 | position and fullscreen requests are advisory to the window manager (and | |
| 38 | global positioning can be unavailable under Wayland). | |
| 37 | 39 | |
| 38 | 40 | ```typescript |
| 39 | 41 | import { createWindow } from 'craft-native' |
| @@ -62,7 +64,21 @@ const settings = await createWindow({ | ||
| 62 | 64 | }) |
| 63 | 65 | |
| 64 | 66 | await settings.setTitle('Settings — Account') |
| 65 | ``` | |
| 67 | await settings.setBounds({ x: 120, y: 80, width: 800 }) | |
| 68 | await settings.setMinimumSize(480, 360) | |
| 69 | await settings.setMaximumSize(1600, 1200) | |
| 70 | await settings.setResizable(false) | |
| 71 | const canResize = await settings.isResizable() | |
| 72 | await settings.center() | |
| 73 | await settings.setFullscreen(true) | |
| 74 | await settings.toggleFullscreen() | |
| 75 | ``` | |
| 76 | ||
| 77 | The bounds update preserves omitted coordinates and dimensions. Native window | |
| 78 | managers can adjust requested geometry for decorations or screen constraints; | |
| 79 | read `getBounds()` after a resize event if you need the actual result. | |
| 80 | Creation-time `minWidth`/`minHeight` and `maxWidth`/`maxHeight` may constrain | |
| 81 | either axis independently; the native window system's own minimum still applies. | |
| 66 | 82 | |
| 67 | 83 | The older `createWindow(html, options)` overload remains available when the |
| 68 | 84 | content is already in a string. |
| @@ -86,6 +86,30 @@ describe('typed window-handle routing', () => { | ||
| 86 | 86 | ]) |
| 87 | 87 | }) |
| 88 | 88 | |
| 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 | ||
| 89 | 113 | it('loads content in the retained handle instead of the calling page', async () => { |
| 90 | 114 | const settings = new Window('settings') |
| 91 | 115 | |
| @@ -1273,6 +1273,14 @@ pub fn build(b: *std.Build) void { | ||
| 1273 | 1273 | }), |
| 1274 | 1274 | }); |
| 1275 | 1275 | |
| 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 | ||
| 1276 | 1284 | const desktop_bridge_envelope_tests = b.addTest(.{ |
| 1277 | 1285 | .root_module = b.createModule(.{ |
| 1278 | 1286 | .root_source_file = b.path("src/desktop_bridge_envelope.zig"), |
| @@ -1768,6 +1776,7 @@ pub fn build(b: *std.Build) void { | ||
| 1768 | 1776 | const run_window_context_tests = b.addRunArtifact(window_context_tests); |
| 1769 | 1777 | const run_window_reply_target_tests = b.addRunArtifact(window_reply_target_tests); |
| 1770 | 1778 | 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); | |
| 1771 | 1780 | const run_desktop_bridge_envelope_tests = b.addRunArtifact(desktop_bridge_envelope_tests); |
| 1772 | 1781 | const run_desktop_bridge_text_tests = b.addRunArtifact(desktop_bridge_text_tests); |
| 1773 | 1782 | const run_desktop_window_events_tests = b.addRunArtifact(desktop_window_events_tests); |
| @@ -1924,6 +1933,7 @@ pub fn build(b: *std.Build) void { | ||
| 1924 | 1933 | test_step.dependOn(&run_window_context_tests.step); |
| 1925 | 1934 | test_step.dependOn(&run_window_reply_target_tests.step); |
| 1926 | 1935 | test_step.dependOn(&run_desktop_window_registry_tests.step); |
| 1936 | test_step.dependOn(&run_desktop_window_controls_tests.step); | |
| 1927 | 1937 | test_step.dependOn(&run_desktop_bridge_envelope_tests.step); |
| 1928 | 1938 | test_step.dependOn(&run_desktop_bridge_text_tests.step); |
| 1929 | 1939 | test_step.dependOn(&run_desktop_window_events_tests.step); |
| @@ -0,0 +1,190 @@ | ||
| 1 | //! Validated, platform-neutral inputs for the portable window controls. | |
| 2 | const std = ; | |
| 3 | ||
| 4 | pub const Bounds = struct { | |
| 5 | x: ?i32 = null, | |
| 6 | y: ?i32 = null, | |
| 7 | width: ?u32 = null, | |
| 8 | height: ?u32 = null, | |
| 9 | }; | |
| 10 | ||
| 11 | pub const Size = struct { width: u32, height: u32 }; | |
| 12 | pub const Position = struct { x: i32, y: i32 }; | |
| 13 | pub const Workarea = struct { x: i32, y: i32, width: u32, height: u32 }; | |
| 14 | ||
| 15 | pub 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 | ||
| 24 | pub 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 | ||
| 60 | fn 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 | ||
| 69 | fn 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 | ||
| 74 | fn 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 | ||
| 79 | pub 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 | ||
| 99 | pub 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. | |
| 113 | pub 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 | ||
| 136 | pub 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 | ||
| 144 | test "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 | ||
| 153 | test "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 | ||
| 163 | test "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 | ||
| 172 | test "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 | ||
| 183 | test "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 | } | |
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | //! before any later bridge reply or resize selects its destroyed webview. |
| 6 | 6 | |
| 7 | 7 | const std = ; |
| 8 | const desktop_window_controls = ; | |
| 8 | 9 | |
| 9 | 10 | pub const capacity = 32; |
| 10 | 11 | pub const Geometry = struct { |
| @@ -35,6 +36,10 @@ pub const Entry = struct { | ||
| 35 | 36 | geometry: ?Geometry = null, |
| 36 | 37 | minimized: bool = false, |
| 37 | 38 | 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, | |
| 38 | 43 | }; |
| 39 | 44 | |
| 40 | 45 | pub const Registry = struct { |
| @@ -101,6 +106,33 @@ pub const Registry = struct { | ||
| 101 | 106 | return false; |
| 102 | 107 | } |
| 103 | 108 | |
| 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 | ||
| 104 | 136 | pub fn observeGeometry(self: *Registry, window: usize, next: Geometry) ?GeometryChange { |
| 105 | 137 | if (window == 0) return null; |
| 106 | 138 | for (&self.entries) |*slot| { |
| @@ -241,6 +273,23 @@ test "a web message subscription belongs only to its live window" { | ||
| 241 | 273 | try std.testing.expect(registry.byWebview(0x1001) == null); |
| 242 | 274 | } |
| 243 | 275 | |
| 276 | test "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 | ||
| 244 | 293 | test "geometry changes belong to one live window and reset on reopen" { |
| 245 | 294 | var registry: Registry = .{}; |
| 246 | 295 | _ = registry.remember(0x1000, 0x1001); |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | const std = ; |
| 2 | 2 | const bridge_error = ; |
| 3 | 3 | const desktop_bridge_envelope = ; |
| 4 | const desktop_window_controls = ; | |
| 4 | 5 | const desktop_window_registry = ; |
| 5 | 6 | const desktop_window_events = ; |
| 6 | 7 | const desktop_window_reads = ; |
| @@ -27,6 +28,8 @@ pub extern "c" fn gtk_window_present(window: *anyopaque) void; | ||
| 27 | 28 | pub extern "c" fn gtk_window_close(window: *anyopaque) void; |
| 28 | 29 | pub extern "c" fn gtk_window_set_decorated(window: *anyopaque, decorated: c_int) void; |
| 29 | 30 | pub extern "c" fn gtk_window_set_resizable(window: *anyopaque, resizable: c_int) void; |
| 31 | pub extern "c" fn gtk_window_get_resizable(window: *anyopaque) c_int; | |
| 32 | pub extern "c" fn gtk_window_set_geometry_hints(window: *anyopaque, geometry_widget: ?*anyopaque, geometry: ?*const GdkGeometry, geom_mask: c_int) void; | |
| 30 | 33 | pub extern "c" fn gtk_window_fullscreen(window: *anyopaque) void; |
| 31 | 34 | pub extern "c" fn gtk_window_unfullscreen(window: *anyopaque) void; |
| 32 | 35 | pub 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; | ||
| 41 | 44 | pub extern "c" fn gtk_window_is_active(window: *anyopaque) c_int; |
| 42 | 45 | pub extern "c" fn gtk_widget_get_window(widget: *anyopaque) ?*anyopaque; |
| 43 | 46 | pub extern "c" fn gdk_window_get_state(window: *anyopaque) c_uint; |
| 47 | pub extern "c" fn gdk_display_get_monitor_at_window(display: *anyopaque, window: *anyopaque) ?*anyopaque; | |
| 48 | pub extern "c" fn gdk_monitor_get_workarea(monitor: *anyopaque, dest: *GdkRectangle) void; | |
| 49 | ||
| 50 | const GdkRectangle = extern struct { x: c_int, y: c_int, width: c_int, height: c_int }; | |
| 51 | const 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 | }; | |
| 44 | 64 | |
| 45 | 65 | pub extern "c" fn webkit_web_view_new() *anyopaque; |
| 46 | 66 | pub 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 { | ||
| 387 | 407 | |
| 388 | 408 | const width = json_utils.getInt(u32, json, "width") orelse 800; |
| 389 | 409 | const height = json_utils.getInt(u32, json, "height") orelse 600; |
| 410 | const limits = try desktop_window_controls.parseCreateLimits(json); | |
| 390 | 411 | if (width == 0 or height == 0 or width > @as(u32, std.math.maxInt(c_int)) or height > @as(u32, std.math.maxInt(c_int))) |
| 391 | 412 | return error.InvalidParameter; |
| 392 | 413 | var created = try Window.create(.{ |
| @@ -401,6 +422,8 @@ fn openNamedWindow(action: []const u8, data: ?[]const u8) !void { | ||
| 401 | 422 | .dev_tools = json_utils.getBool(json, "devTools") orelse false, |
| 402 | 423 | }); |
| 403 | 424 | 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); | |
| 404 | 427 | if (url) |text| try created.loadURL(text) else if (html) |text| try created.loadHTML(text); |
| 405 | 428 | if (!window_registry.rememberNamedOwned(@intFromPtr(created.gtk_window), name, owner)) |
| 406 | 429 | return error.TooManyWindows; |
| @@ -433,6 +456,47 @@ fn windowGeometry(window: *anyopaque) !desktop_window_registry.Geometry { | ||
| 433 | 456 | return .{ .x = x, .y = y, .width = @intCast(width), .height = @intCast(height) }; |
| 434 | 457 | } |
| 435 | 458 | |
| 459 | fn 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 | ||
| 475 | fn 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 | ||
| 485 | fn 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 | ||
| 436 | 500 | fn sendWindowRead(action: []const u8, data: ?[]const u8) !void { |
| 437 | 501 | const allocator = std.heap.c_allocator; |
| 438 | 502 | const entry = try targetWindow(data); |
| @@ -491,17 +555,35 @@ fn handleWindowAction(action: []const u8, data: ?[]const u8) !void { | ||
| 491 | 555 | defer std.heap.c_allocator.free(title_z); |
| 492 | 556 | gtk_window_set_title(window, title_z); |
| 493 | 557 | } 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)); | |
| 500 | 560 | } else if (std.mem.eql(u8, action, "setPosition")) { |
| 501 | 561 | const json = data orelse return error.MissingData; |
| 502 | 562 | const x = json_utils.getInt(i32, json, "x") orelse return error.InvalidParameter; |
| 503 | 563 | const y = json_utils.getInt(i32, json, "y") orelse return error.InvalidParameter; |
| 504 | 564 | 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); | |
| 505 | 587 | } else if (std.mem.eql(u8, action, "loadURL") or std.mem.eql(u8, action, "loadHTML")) { |
| 506 | 588 | const json = data orelse return error.MissingData; |
| 507 | 589 | 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 { | ||
| 516 | 598 | } |
| 517 | 599 | } |
| 518 | 600 | |
| 601 | test "Linux portable controls require an authenticated live sender" { | |
| 602 | try std.testing.expectError(error.WindowHandleNotSet, handleWindowAction("setBounds", "{}")); | |
| 603 | } | |
| 604 | ||
| 519 | 605 | // Application state |
| 520 | 606 | var app_instance: ?*anyopaque = null; |
| 521 | 607 | |
| @@ -2,6 +2,7 @@ const std = ; | ||
| 2 | 2 | const bridge_error = ; |
| 3 | 3 | const desktop_bridge_envelope = ; |
| 4 | 4 | const desktop_bridge_text = ; |
| 5 | const desktop_window_controls = ; | |
| 5 | 6 | const desktop_script_encoding = ; |
| 6 | 7 | const desktop_window_events = ; |
| 7 | 8 | const desktop_window_reads = ; |
| @@ -63,6 +64,20 @@ pub const RECT = extern struct { | ||
| 63 | 64 | right: c_long, |
| 64 | 65 | bottom: c_long, |
| 65 | 66 | }; |
| 67 | pub const POINT = extern struct { x: c_long, y: c_long }; | |
| 68 | pub const MINMAXINFO = extern struct { | |
| 69 | ptReserved: POINT, | |
| 70 | ptMaxSize: POINT, | |
| 71 | ptMaxPosition: POINT, | |
| 72 | ptMinTrackSize: POINT, | |
| 73 | ptMaxTrackSize: POINT, | |
| 74 | }; | |
| 75 | pub const MONITORINFO = extern struct { | |
| 76 | cbSize: DWORD, | |
| 77 | rcMonitor: RECT, | |
| 78 | rcWork: RECT, | |
| 79 | dwFlags: DWORD, | |
| 80 | }; | |
| 66 | 81 | |
| 67 | 82 | // COM base type |
| 68 | 83 | pub const GUID = extern struct { |
| @@ -82,6 +97,7 @@ pub const WS_OVERLAPPEDWINDOW: DWORD = 0x00CF0000; | ||
| 82 | 97 | pub const WS_VISIBLE: DWORD = 0x10000000; |
| 83 | 98 | pub const WS_POPUP: DWORD = 0x80000000; |
| 84 | 99 | pub const WS_THICKFRAME: DWORD = 0x00040000; |
| 100 | pub const WS_MAXIMIZEBOX: DWORD = 0x00010000; | |
| 85 | 101 | pub const WS_EX_TOPMOST: DWORD = 0x00000008; |
| 86 | 102 | pub const WS_EX_LAYERED: DWORD = 0x00080000; |
| 87 | 103 | pub const CW_USEDEFAULT: c_int = @bitCast(@as(c_uint, 0x80000000)); |
| @@ -94,10 +110,18 @@ pub const WM_MOVE: UINT = 0x0003; | ||
| 94 | 110 | pub const WM_SIZE: UINT = 0x0005; |
| 95 | 111 | pub const WM_ACTIVATE: UINT = 0x0006; |
| 96 | 112 | pub const WM_CLOSE: UINT = 0x0010; |
| 113 | pub const WM_GETMINMAXINFO: UINT = 0x0024; | |
| 97 | 114 | pub const WM_QUIT: UINT = 0x0012; |
| 98 | 115 | const WM_CRAFT_OPEN_WINDOW: UINT = 0x8001; // WM_APP + 1 |
| 99 | 116 | pub const PM_REMOVE: UINT = 0x0001; |
| 100 | 117 | pub const GWLP_USERDATA: c_int = -21; |
| 118 | const GWL_STYLE: c_int = -16; | |
| 119 | const SWP_NOSIZE: UINT = 0x0001; | |
| 120 | const SWP_NOMOVE: UINT = 0x0002; | |
| 121 | const SWP_NOZORDER: UINT = 0x0004; | |
| 122 | const SWP_FRAMECHANGED: UINT = 0x0020; | |
| 123 | const SWP_NOACTIVATE: UINT = 0x0010; | |
| 124 | const MONITOR_DEFAULTTONEAREST: DWORD = 2; | |
| 101 | 125 | |
| 102 | 126 | // Win32 API functions |
| 103 | 127 | pub 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 | ||
| 135 | 159 | pub extern "user32" fn GetWindowRect(hWnd: HWND, lpRect: *RECT) callconv(.c) BOOL; |
| 136 | 160 | pub extern "user32" fn SetWindowLongPtrW(hWnd: HWND, nIndex: c_int, dwNewLong: isize) callconv(.c) isize; |
| 137 | 161 | pub extern "user32" fn GetWindowLongPtrW(hWnd: HWND, nIndex: c_int) callconv(.c) isize; |
| 162 | pub extern "user32" fn MonitorFromWindow(hWnd: HWND, dwFlags: DWORD) callconv(.c) ?*anyopaque; | |
| 163 | pub extern "user32" fn GetMonitorInfoW(hMonitor: *anyopaque, lpmi: *MONITORINFO) callconv(.c) BOOL; | |
| 138 | 164 | pub extern "kernel32" fn GetModuleHandleW(lpModuleName: ?LPCWSTR) callconv(.c) ?HINSTANCE; |
| 139 | 165 | pub extern "kernel32" fn Sleep(dwMilliseconds: DWORD) callconv(.c) void; |
| 140 | 166 | pub extern "ole32" fn CoTaskMemFree(pv: ?*anyopaque) callconv(.c) void; |
| @@ -1044,6 +1070,7 @@ fn openNamedWindow(action: []const u8, data: ?[]const u8) !void { | ||
| 1044 | 1070 | |
| 1045 | 1071 | const width = json_utils.getInt(u32, json, "width") orelse 800; |
| 1046 | 1072 | const height = json_utils.getInt(u32, json, "height") orelse 600; |
| 1073 | const limits = try desktop_window_controls.parseCreateLimits(json); | |
| 1047 | 1074 | if (width == 0 or height == 0 or width > @as(u32, std.math.maxInt(c_int)) or height > @as(u32, std.math.maxInt(c_int))) |
| 1048 | 1075 | return error.InvalidParameter; |
| 1049 | 1076 | var created = try Window.create(.{ |
| @@ -1059,6 +1086,8 @@ fn openNamedWindow(action: []const u8, data: ?[]const u8) !void { | ||
| 1059 | 1086 | .dev_tools = json_utils.getBool(json, "devTools") orelse false, |
| 1060 | 1087 | }); |
| 1061 | 1088 | 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); | |
| 1062 | 1091 | if (url) |text| try created.loadURL(text) else if (html) |text| try created.loadHTML(text); |
| 1063 | 1092 | if (desktop_windows.byWebview(owner) == null) return error.WindowHandleNotSet; |
| 1064 | 1093 | if (!window_registry.rememberNamedOwned(@intFromPtr(created.hwnd), name, owner)) |
| @@ -1091,6 +1120,130 @@ fn windowGeometry(hwnd: HWND) !desktop_window_registry.Geometry { | ||
| 1091 | 1120 | return .{ .x = @intCast(rect.left), .y = @intCast(rect.top), .width = @intCast(width), .height = @intCast(height) }; |
| 1092 | 1121 | } |
| 1093 | 1122 | |
| 1123 | fn windowStyle(hwnd: HWND) DWORD { | |
| 1124 | return @truncate(@as(usize, @bitCast(GetWindowLongPtrW(hwnd, GWL_STYLE)))); | |
| 1125 | } | |
| 1126 | ||
| 1127 | fn setWindowStyle(hwnd: HWND, style: DWORD) void { | |
| 1128 | _ = SetWindowLongPtrW(hwnd, GWL_STYLE, @bitCast(@as(usize, style))); | |
| 1129 | } | |
| 1130 | ||
| 1131 | fn 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 | ||
| 1138 | fn 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 | ||
| 1152 | fn 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 | ||
| 1163 | fn 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 | ||
| 1181 | fn 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 | ||
| 1221 | fn 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 | ||
| 1232 | test "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 | ||
| 1094 | 1247 | fn windowTitle(allocator: std.mem.Allocator, hwnd: HWND) ![:0]u8 { |
| 1095 | 1248 | const length = GetWindowTextLengthW(hwnd); |
| 1096 | 1249 | if (length < 0) return error.NativeCallFailed; |
| @@ -1155,17 +1308,36 @@ fn handleWindowAction(action: []const u8, data: ?[]const u8) !void { | ||
| 1155 | 1308 | const title_wide = utf8ToUtf16Z(256, title) catch return error.InvalidParameter; |
| 1156 | 1309 | if (SetWindowTextW(hwnd, @ptrCast(&title_wide)) == 0) return error.NativeCallFailed; |
| 1157 | 1310 | } 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; | |
| 1164 | 1313 | } else if (std.mem.eql(u8, action, "setPosition")) { |
| 1165 | 1314 | const json = data orelse return error.MissingData; |
| 1166 | 1315 | const x = json_utils.getInt(i32, json, "x") orelse return error.InvalidParameter; |
| 1167 | 1316 | 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); | |
| 1169 | 1341 | } else if (std.mem.eql(u8, action, "loadURL") or std.mem.eql(u8, action, "loadHTML")) { |
| 1170 | 1342 | const json = data orelse return error.MissingData; |
| 1171 | 1343 | 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 { | ||
| 1179 | 1351 | } |
| 1180 | 1352 | } |
| 1181 | 1353 | |
| 1354 | test "Windows portable controls require an authenticated live sender" { | |
| 1355 | try std.testing.expectError(error.WindowHandleNotSet, handleWindowAction("setBounds", "{}")); | |
| 1356 | } | |
| 1357 | ||
| 1182 | 1358 | pub const WindowStyle = struct { |
| 1183 | 1359 | frameless: bool = false, |
| 1184 | 1360 | transparent: bool = false, |
| @@ -1232,9 +1408,9 @@ pub const Window = struct { | ||
| 1232 | 1408 | } |
| 1233 | 1409 | |
| 1234 | 1410 | // 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); | |
| 1238 | 1414 | } |
| 1239 | 1415 | style |= WS_VISIBLE; |
| 1240 | 1416 | |
| @@ -1348,6 +1524,7 @@ pub const Window = struct { | ||
| 1348 | 1524 | return error.WebMessageRegistrationFailed; |
| 1349 | 1525 | } |
| 1350 | 1526 | observeWindowGeometry(desktop_windows.byId(window_id).?); |
| 1527 | if (options.fullscreen) try setWindowFullscreen(desktop_windows.byId(window_id).?, true); | |
| 1351 | 1528 | |
| 1352 | 1529 | var window = Window{ |
| 1353 | 1530 | .id = window_id, |
| @@ -1460,11 +1637,8 @@ pub const Window = struct { | ||
| 1460 | 1637 | } |
| 1461 | 1638 | |
| 1462 | 1639 | 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 {}; | |
| 1468 | 1642 | } |
| 1469 | 1643 | |
| 1470 | 1644 | pub fn executeJavaScript(self: *Window, script: []const u8) !void { |
| @@ -1549,6 +1723,13 @@ pub const Window = struct { | ||
| 1549 | 1723 | |
| 1550 | 1724 | fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c) LRESULT { |
| 1551 | 1725 | 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 | }, | |
| 1552 | 1733 | WM_CRAFT_OPEN_WINDOW => { |
| 1553 | 1734 | processPendingOpens(); |
| 1554 | 1735 | return 0; |
| @@ -1561,7 +1742,7 @@ fn WindowProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM) callconv(.c | ||
| 1561 | 1742 | _ = GetClientRect(hwnd, &bounds); |
| 1562 | 1743 | _ = controller.lpVtbl.put_Bounds(controller, bounds); |
| 1563 | 1744 | 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| { | |
| 1565 | 1746 | if (change.minimized) |now_minimized| |
| 1566 | 1747 | deliverWindowEvent(entry, if (now_minimized) "minimize" else "restore", ""); |
| 1567 | 1748 | } |
| @@ -11,6 +11,7 @@ const platformName = isWindows ? 'Windows' : 'Linux' | ||
| 11 | 11 | |
| 12 | 12 | const mainPage = `<!doctype html><script> |
| 13 | 13 | (async () => { |
| 14 | const isWindows = ${isWindows} | |
| 14 | 15 | const report = (step) => fetch('/report?step=' + encodeURIComponent(step), { method: 'POST' }) |
| 15 | 16 | const waitFor = async (step) => { |
| 16 | 17 | for (let attempt = 0; attempt < 200; attempt++) { |
| @@ -23,6 +24,24 @@ const mainPage = `<!doctype html><script> | ||
| 23 | 24 | const child = (cycle) => window.craft.window.open({ |
| 24 | 25 | name: 'settings', title: 'Child ' + cycle, |
| 25 | 26 | 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 }) | |
| 26 | 45 | }) |
| 27 | 46 | try { |
| 28 | 47 | if (!window.craft || !window.craft.window || !window.craft.window.open) |
| @@ -33,6 +52,8 @@ const mainPage = `<!doctype html><script> | ||
| 33 | 52 | await waitFor('child-1') |
| 34 | 53 | const bounds = await window.craft.window._call('getBounds', {}, 'settings') |
| 35 | 54 | 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') | |
| 36 | 57 | await window.craft.window._call('setSize', { width: 640, height: 480 }, 'settings') |
| 37 | 58 | let resized = false |
| 38 | 59 | for (let attempt = 0; attempt < 40; attempt++) { |
| @@ -47,6 +68,49 @@ const mainPage = `<!doctype html><script> | ||
| 47 | 68 | const title = await window.craft.window._call('getTitle', {}, 'settings') |
| 48 | 69 | if (title !== 'Child 1') throw new Error('child title not routed to creator: ' + title) |
| 49 | 70 | |
| 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 | ||
| 50 | 114 | const [queuedA, queuedB] = await Promise.all([ |
| 51 | 115 | window.craft.window.open({ name: 'queue-a', title: 'Child queue-a', url: location.origin + '/child?cycle=queue-a' }), |
| 52 | 116 | 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) => { | ||
| 151 | 215 | if (step) steps.add(step) |
| 152 | 216 | response.end('ok') |
| 153 | 217 | 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'] | |
| 155 | 219 | if (required.every(name => steps.has(name))) resolveDone() |
| 156 | 220 | else rejectDone(new Error(`incomplete smoke steps: ${[...steps].join(', ')}`)) |
| 157 | 221 | } |