@zogien/native-sdk 0.11.0-fork.4 → 0.11.0-fork.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/build/app.zig +50 -34
  2. package/package.json +9 -9
  3. package/packages/core/package-lock.json +2 -2
  4. package/packages/core/package.json +1 -1
  5. package/skill-data/core/references/bridge-security-native-capabilities.md +4 -0
  6. package/src/platform/linux/cef_host.cpp +22 -0
  7. package/src/platform/linux/gtk_host.c +317 -34
  8. package/src/platform/linux/gtk_host.h +37 -0
  9. package/src/platform/linux/root.zig +114 -20
  10. package/src/platform/macos/appkit_host.h +44 -0
  11. package/src/platform/macos/appkit_host.m +301 -67
  12. package/src/platform/macos/cef_host.mm +21 -0
  13. package/src/platform/macos/root.zig +117 -23
  14. package/src/platform/null_platform.zig +136 -0
  15. package/src/platform/root.zig +6 -0
  16. package/src/platform/types.zig +81 -0
  17. package/src/platform/windows/dialog_test.cpp +303 -0
  18. package/src/platform/windows/host_loop_test.cpp +169 -0
  19. package/src/platform/windows/root.zig +225 -39
  20. package/src/platform/windows/session_end_test.cpp +303 -0
  21. package/src/platform/windows/webview2_host.cpp +1064 -280
  22. package/src/root.zig +7 -0
  23. package/src/runtime/builtin_bridge.zig +108 -13
  24. package/src/runtime/core.zig +24 -3
  25. package/src/runtime/dialog_tests.zig +367 -0
  26. package/src/runtime/dialogs.zig +385 -0
  27. package/src/runtime/effects.zig +71 -2
  28. package/src/runtime/effects_host_tests.zig +69 -1
  29. package/src/runtime/flow.zig +14 -6
  30. package/src/runtime/flow_reference.zig +14 -6
  31. package/src/runtime/platform_bridge_tests.zig +1 -0
  32. package/src/runtime/root.zig +5 -0
  33. package/src/runtime/system_services.zig +64 -82
  34. package/src/runtime/ui_app.zig +67 -0
  35. package/tools/native-sdk/main.zig +1 -1
package/src/root.zig CHANGED
@@ -270,6 +270,13 @@ pub const SaveDialogOptions = platform.SaveDialogOptions;
270
270
  pub const MessageDialogStyle = platform.MessageDialogStyle;
271
271
  pub const MessageDialogResult = platform.MessageDialogResult;
272
272
  pub const MessageDialogOptions = platform.MessageDialogOptions;
273
+ pub const DialogRequestId = platform.DialogRequestId;
274
+ pub const DialogKind = platform.DialogKind;
275
+ pub const DialogOptions = platform.DialogOptions;
276
+ pub const DialogOutcome = platform.DialogOutcome;
277
+ pub const DialogResult = platform.DialogResult;
278
+ pub const DialogCompletion = runtime.DialogCompletion;
279
+ pub const DialogRequestOptions = runtime.DialogRequestOptions;
273
280
  pub const NotificationOptions = platform.NotificationOptions;
274
281
  pub const StatusItemId = platform.StatusItemId;
275
282
  pub const primary_status_item_id = platform.primary_status_item_id;
@@ -52,6 +52,7 @@ pub fn RuntimeBuiltinBridge(comptime Runtime: type) type {
52
52
  const App = runtime_api.App(Runtime);
53
53
  const SystemServiceMethods = runtime_system_services.RuntimeSystemServices(Runtime);
54
54
  const WindowViewMethods = runtime_window_views.RuntimeWindowViews(Runtime);
55
+ const AsyncBridgeResponseSlot = @import("async_bridge.zig").AsyncBridgeResponseSlot(Runtime);
55
56
 
56
57
  return struct {
57
58
  const Self = @This();
@@ -125,15 +126,106 @@ pub fn RuntimeBuiltinBridge(comptime Runtime: type) type {
125
126
  return bridge.writeSuccessResponse(response_buffer, request.id, result);
126
127
  }
127
128
 
128
- fn dispatchCompiledDialogBridgeCommand(self: *Runtime, request: bridge.Request, result_buffer: []u8, response_buffer: []u8) []const u8 {
129
+ fn dispatchCompiledDialogBridgeCommand(self: *Runtime, request: bridge.Request, message: platform.BridgeMessage, result_buffer: []u8, response_buffer: []u8) ?[]const u8 {
129
130
  const plan = builtin_coordination.route(self.builtin_coordination_policy.?, .dialog, request.command);
130
- const result = switch (plan.verb()) {
131
- .dialog_openFile => SystemServiceMethods.openFileDialogFromJson(self, request.payload, result_buffer) catch |err| return Self.writeCompiledBuiltinError(self, request.id, err, response_buffer),
132
- .dialog_saveFile => SystemServiceMethods.saveFileDialogFromJson(self, request.payload, result_buffer) catch |err| return Self.writeCompiledBuiltinError(self, request.id, err, response_buffer),
133
- .dialog_showMessage => SystemServiceMethods.showMessageDialogFromJson(self, request.payload, result_buffer) catch |err| return Self.writeCompiledBuiltinError(self, request.id, err, response_buffer),
131
+ const kind: platform.DialogKind = switch (plan.verb()) {
132
+ .dialog_openFile => .open,
133
+ .dialog_saveFile => .save,
134
+ .dialog_showMessage => .message,
134
135
  else => return bridge.writeErrorResponse(response_buffer, request.id, .unknown_command, plan.unknownMessage()),
135
136
  };
136
- return bridge.writeSuccessResponse(response_buffer, request.id, result);
137
+ return Self.startDialogBridgeCommand(self, kind, request, message, result_buffer, response_buffer);
138
+ }
139
+
140
+ /// Queue the dialog and answer the bridge request when it
141
+ /// finishes. Null means the response is deferred; a returned
142
+ /// response is an immediate refusal, or, on a platform without
143
+ /// asynchronous dialogs, the blocking dialog's answer.
144
+ fn startDialogBridgeCommand(self: *Runtime, kind: platform.DialogKind, request: bridge.Request, message: platform.BridgeMessage, result_buffer: []u8, response_buffer: []u8) ?[]const u8 {
145
+ var storage = json.StringStorage.init(result_buffer);
146
+ const options = SystemServiceMethods.dialogOptionsFromJson(kind, request.payload, &storage) catch |err| return Self.writeDialogBridgeError(self, request.id, err, response_buffer);
147
+ if (!self.options.platform.services.supportsAsyncDialogs()) return Self.answerDialogBridgeCommand(self, kind, request.id, options, result_buffer, response_buffer);
148
+ const slot =Self.reserveDialogBridgeResponse(self, message) catch |err| return Self.writeDialogBridgeError(self, request.id, err, response_buffer);
149
+ var tag: [1 + bridge.max_id_bytes]u8 = undefined;
150
+ tag[0] = @intFromEnum(kind);
151
+ @memcpy(tag[1..][0..request.id.len], request.id);
152
+ _ = self.requestDialog(.{
153
+ .options = options,
154
+ .parent_window_id = Self.openWindowId(self, message.window_id),
155
+ }, .{
156
+ .context = slot,
157
+ .tag = tag[0 .. 1 + request.id.len],
158
+ .on_result = Self.finishDialogBridgeCommand,
159
+ }) catch |err| {
160
+ slot.release();
161
+ return Self.writeDialogBridgeError(self, request.id, err, response_buffer);
162
+ };
163
+ return null;
164
+ }
165
+
166
+ fn finishDialogBridgeCommand(context: *anyopaque, runtime: *Runtime, tag: []const u8, result: platform.DialogResult) anyerror!void {
167
+ _ = runtime;
168
+ const slot: *AsyncBridgeResponseSlot = @ptrCast(@alignCast(context));
169
+ const kind: platform.DialogKind = @enumFromInt(tag[0]);
170
+ const id = tag[1..];
171
+ var result_buffer: [bridge.max_result_bytes]u8 = undefined;
172
+ var response_buffer: [bridge.max_response_bytes]u8 = undefined;
173
+ const response = if (result.outcome == .failed)
174
+ bridge.writeErrorResponse(&response_buffer, id, .internal_error, "Dialog could not be shown")
175
+ else if (SystemServiceMethods.writeDialogResultJson(kind, result, &result_buffer)) |value|
176
+ bridge.writeSuccessResponse(&response_buffer, id, value)
177
+ else |err|
178
+ bridge.writeErrorResponse(&response_buffer, id, builtinBridgeErrorCode(err), builtinBridgeErrorMessage(err));
179
+ try slot.respond(response);
180
+ }
181
+
182
+ /// Platforms without asynchronous dialogs (Chromium builds, the
183
+ /// null platform) answer from the blocking call, at once, as they
184
+ /// always have. `options` borrows `result_buffer`, which is free
185
+ /// again once the dialog has returned.
186
+ fn answerDialogBridgeCommand(self: *Runtime, kind: platform.DialogKind, id: []const u8, options: platform.DialogOptions, result_buffer: []u8, response_buffer: []u8) []const u8 {
187
+ var paths: [platform.max_dialog_paths_bytes]u8 = undefined;
188
+ const result: platform.DialogResult = switch (options) {
189
+ .open => |open| opened: {
190
+ const opened = self.showOpenDialog(open, &paths) catch |err| return Self.writeDialogBridgeError(self, id, err, response_buffer);
191
+ break :opened .{ .id = 0, .outcome = if (opened.count == 0) .cancelled else .selected, .paths = opened.paths, .count = opened.count };
192
+ },
193
+ .save => |save| saved: {
194
+ const saved = self.showSaveDialog(save, paths[0..platform.max_dialog_path_bytes]) catch |err| return Self.writeDialogBridgeError(self, id, err, response_buffer);
195
+ const path = saved orelse break :saved .{ .id = 0, .outcome = .cancelled };
196
+ break :saved .{ .id = 0, .outcome = .selected, .paths = path, .count = 1 };
197
+ },
198
+ .message => |dialog| .{
199
+ .id = 0,
200
+ .outcome = .selected,
201
+ .button = self.showMessageDialog(dialog) catch |err| return Self.writeDialogBridgeError(self, id, err, response_buffer),
202
+ },
203
+ };
204
+ const value = SystemServiceMethods.writeDialogResultJson(kind, result, result_buffer) catch |err| return Self.writeDialogBridgeError(self, id, err, response_buffer);
205
+ return bridge.writeSuccessResponse(response_buffer, id, value);
206
+ }
207
+
208
+ fn writeDialogBridgeError(self: *Runtime, id: []const u8, err: anyerror, output: []u8) []const u8 {
209
+ if (self.builtin_coordination_policy != null) return Self.writeCompiledBuiltinError(self, id, err, output);
210
+ return bridge.writeErrorResponse(output, id, builtinBridgeErrorCode(err), builtinBridgeErrorMessage(err));
211
+ }
212
+
213
+ fn reserveDialogBridgeResponse(self: *Runtime, message: platform.BridgeMessage) !*AsyncBridgeResponseSlot {
214
+ for (&self.async_bridge_responses) |*slot| {
215
+ if (slot.in_use) continue;
216
+ try slot.init(self, .{ .origin = message.origin, .window_id = message.window_id, .webview_label = message.webview_label });
217
+ return slot;
218
+ }
219
+ return error.AsyncBridgeResponseLimitReached;
220
+ }
221
+
222
+ /// The requesting window when the runtime knows it as open;
223
+ /// otherwise null lets the broker pick a parent.
224
+ fn openWindowId(self: *Runtime, window_id: platform.WindowId) ?platform.WindowId {
225
+ for (self.windows[0..self.window_count]) |window| {
226
+ if (window.info.id == window_id and window.info.open) return window_id;
227
+ }
228
+ return null;
137
229
  }
138
230
 
139
231
  fn dispatchCompiledOsBridgeCommand(self: *Runtime, request: bridge.Request, origin: []const u8, result_buffer: []u8, response_buffer: []u8) []const u8 {
@@ -306,17 +398,20 @@ pub fn RuntimeBuiltinBridge(comptime Runtime: type) type {
306
398
  return bridge.writeSuccessResponse(response_buffer, request.id, result);
307
399
  }
308
400
 
309
- pub fn dispatchDialogBridgeCommand(self: *Runtime, request: bridge.Request, result_buffer: []u8, response_buffer: []u8) []const u8 {
310
- if (self.builtin_coordination_policy != null) return Self.dispatchCompiledDialogBridgeCommand(self, request, result_buffer, response_buffer);
311
- const result = if (std.mem.eql(u8, request.command, "native-sdk.dialog.openFile"))
312
- SystemServiceMethods.openFileDialogFromJson(self, request.payload, result_buffer) catch |err| return bridge.writeErrorResponse(response_buffer, request.id, builtinBridgeErrorCode(err), builtinBridgeErrorMessage(err))
401
+ /// Dialog commands answer asynchronously: null means the response
402
+ /// will follow when the dialog finishes (see
403
+ /// `startDialogBridgeCommand`).
404
+ pub fn dispatchDialogBridgeCommand(self: *Runtime, request: bridge.Request, message: platform.BridgeMessage, result_buffer: []u8, response_buffer: []u8) ?[]const u8 {
405
+ if (self.builtin_coordination_policy != null) return Self.dispatchCompiledDialogBridgeCommand(self, request, message, result_buffer, response_buffer);
406
+ const kind: platform.DialogKind = if (std.mem.eql(u8, request.command, "native-sdk.dialog.openFile"))
407
+ .open
313
408
  else if (std.mem.eql(u8, request.command, "native-sdk.dialog.saveFile"))
314
- SystemServiceMethods.saveFileDialogFromJson(self, request.payload, result_buffer) catch |err| return bridge.writeErrorResponse(response_buffer, request.id, builtinBridgeErrorCode(err), builtinBridgeErrorMessage(err))
409
+ .save
315
410
  else if (std.mem.eql(u8, request.command, "native-sdk.dialog.showMessage"))
316
- SystemServiceMethods.showMessageDialogFromJson(self, request.payload, result_buffer) catch |err| return bridge.writeErrorResponse(response_buffer, request.id, builtinBridgeErrorCode(err), builtinBridgeErrorMessage(err))
411
+ .message
317
412
  else
318
413
  return bridge.writeErrorResponse(response_buffer, request.id, .unknown_command, "Unknown dialog command");
319
- return bridge.writeSuccessResponse(response_buffer, request.id, result);
414
+ return Self.startDialogBridgeCommand(self, kind, request, message, result_buffer, response_buffer);
320
415
  }
321
416
 
322
417
  pub fn dispatchOsBridgeCommand(self: *Runtime, request: bridge.Request, origin: []const u8, result_buffer: []u8, response_buffer: []u8) []const u8 {
@@ -30,6 +30,7 @@ const runtime_session_state = @import("session_state.zig");
30
30
  const session_journal = @import("session_journal.zig");
31
31
  const runtime_state = @import("state.zig");
32
32
  const runtime_system_services = @import("system_services.zig");
33
+ const runtime_dialogs = @import("dialogs.zig");
33
34
  const runtime_builtin_bridge = @import("builtin_bridge.zig");
34
35
  const runtime_view = @import("view.zig");
35
36
  const runtime_window_views = @import("window_views.zig");
@@ -110,6 +111,10 @@ const FocusTraversalDirection = runtime_state.FocusTraversalDirection;
110
111
  const copySourceInto = runtime_state.copySourceInto;
111
112
  const sourceWebViewUrl = runtime_state.sourceWebViewUrl;
112
113
  const AsyncBridgeResponseSlot = runtime_async_bridge.AsyncBridgeResponseSlot(Runtime);
114
+ pub const DialogCompletion = runtime_dialogs.Completion(Runtime);
115
+ pub const DialogRequestOptions = runtime_dialogs.DialogRequestOptions;
116
+ pub const max_dialog_requests = runtime_dialogs.max_dialog_requests;
117
+ pub const max_dialog_tag_bytes = runtime_dialogs.max_dialog_tag_bytes;
113
118
  const platformCursorFromCanvas = widget_bridge.platformCursorFromCanvas;
114
119
  const widgetRoleName = widget_bridge.widgetRoleName;
115
120
  const platformWidgetAccessibilityRole = widget_bridge.platformWidgetAccessibilityRole;
@@ -351,6 +356,8 @@ pub const Runtime = struct {
351
356
  /// copy bound, so `initAt` assigns it explicitly.
352
357
  frame_profile: runtime_frame_profile.FrameProfile = .{},
353
358
  async_bridge_responses: [max_async_bridge_responses]AsyncBridgeResponseSlot = [_]AsyncBridgeResponseSlot{.{}} ** max_async_bridge_responses,
359
+ /// Asynchronous dialogs in flight (see `dialogs.zig`).
360
+ dialog_broker: runtime_dialogs.Broker(Runtime) = .{},
354
361
  automation_windows: [automation.snapshot.max_windows]automation.snapshot.Window = undefined,
355
362
  /// Snapshot-side storage for the frame profile's per-stage stats
356
363
  /// (the snapshot Input only references runtime-owned memory).
@@ -562,6 +569,9 @@ pub const Runtime = struct {
562
569
  item.title = "";
563
570
  item.item_count = 0;
564
571
  }
572
+ // The dialog broker's result buffer exceeds the copy bound too;
573
+ // reset its bookkeeping and leave the buffer alone.
574
+ self.dialog_broker.reset();
565
575
  self.started_timestamp_ns = timestampToU64(nowNanoseconds());
566
576
  self.text_measure_provider = if (options.platform.services.measure_text_fn) |measure_fn|
567
577
  .{
@@ -600,6 +610,7 @@ pub const Runtime = struct {
600
610
  /// host) call it to return the storage.
601
611
  pub fn deinit(self: *Runtime) void {
602
612
  self.disarmMediaSurfaceWakes();
613
+ self.releaseDialogs();
603
614
  for (self.views[0..self.view_count]) |*view| {
604
615
  if (view.widget_text_bytes_heap_owned) self.owned_allocator.free(view.widget_text_bytes);
605
616
  if (view.canvas_widget_text_history_bytes_heap_owned) self.owned_allocator.free(view.canvas_widget_text_history_bytes);
@@ -832,6 +843,19 @@ pub const Runtime = struct {
832
843
  pub const showOpenDialog = SystemServiceMethods.showOpenDialog;
833
844
  pub const showSaveDialog = SystemServiceMethods.showSaveDialog;
834
845
  pub const showMessageDialog = SystemServiceMethods.showMessageDialog;
846
+
847
+ const DialogMethods = runtime_dialogs.RuntimeDialogs(Runtime);
848
+ pub const requestDialog = DialogMethods.requestDialog;
849
+ pub const requestOpenDialog = DialogMethods.requestOpenDialog;
850
+ pub const requestSaveDialog = DialogMethods.requestSaveDialog;
851
+ pub const requestMessageDialog = DialogMethods.requestMessageDialog;
852
+ pub const cancelDialog = DialogMethods.cancelDialog;
853
+ pub const adoptDialogResults = DialogMethods.adoptDialogResults;
854
+ pub const cancelDialogsForWindow = DialogMethods.cancelDialogsForWindow;
855
+ pub const abandonDialogs = DialogMethods.abandonDialogs;
856
+ pub const pendingDialogCount = DialogMethods.pendingDialogCount;
857
+ const releaseDialogs = DialogMethods.releaseDialogs;
858
+
835
859
  pub const showNotification = SystemServiceMethods.showNotification;
836
860
  pub const setCredential = SystemServiceMethods.setCredential;
837
861
  pub const getCredential = SystemServiceMethods.getCredential;
@@ -863,9 +887,6 @@ pub const Runtime = struct {
863
887
  const revealPathFromJson = SystemServiceMethods.revealPathFromJson;
864
888
  const addRecentDocumentFromJson = SystemServiceMethods.addRecentDocumentFromJson;
865
889
  const clearRecentDocumentsFromJson = SystemServiceMethods.clearRecentDocumentsFromJson;
866
- const openFileDialogFromJson = SystemServiceMethods.openFileDialogFromJson;
867
- const saveFileDialogFromJson = SystemServiceMethods.saveFileDialogFromJson;
868
- const showMessageDialogFromJson = SystemServiceMethods.showMessageDialogFromJson;
869
890
 
870
891
  /// Start (or replace) a platform timer. The platform delivers `.timer`
871
892
  /// events carrying `id` until `cancelTimer` (or after the first fire when
@@ -0,0 +1,367 @@
1
+ const support = @import("test_support.zig");
2
+ const std = support.std;
3
+ const geometry = support.geometry;
4
+ const platform = support.platform;
5
+ const App = support.App;
6
+ const Runtime = support.Runtime;
7
+ const TestHarness = support.TestHarness;
8
+ const DialogCompletion = support.runtime_module.DialogCompletion;
9
+
10
+ const Probe = struct {
11
+ calls: usize = 0,
12
+ ids: [8]platform.DialogRequestId = undefined,
13
+ outcomes: [8]platform.DialogOutcome = undefined,
14
+ last_paths: [256]u8 = undefined,
15
+ last_paths_len: usize = 0,
16
+ last_count: usize = 0,
17
+ last_button: platform.MessageDialogResult = .primary,
18
+ last_tag: [64]u8 = undefined,
19
+ last_tag_len: usize = 0,
20
+
21
+ fn completion(self: *Probe, tag: []const u8) DialogCompletion {
22
+ return .{ .context = self, .tag = tag, .on_result = onResult };
23
+ }
24
+
25
+ fn onResult(context: *anyopaque, runtime: *Runtime, tag: []const u8, result: platform.DialogResult) anyerror!void {
26
+ _ = runtime;
27
+ const self: *Probe = @ptrCast(@alignCast(context));
28
+ self.ids[self.calls] = result.id;
29
+ self.outcomes[self.calls] = result.outcome;
30
+ self.calls += 1;
31
+ self.last_paths_len = result.paths.len;
32
+ @memcpy(self.last_paths[0..result.paths.len], result.paths);
33
+ self.last_count = result.count;
34
+ self.last_button = result.button;
35
+ self.last_tag_len = tag.len;
36
+ @memcpy(self.last_tag[0..tag.len], tag);
37
+ }
38
+
39
+ fn paths(self: *const Probe) []const u8 {
40
+ return self.last_paths[0..self.last_paths_len];
41
+ }
42
+
43
+ fn tagText(self: *const Probe) []const u8 {
44
+ return self.last_tag[0..self.last_tag_len];
45
+ }
46
+ };
47
+
48
+ fn dialogApp(harness: *TestHarness()) App {
49
+ return .{ .context = harness, .name = "dialogs", .source = platform.WebViewSource.html("<p>Dialogs</p>") };
50
+ }
51
+
52
+ /// A started runtime whose null platform presents dialogs asynchronously.
53
+ fn started() !*TestHarness() {
54
+ const harness = try startedBlocking();
55
+ harness.null_platform.useAsyncDialogs(&harness.runtime.options.platform.services);
56
+ return harness;
57
+ }
58
+
59
+ /// The null platform as app tests get it: blocking dialogs that answer at
60
+ /// once.
61
+ fn startedBlocking() !*TestHarness() {
62
+ const harness = try TestHarness().create(std.testing.allocator, .{});
63
+ errdefer harness.destroy(std.testing.allocator);
64
+ try harness.runtime.dispatchPlatformEvent(dialogApp(harness), .app_start);
65
+ return harness;
66
+ }
67
+
68
+ /// Deliver the wakes the platform posted, as a host loop would.
69
+ fn deliverWakes(harness: *TestHarness()) !void {
70
+ while (harness.null_platform.takeWake()) |event| try harness.runtime.dispatchPlatformEvent(dialogApp(harness), event);
71
+ }
72
+
73
+ fn closeMainWindow(harness: *TestHarness()) !void {
74
+ try harness.runtime.dispatchPlatformEvent(dialogApp(harness), .{ .window_frame_changed = .{
75
+ .id = 1,
76
+ .label = "main",
77
+ .title = "",
78
+ .frame = geometry.RectF.init(0, 0, 720, 480),
79
+ .scale_factor = 1,
80
+ .open = false,
81
+ .focused = false,
82
+ } });
83
+ }
84
+
85
+ test "dialog requests return at once and complete on a later wake" {
86
+ const harness = try started();
87
+ defer harness.destroy(std.testing.allocator);
88
+ var probe: Probe = .{};
89
+
90
+ const id = try harness.runtime.requestOpenDialog(.{ .title = "Open", .allow_multiple = true }, null, probe.completion("request-1"));
91
+ try std.testing.expectEqual(@as(usize, 0), probe.calls);
92
+ const shown = harness.null_platform.findPresentedDialog(id).?;
93
+ try std.testing.expectEqual(platform.DialogKind.open, shown.kind);
94
+ try std.testing.expectEqual(@as(?platform.WindowId, 1), shown.parent_window_id);
95
+ try std.testing.expectEqualStrings("Open", shown.titleText());
96
+
97
+ try harness.null_platform.finishDialog(id, .selected, "/a.txt\n/b.txt", .primary);
98
+ try std.testing.expectEqual(@as(usize, 0), probe.calls);
99
+ try deliverWakes(harness);
100
+
101
+ try std.testing.expectEqual(@as(usize, 1), probe.calls);
102
+ try std.testing.expectEqual(id, probe.ids[0]);
103
+ try std.testing.expectEqual(platform.DialogOutcome.selected, probe.outcomes[0]);
104
+ try std.testing.expectEqualStrings("/a.txt\n/b.txt", probe.paths());
105
+ try std.testing.expectEqual(@as(usize, 2), probe.last_count);
106
+ try std.testing.expectEqualStrings("request-1", probe.tagText());
107
+ try std.testing.expectEqual(@as(usize, 0), harness.runtime.pendingDialogCount());
108
+ }
109
+
110
+ test "dialogs for one window show one at a time in request order" {
111
+ const harness = try started();
112
+ defer harness.destroy(std.testing.allocator);
113
+ var probe: Probe = .{};
114
+
115
+ const first = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
116
+ const second = try harness.runtime.requestMessageDialog(.{ .message = "Next" }, 1, probe.completion(""));
117
+ const detached = try harness.runtime.requestDialog(.{ .options = .{ .save = .{ .default_name = "a.txt" } }, .detached = true }, probe.completion(""));
118
+ try std.testing.expectEqual(@as(usize, 2), harness.null_platform.presentedDialogCount());
119
+ try std.testing.expect(harness.null_platform.findPresentedDialog(second) == null);
120
+ try std.testing.expectEqual(@as(?platform.WindowId, null), harness.null_platform.findPresentedDialog(detached).?.parent_window_id);
121
+
122
+ try harness.null_platform.finishDialog(first, .cancelled, "", .primary);
123
+ try deliverWakes(harness);
124
+ try std.testing.expect(harness.null_platform.findPresentedDialog(second) != null);
125
+
126
+ try harness.null_platform.finishDialog(detached, .selected, "/a.txt", .primary);
127
+ try deliverWakes(harness);
128
+ try harness.null_platform.finishDialog(second, .selected, "", .secondary);
129
+ try deliverWakes(harness);
130
+
131
+ try std.testing.expectEqual(@as(usize, 3), probe.calls);
132
+ try std.testing.expectEqualSlices(platform.DialogRequestId, &.{ first, detached, second }, probe.ids[0..3]);
133
+ try std.testing.expectEqual(platform.MessageDialogResult.secondary, probe.last_button);
134
+ }
135
+
136
+ test "cancelling a queued dialog never shows it and a presented one closes" {
137
+ const harness = try started();
138
+ defer harness.destroy(std.testing.allocator);
139
+ var probe: Probe = .{};
140
+
141
+ const first = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
142
+ const second = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
143
+ harness.runtime.cancelDialog(second);
144
+ try std.testing.expectEqual(@as(usize, 0), probe.calls);
145
+ try deliverWakes(harness);
146
+ try std.testing.expectEqual(@as(usize, 1), probe.calls);
147
+ try std.testing.expectEqual(second, probe.ids[0]);
148
+ try std.testing.expectEqual(platform.DialogOutcome.cancelled, probe.outcomes[0]);
149
+ try std.testing.expectEqual(@as(usize, 1), harness.null_platform.dialog_request_count);
150
+
151
+ harness.runtime.cancelDialog(first);
152
+ try std.testing.expectEqual(@as(usize, 1), harness.null_platform.dialog_cancel_count);
153
+ try deliverWakes(harness);
154
+ try std.testing.expectEqual(@as(usize, 2), probe.calls);
155
+ try std.testing.expectEqual(platform.DialogOutcome.cancelled, probe.outcomes[1]);
156
+ harness.runtime.cancelDialog(first);
157
+ try std.testing.expectEqual(@as(usize, 1), harness.null_platform.dialog_cancel_count);
158
+ }
159
+
160
+ test "closing the parent window cancels its dialogs and leaves others" {
161
+ const harness = try started();
162
+ defer harness.destroy(std.testing.allocator);
163
+ var probe: Probe = .{};
164
+
165
+ const shown = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
166
+ const queued = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
167
+ const detached = try harness.runtime.requestDialog(.{ .options = .{ .open = .{} }, .detached = true }, probe.completion(""));
168
+ try closeMainWindow(harness);
169
+ try deliverWakes(harness);
170
+
171
+ // Platform-reported results drain before the runtime's own.
172
+ try std.testing.expectEqual(@as(usize, 2), probe.calls);
173
+ try std.testing.expectEqualSlices(platform.DialogRequestId, &.{ shown, queued }, probe.ids[0..2]);
174
+ try std.testing.expectEqualSlices(platform.DialogOutcome, &.{ .cancelled, .cancelled }, probe.outcomes[0..2]);
175
+ try std.testing.expect(harness.null_platform.findPresentedDialog(detached) != null);
176
+ }
177
+
178
+ test "an app stop drops pending dialogs without running completions" {
179
+ const harness = try started();
180
+ defer harness.destroy(std.testing.allocator);
181
+ var probe: Probe = .{};
182
+
183
+ _ = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
184
+ _ = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
185
+ try harness.runtime.dispatchPlatformEvent(dialogApp(harness), .app_shutdown);
186
+ try std.testing.expectEqual(@as(usize, 0), harness.runtime.pendingDialogCount());
187
+ try std.testing.expectEqual(@as(usize, 1), harness.null_platform.dialog_cancel_count);
188
+ try deliverWakes(harness);
189
+ try std.testing.expectEqual(@as(usize, 0), probe.calls);
190
+ }
191
+
192
+ test "a dialog the platform cannot present reports failed" {
193
+ const harness = try started();
194
+ defer harness.destroy(std.testing.allocator);
195
+ var probe: Probe = .{};
196
+
197
+ harness.null_platform.fail_next_dialog_request = true;
198
+ const failed = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
199
+ const next = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
200
+ try deliverWakes(harness);
201
+ try std.testing.expectEqual(@as(usize, 1), probe.calls);
202
+ try std.testing.expectEqual(failed, probe.ids[0]);
203
+ try std.testing.expectEqual(platform.DialogOutcome.failed, probe.outcomes[0]);
204
+ try std.testing.expect(harness.null_platform.findPresentedDialog(next) != null);
205
+ }
206
+
207
+ test "platforms without asynchronous dialogs fall back to the blocking call" {
208
+ const harness = try startedBlocking();
209
+ defer harness.destroy(std.testing.allocator);
210
+ var probe: Probe = .{};
211
+
212
+ _ = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
213
+ try std.testing.expectEqual(@as(usize, 1), harness.null_platform.open_dialog_count);
214
+ try std.testing.expectEqual(@as(usize, 0), probe.calls);
215
+ try deliverWakes(harness);
216
+ try std.testing.expectEqual(@as(usize, 1), probe.calls);
217
+ try std.testing.expectEqualStrings("/tmp/native-sdk-open.txt", probe.paths());
218
+
219
+ harness.null_platform.message_dialog_result = .tertiary;
220
+ _ = try harness.runtime.requestMessageDialog(.{ .message = "Sure?" }, null, probe.completion(""));
221
+ try deliverWakes(harness);
222
+ try std.testing.expectEqual(platform.DialogOutcome.selected, probe.outcomes[1]);
223
+ try std.testing.expectEqual(platform.MessageDialogResult.tertiary, probe.last_button);
224
+ }
225
+
226
+ test "a completion may request the next dialog without jumping the queue" {
227
+ const harness = try started();
228
+ defer harness.destroy(std.testing.allocator);
229
+ const Chain = struct {
230
+ harness: *TestHarness(),
231
+ probe: Probe = .{},
232
+ third: platform.DialogRequestId = 0,
233
+
234
+ fn onResult(context: *anyopaque, runtime: *Runtime, tag: []const u8, result: platform.DialogResult) anyerror!void {
235
+ const self: *@This() = @ptrCast(@alignCast(context));
236
+ try Probe.onResult(&self.probe, runtime, tag, result);
237
+ if (self.third == 0) self.third = try runtime.requestOpenDialog(.{}, 1, .{ .context = self, .on_result = onResult });
238
+ }
239
+ };
240
+ var chain: Chain = .{ .harness = harness };
241
+
242
+ const first = try harness.runtime.requestOpenDialog(.{}, null, .{ .context = &chain, .on_result = Chain.onResult });
243
+ const second = try harness.runtime.requestOpenDialog(.{}, null, .{ .context = &chain, .on_result = Chain.onResult });
244
+ try harness.null_platform.finishDialog(first, .cancelled, "", .primary);
245
+ try deliverWakes(harness);
246
+ try std.testing.expect(chain.third != 0);
247
+ try std.testing.expect(harness.null_platform.findPresentedDialog(second) != null);
248
+ try std.testing.expect(harness.null_platform.findPresentedDialog(chain.third) == null);
249
+
250
+ try harness.null_platform.finishDialog(second, .cancelled, "", .primary);
251
+ try deliverWakes(harness);
252
+ try std.testing.expect(harness.null_platform.findPresentedDialog(chain.third) != null);
253
+ }
254
+
255
+ test "dialog requests are validated before they queue" {
256
+ const harness = try started();
257
+ defer harness.destroy(std.testing.allocator);
258
+ var probe: Probe = .{};
259
+
260
+ const long_tag = [_]u8{'t'} ** (support.runtime_module.max_dialog_tag_bytes + 1);
261
+ try std.testing.expectError(error.DialogTagTooLarge, harness.runtime.requestOpenDialog(.{}, null, probe.completion(&long_tag)));
262
+ try std.testing.expectError(error.InvalidDialogOptions, harness.runtime.requestMessageDialog(.{ .primary_button = "" }, null, probe.completion("")));
263
+ const long_title = [_]u8{'x'} ** (platform.max_dialog_title_bytes + 1);
264
+ try std.testing.expectError(error.DialogFieldTooLarge, harness.runtime.requestOpenDialog(.{ .title = &long_title }, null, probe.completion("")));
265
+ try std.testing.expectError(error.WindowNotFound, harness.runtime.requestOpenDialog(.{}, 99, probe.completion("")));
266
+ try std.testing.expectEqual(@as(usize, 0), harness.runtime.pendingDialogCount());
267
+
268
+ for (0..support.runtime_module.max_dialog_requests) |_| _ = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
269
+ try std.testing.expectError(error.DialogQueueFull, harness.runtime.requestOpenDialog(.{}, null, probe.completion("")));
270
+ }
271
+
272
+ test "a failing completion is recorded and the queue moves on" {
273
+ const harness = try started();
274
+ defer harness.destroy(std.testing.allocator);
275
+ const Failing = struct {
276
+ fn onResult(context: *anyopaque, runtime: *Runtime, tag: []const u8, result: platform.DialogResult) anyerror!void {
277
+ _ = .{ context, runtime, tag, result };
278
+ return error.CompletionFailed;
279
+ }
280
+ };
281
+ var probe: Probe = .{};
282
+ const first = try harness.runtime.requestOpenDialog(.{}, null, .{ .context = &probe, .on_result = Failing.onResult });
283
+ const second = try harness.runtime.requestOpenDialog(.{}, null, probe.completion(""));
284
+ const errors_before = harness.runtime.dispatch_error_total;
285
+ try harness.null_platform.finishDialog(first, .cancelled, "", .primary);
286
+ try deliverWakes(harness);
287
+ try std.testing.expectEqual(errors_before + 1, harness.runtime.dispatch_error_total);
288
+ try std.testing.expect(harness.null_platform.findPresentedDialog(second) != null);
289
+ }
290
+
291
+ fn allowDialogBridge(harness: *TestHarness()) void {
292
+ const permissions = struct {
293
+ const dialog = [_][]const u8{support.security.permission_dialog};
294
+ const commands = [_]support.bridge.CommandPolicy{
295
+ .{ .name = "native-sdk.dialog.openFile", .permissions = &dialog, .origins = &.{"zero://inline"} },
296
+ .{ .name = "native-sdk.dialog.saveFile", .permissions = &dialog, .origins = &.{"zero://inline"} },
297
+ .{ .name = "native-sdk.dialog.showMessage", .permissions = &dialog, .origins = &.{"zero://inline"} },
298
+ };
299
+ };
300
+ harness.runtime.options.security.permissions = &permissions.dialog;
301
+ harness.runtime.options.builtin_bridge = .{ .enabled = true, .commands = &permissions.commands };
302
+ }
303
+
304
+ fn sendBridge(harness: *TestHarness(), bytes: []const u8) !void {
305
+ try harness.runtime.dispatchPlatformEvent(dialogApp(harness), .{ .bridge_message = .{ .bytes = bytes, .origin = "zero://inline", .window_id = 1 } });
306
+ }
307
+
308
+ test "built-in bridge dialogs answer when the dialog finishes" {
309
+ const harness = try started();
310
+ defer harness.destroy(std.testing.allocator);
311
+ allowDialogBridge(harness);
312
+
313
+ try sendBridge(harness, "{\"id\":\"pick\",\"command\":\"native-sdk.dialog.openFile\",\"payload\":{\"title\":\"Pick\",\"allowMultiple\":true}}");
314
+ try std.testing.expectEqualStrings("", harness.null_platform.lastBridgeResponse());
315
+ const shown = harness.null_platform.findPresentedDialog(1).?;
316
+ try std.testing.expectEqualStrings("Pick", shown.titleText());
317
+ try std.testing.expectEqual(@as(?platform.WindowId, 1), shown.parent_window_id);
318
+
319
+ try harness.null_platform.finishDialog(1, .selected, "/a.txt\n/b.txt", .primary);
320
+ try deliverWakes(harness);
321
+ const response = harness.null_platform.lastBridgeResponse();
322
+ try std.testing.expect(std.mem.indexOf(u8, response, "\"id\":\"pick\"") != null);
323
+ try std.testing.expect(std.mem.indexOf(u8, response, "[\"/a.txt\",\"/b.txt\"]") != null);
324
+ }
325
+
326
+ test "built-in bridge dialogs report dismissal, cancellation and failure" {
327
+ const harness = try started();
328
+ defer harness.destroy(std.testing.allocator);
329
+ allowDialogBridge(harness);
330
+
331
+ try sendBridge(harness, "{\"id\":\"ask\",\"command\":\"native-sdk.dialog.showMessage\",\"payload\":{\"message\":\"Delete?\",\"primaryButton\":\"Delete\",\"secondaryButton\":\"Keep\"}}");
332
+ try harness.null_platform.finishDialog(1, .cancelled, "", .primary);
333
+ try deliverWakes(harness);
334
+ try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"dismissed\"") != null);
335
+
336
+ try sendBridge(harness, "{\"id\":\"save\",\"command\":\"native-sdk.dialog.saveFile\",\"payload\":{\"defaultName\":\"a.txt\"}}");
337
+ try harness.null_platform.finishDialog(2, .cancelled, "", .primary);
338
+ try deliverWakes(harness);
339
+ const saved = harness.null_platform.lastBridgeResponse();
340
+ try std.testing.expect(std.mem.indexOf(u8, saved, "\"id\":\"save\"") != null);
341
+ try std.testing.expect(std.mem.indexOf(u8, saved, "null") != null);
342
+
343
+ harness.null_platform.fail_next_dialog_request = true;
344
+ try sendBridge(harness, "{\"id\":\"broken\",\"command\":\"native-sdk.dialog.openFile\",\"payload\":{}}");
345
+ try deliverWakes(harness);
346
+ const broken = harness.null_platform.lastBridgeResponse();
347
+ try std.testing.expect(std.mem.indexOf(u8, broken, "\"id\":\"broken\"") != null);
348
+ try std.testing.expect(std.mem.indexOf(u8, broken, "\"internal_error\"") != null);
349
+ }
350
+
351
+ test "built-in bridge dialogs on platforms without asynchronous dialogs answer at once" {
352
+ const harness = try startedBlocking();
353
+ defer harness.destroy(std.testing.allocator);
354
+ allowDialogBridge(harness);
355
+
356
+ try sendBridge(harness, "{\"id\":\"pick\",\"command\":\"native-sdk.dialog.openFile\",\"payload\":{\"title\":\"Pick\"}}");
357
+ const picked = harness.null_platform.lastBridgeResponse();
358
+ try std.testing.expect(std.mem.indexOf(u8, picked, "\"id\":\"pick\"") != null);
359
+ try std.testing.expect(std.mem.indexOf(u8, picked, "[\"/tmp/native-sdk-open.txt\"]") != null);
360
+
361
+ harness.null_platform.message_dialog_result = .secondary;
362
+ try sendBridge(harness, "{\"id\":\"ask\",\"command\":\"native-sdk.dialog.showMessage\",\"payload\":{\"message\":\"Delete?\",\"secondaryButton\":\"Keep\"}}");
363
+ try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"secondary\"") != null);
364
+ try std.testing.expectEqual(@as(usize, 1), harness.null_platform.open_dialog_count);
365
+ try std.testing.expectEqual(@as(usize, 1), harness.null_platform.message_dialog_count);
366
+ try std.testing.expectEqual(@as(usize, 0), harness.runtime.pendingDialogCount());
367
+ }