@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
@@ -0,0 +1,385 @@
1
+ //! Asynchronous native dialogs. A request returns at once; the platform
2
+ //! presents the dialog without blocking the event loop and reports back
3
+ //! through a wake, and the runtime then hands the result to the
4
+ //! requester's completion. One dialog shows per parent window (and one
5
+ //! among parentless dialogs); later requests for the same parent wait in
6
+ //! order. Completions only ever run from a later wake dispatch, never
7
+ //! inside `requestDialog` or `cancelDialog`, so a requester never sees its
8
+ //! own completion re-enter it.
9
+ const std = @import("std");
10
+ const platform = @import("../platform/root.zig");
11
+ const admission = @import("system_admission_policy.zig");
12
+
13
+ /// Requests the runtime holds at once, queued and presented together.
14
+ pub const max_dialog_requests: usize = 8;
15
+ /// Requester bytes a completion gets back verbatim: room for a bridge
16
+ /// request id (at most 64 bytes) plus a few bytes of routing.
17
+ pub const max_dialog_tag_bytes: usize = 80;
18
+
19
+ pub const DialogRequestOptions = struct {
20
+ options: platform.DialogOptions,
21
+ /// The owning window. Null picks the focused window, then the first
22
+ /// open one; `detached` presents without a parent instead.
23
+ parent_window_id: ?platform.WindowId = null,
24
+ detached: bool = false,
25
+ };
26
+
27
+ pub fn Completion(comptime Runtime: type) type {
28
+ return struct {
29
+ context: *anyopaque,
30
+ /// Returned to `on_result` unchanged; at most `max_dialog_tag_bytes`.
31
+ tag: []const u8 = "",
32
+ /// Runs exactly once per request from a wake dispatch, unless the
33
+ /// app stops first. `tag` and `result.paths` are valid only for the
34
+ /// duration of the call. An error is recorded like a handler error.
35
+ on_result: *const fn (context: *anyopaque, runtime: *Runtime, tag: []const u8, result: platform.DialogResult) anyerror!void,
36
+ };
37
+ }
38
+
39
+ const State = enum { free, queued, presented, finished, delivering };
40
+
41
+ fn Entry(comptime Runtime: type) type {
42
+ return struct {
43
+ state: State = .free,
44
+ id: platform.DialogRequestId = 0,
45
+ parent: ?platform.WindowId = null,
46
+ order: u64 = 0,
47
+ completion: Completion(Runtime) = undefined,
48
+ tag_storage: [max_dialog_tag_bytes]u8 = undefined,
49
+ tag_len: usize = 0,
50
+ /// Owns the copied options and, once finished, the result paths.
51
+ arena: std.heap.ArenaAllocator = undefined,
52
+ options: platform.DialogOptions = undefined,
53
+ result: platform.DialogResult = undefined,
54
+ };
55
+ }
56
+
57
+ pub fn Broker(comptime Runtime: type) type {
58
+ return struct {
59
+ entries: [max_dialog_requests]Entry(Runtime) = [_]Entry(Runtime){.{}} ** max_dialog_requests,
60
+ next_id: platform.DialogRequestId = 1,
61
+ next_order: u64 = 0,
62
+ /// Polled platform results and synchronous fallbacks write here.
63
+ result_buffer: [platform.max_dialog_paths_bytes]u8 = undefined,
64
+
65
+ /// `Runtime.initAt` skips large field defaults; this is the part
66
+ /// that must start defined.
67
+ pub fn reset(self: *@This()) void {
68
+ for (&self.entries) |*entry| entry.* = .{};
69
+ self.next_id = 1;
70
+ self.next_order = 0;
71
+ }
72
+ };
73
+ }
74
+
75
+ pub fn RuntimeDialogs(comptime Runtime: type) type {
76
+ return struct {
77
+ const DialogEntry = Entry(Runtime);
78
+ const DialogCompletion = Completion(Runtime);
79
+
80
+ /// Queue a dialog and return its id. The platform presents it as
81
+ /// soon as no other dialog shows for the same parent.
82
+ pub fn requestDialog(self: *Runtime, request: DialogRequestOptions, completion: DialogCompletion) anyerror!platform.DialogRequestId {
83
+ const broker = &self.dialog_broker;
84
+ if (completion.tag.len > max_dialog_tag_bytes) return error.DialogTagTooLarge;
85
+ try admitOptions(self, request.options);
86
+ if (!canPresent(self.options.platform.services, request.options)) return error.UnsupportedService;
87
+ const parent = try resolveParent(self, request);
88
+ const entry = freeEntry(broker) orelse return error.DialogQueueFull;
89
+ entry.arena = std.heap.ArenaAllocator.init(self.owned_allocator);
90
+ errdefer entry.arena.deinit();
91
+ entry.options = try copyOptions(entry.arena.allocator(), request.options);
92
+ @memcpy(entry.tag_storage[0..completion.tag.len], completion.tag);
93
+ entry.tag_len = completion.tag.len;
94
+ entry.completion = completion;
95
+ entry.completion.tag = "";
96
+ entry.id = broker.next_id;
97
+ broker.next_id += 1;
98
+ entry.order = broker.next_order;
99
+ broker.next_order += 1;
100
+ entry.parent = parent;
101
+ entry.state = .queued;
102
+ const id = entry.id;
103
+ presentNext(self, parent);
104
+ return id;
105
+ }
106
+
107
+ pub fn requestOpenDialog(self: *Runtime, options: platform.OpenDialogOptions, parent_window_id: ?platform.WindowId, completion: DialogCompletion) anyerror!platform.DialogRequestId {
108
+ return requestDialog(self, .{ .options = .{ .open = options }, .parent_window_id = parent_window_id }, completion);
109
+ }
110
+
111
+ pub fn requestSaveDialog(self: *Runtime, options: platform.SaveDialogOptions, parent_window_id: ?platform.WindowId, completion: DialogCompletion) anyerror!platform.DialogRequestId {
112
+ return requestDialog(self, .{ .options = .{ .save = options }, .parent_window_id = parent_window_id }, completion);
113
+ }
114
+
115
+ pub fn requestMessageDialog(self: *Runtime, options: platform.MessageDialogOptions, parent_window_id: ?platform.WindowId, completion: DialogCompletion) anyerror!platform.DialogRequestId {
116
+ return requestDialog(self, .{ .options = .{ .message = options }, .parent_window_id = parent_window_id }, completion);
117
+ }
118
+
119
+ /// Close a dialog early. A queued request finishes as `.cancelled`
120
+ /// without ever showing; a presented one is closed by the platform
121
+ /// and reports `.cancelled` through its usual wake. Finished or
122
+ /// unknown ids are a no-op.
123
+ pub fn cancelDialog(self: *Runtime, id: platform.DialogRequestId) void {
124
+ const entry = findEntry(&self.dialog_broker, id) orelse return;
125
+ switch (entry.state) {
126
+ .queued => {
127
+ finish(entry, .{ .id = id, .outcome = .cancelled }) catch finishFailed(entry);
128
+ requestWake(self);
129
+ },
130
+ .presented => self.options.platform.services.cancelDialog(id),
131
+ .free, .finished, .delivering => {},
132
+ }
133
+ }
134
+
135
+ /// Hand every finished dialog to its completion: the platform's
136
+ /// results in the order it reports them, then the runtime's own
137
+ /// (cancelled while queued, synchronous fallback, failed to
138
+ /// present), oldest first. Called on every `.wake`.
139
+ pub fn adoptDialogResults(self: *Runtime) void {
140
+ const broker = &self.dialog_broker;
141
+ const services = self.options.platform.services;
142
+ while (services.pollDialogResult(&broker.result_buffer)) |result| {
143
+ const entry = findEntry(broker, result.id) orelse continue;
144
+ if (entry.state != .presented) continue;
145
+ finish(entry, result) catch finishFailed(entry);
146
+ deliver(self, entry);
147
+ }
148
+ while (oldestFinished(broker)) |entry| deliver(self, entry);
149
+ }
150
+
151
+ /// A window closed: its queued dialogs finish as `.cancelled` and
152
+ /// its presented one is closed (the platform reports it).
153
+ pub fn cancelDialogsForWindow(self: *Runtime, window_id: platform.WindowId) void {
154
+ var finished_any = false;
155
+ for (&self.dialog_broker.entries) |*entry| {
156
+ if (entry.parent == null or entry.parent.? != window_id) continue;
157
+ switch (entry.state) {
158
+ .queued => {
159
+ finish(entry, .{ .id = entry.id, .outcome = .cancelled }) catch finishFailed(entry);
160
+ finished_any = true;
161
+ },
162
+ .presented => self.options.platform.services.cancelDialog(entry.id),
163
+ .free, .finished, .delivering => {},
164
+ }
165
+ }
166
+ if (finished_any) requestWake(self);
167
+ }
168
+
169
+ /// The app is stopping: close presented dialogs and drop every
170
+ /// request without running completions. Late platform results find
171
+ /// no request and are discarded.
172
+ pub fn abandonDialogs(self: *Runtime) void {
173
+ for (&self.dialog_broker.entries) |*entry| {
174
+ if (entry.state == .presented) self.options.platform.services.cancelDialog(entry.id);
175
+ if (entry.state != .free) release(entry);
176
+ }
177
+ }
178
+
179
+ /// Runtime teardown: return the request storage. Touches no
180
+ /// platform service — the host may already be shutting down.
181
+ pub fn releaseDialogs(self: *Runtime) void {
182
+ for (&self.dialog_broker.entries) |*entry| {
183
+ if (entry.state != .free) release(entry);
184
+ }
185
+ }
186
+
187
+ pub fn pendingDialogCount(self: *const Runtime) usize {
188
+ var count: usize = 0;
189
+ for (self.dialog_broker.entries) |entry| {
190
+ if (entry.state != .free) count += 1;
191
+ }
192
+ return count;
193
+ }
194
+
195
+ fn admitOptions(self: *Runtime, options: platform.DialogOptions) !void {
196
+ const buffer = &self.dialog_broker.result_buffer;
197
+ switch (options) {
198
+ .open => |open| try admission.open(self.system_admission_policy, open, buffer),
199
+ .save => |save| try admission.save(self.system_admission_policy, save, buffer[0..platform.max_dialog_path_bytes]),
200
+ .message => |message| try admission.message(self.system_admission_policy, message),
201
+ }
202
+ }
203
+
204
+ fn canPresent(services: platform.PlatformServices, options: platform.DialogOptions) bool {
205
+ if (services.supportsAsyncDialogs()) return true;
206
+ return switch (options) {
207
+ .open => services.show_open_dialog_fn != null,
208
+ .save => services.show_save_dialog_fn != null,
209
+ .message => services.show_message_dialog_fn != null,
210
+ };
211
+ }
212
+
213
+ fn resolveParent(self: *Runtime, request: DialogRequestOptions) !?platform.WindowId {
214
+ if (request.detached) return null;
215
+ const windows = self.windows[0..self.window_count];
216
+ if (request.parent_window_id) |window_id| {
217
+ for (windows) |window| {
218
+ if (window.info.id == window_id and window.info.open) return window_id;
219
+ }
220
+ return error.WindowNotFound;
221
+ }
222
+ for (windows) |window| {
223
+ if (window.info.open and !window.info.hidden and window.info.focused) return window.info.id;
224
+ }
225
+ for (windows) |window| {
226
+ if (window.info.open and !window.info.hidden) return window.info.id;
227
+ }
228
+ return null;
229
+ }
230
+
231
+ /// Present the oldest queued request for `parent` unless a dialog
232
+ /// already shows for it.
233
+ fn presentNext(self: *Runtime, parent: ?platform.WindowId) void {
234
+ const broker = &self.dialog_broker;
235
+ var next: ?*DialogEntry = null;
236
+ for (&broker.entries) |*entry| {
237
+ if (!sameParent(entry.parent, parent)) continue;
238
+ if (entry.state == .presented) return;
239
+ if (entry.state != .queued) continue;
240
+ if (next == null or entry.order < next.?.order) next = entry;
241
+ }
242
+ const entry = next orelse return;
243
+ const services = self.options.platform.services;
244
+ entry.state = .presented;
245
+ if (services.supportsAsyncDialogs()) {
246
+ services.requestDialog(.{ .id = entry.id, .parent_window_id = entry.parent, .options = entry.options }) catch {
247
+ finish(entry, .{ .id = entry.id, .outcome = .failed }) catch finishFailed(entry);
248
+ requestWake(self);
249
+ };
250
+ return;
251
+ }
252
+ // Platforms without asynchronous dialogs block here, as the
253
+ // synchronous API always has; the result still arrives through
254
+ // a wake so completions keep one delivery path.
255
+ finish(entry, presentBlocking(services, entry, &broker.result_buffer)) catch finishFailed(entry);
256
+ requestWake(self);
257
+ }
258
+
259
+ fn presentBlocking(services: platform.PlatformServices, entry: *DialogEntry, buffer: []u8) platform.DialogResult {
260
+ const id = entry.id;
261
+ switch (entry.options) {
262
+ .open => |open| {
263
+ const result = services.showOpenDialog(open, buffer) catch return .{ .id = id, .outcome = .failed };
264
+ if (result.count == 0) return .{ .id = id, .outcome = .cancelled };
265
+ return .{ .id = id, .outcome = .selected, .paths = result.paths, .count = result.count };
266
+ },
267
+ .save => |save| {
268
+ const path = services.showSaveDialog(save, buffer[0..platform.max_dialog_path_bytes]) catch return .{ .id = id, .outcome = .failed };
269
+ const chosen = path orelse return .{ .id = id, .outcome = .cancelled };
270
+ return .{ .id = id, .outcome = .selected, .paths = chosen, .count = 1 };
271
+ },
272
+ .message => |message| {
273
+ const button = services.showMessageDialog(message) catch |err| return .{
274
+ .id = id,
275
+ .outcome = if (err == error.SessionEnding) .cancelled else .failed,
276
+ };
277
+ return .{ .id = id, .outcome = .selected, .button = button };
278
+ },
279
+ }
280
+ }
281
+
282
+ fn deliver(self: *Runtime, entry: *DialogEntry) void {
283
+ const completion = entry.completion;
284
+ const parent = entry.parent;
285
+ entry.state = .delivering;
286
+ completion.on_result(completion.context, self, entry.tag_storage[0..entry.tag_len], entry.result) catch |err| {
287
+ self.recordDispatchError("dialog.result", err);
288
+ };
289
+ release(entry);
290
+ presentNext(self, parent);
291
+ }
292
+
293
+ fn requestWake(self: *Runtime) void {
294
+ self.options.platform.services.wake() catch {};
295
+ }
296
+
297
+ fn sameParent(a: ?platform.WindowId, b: ?platform.WindowId) bool {
298
+ if (a) |left| return if (b) |right| left == right else false;
299
+ return b == null;
300
+ }
301
+
302
+ fn freeEntry(broker: *Broker(Runtime)) ?*DialogEntry {
303
+ for (&broker.entries) |*entry| {
304
+ if (entry.state == .free) return entry;
305
+ }
306
+ return null;
307
+ }
308
+
309
+ fn findEntry(broker: *Broker(Runtime), id: platform.DialogRequestId) ?*DialogEntry {
310
+ for (&broker.entries) |*entry| {
311
+ if (entry.state != .free and entry.id == id) return entry;
312
+ }
313
+ return null;
314
+ }
315
+
316
+ fn oldestFinished(broker: *Broker(Runtime)) ?*DialogEntry {
317
+ var oldest: ?*DialogEntry = null;
318
+ for (&broker.entries) |*entry| {
319
+ if (entry.state != .finished) continue;
320
+ if (oldest == null or entry.order < oldest.?.order) oldest = entry;
321
+ }
322
+ return oldest;
323
+ }
324
+
325
+ /// Keep `result` with the entry, its paths copied out of whatever
326
+ /// buffer they borrow: a completion may start another dialog that
327
+ /// reuses that buffer.
328
+ fn finish(entry: *DialogEntry, result: platform.DialogResult) !void {
329
+ var kept = result;
330
+ kept.id = entry.id;
331
+ kept.paths = try entry.arena.allocator().dupe(u8, result.paths);
332
+ entry.result = kept;
333
+ entry.state = .finished;
334
+ }
335
+
336
+ /// Out of memory while keeping a result: report the dialog as
337
+ /// failed rather than lose its completion.
338
+ fn finishFailed(entry: *DialogEntry) void {
339
+ entry.result = .{ .id = entry.id, .outcome = .failed };
340
+ entry.state = .finished;
341
+ }
342
+
343
+ fn release(entry: *DialogEntry) void {
344
+ entry.arena.deinit();
345
+ entry.* = .{};
346
+ }
347
+ };
348
+ }
349
+
350
+ fn copyOptions(allocator: std.mem.Allocator, options: platform.DialogOptions) !platform.DialogOptions {
351
+ return switch (options) {
352
+ .open => |open| .{ .open = .{
353
+ .title = try allocator.dupe(u8, open.title),
354
+ .default_path = try allocator.dupe(u8, open.default_path),
355
+ .filters = try copyFilters(allocator, open.filters),
356
+ .allow_directories = open.allow_directories,
357
+ .allow_multiple = open.allow_multiple,
358
+ } },
359
+ .save => |save| .{ .save = .{
360
+ .title = try allocator.dupe(u8, save.title),
361
+ .default_path = try allocator.dupe(u8, save.default_path),
362
+ .default_name = try allocator.dupe(u8, save.default_name),
363
+ .filters = try copyFilters(allocator, save.filters),
364
+ } },
365
+ .message => |message| .{ .message = .{
366
+ .style = message.style,
367
+ .title = try allocator.dupe(u8, message.title),
368
+ .message = try allocator.dupe(u8, message.message),
369
+ .informative_text = try allocator.dupe(u8, message.informative_text),
370
+ .primary_button = try allocator.dupe(u8, message.primary_button),
371
+ .secondary_button = try allocator.dupe(u8, message.secondary_button),
372
+ .tertiary_button = try allocator.dupe(u8, message.tertiary_button),
373
+ } },
374
+ };
375
+ }
376
+
377
+ fn copyFilters(allocator: std.mem.Allocator, filters: []const platform.FileFilter) ![]const platform.FileFilter {
378
+ const copied = try allocator.alloc(platform.FileFilter, filters.len);
379
+ for (filters, copied) |filter, *out| {
380
+ const extensions = try allocator.alloc([]const u8, filter.extensions.len);
381
+ for (filter.extensions, extensions) |extension, *slot| slot.* = try allocator.dupe(u8, extension);
382
+ out.* = .{ .name = try allocator.dupe(u8, filter.name), .extensions = extensions };
383
+ }
384
+ return copied;
385
+ }
@@ -388,11 +388,33 @@ pub const SystemServiceBinding = struct {
388
388
  reveal_path_fn: *const fn (context: *anyopaque, path: []const u8) anyerror!void,
389
389
  format_local_time_fn: *const fn (context: *anyopaque, timestamp_ms: i64, style: platform.LocalTimeStyle, buffer: []u8) anyerror![]const u8,
390
390
  execute_capability_fn: ?*const fn (context: *anyopaque, name: []const u8, payload: []const u8, output: []u8) anyerror![]const u8 = null,
391
+ /// Start a capability that finishes later (a native dialog) and return
392
+ /// a nonzero cancel handle; the reply arrives through `completion`.
393
+ /// Null means "not asynchronous": the call runs through
394
+ /// `execute_capability_fn` as before.
395
+ start_capability_fn: ?*const fn (context: *anyopaque, name: []const u8, payload: []const u8, completion: DesktopCapabilityCompletion) anyerror!?u64 = null,
396
+ /// The started capability behind `handle` was replaced or cancelled.
397
+ cancel_capability_fn: ?*const fn (context: *anyopaque, handle: u64) void = null,
391
398
  /// Restore only runtime window bookkeeping from a journaled successful
392
399
  /// capability, without invoking native window focus or close services.
393
400
  replay_window_result_fn: ?*const fn (context: *anyopaque, label: []const u8, closed: bool) void = null,
394
401
  };
395
402
 
403
+ /// How an asynchronous desktop capability hands its reply back to the
404
+ /// effects channel that started it. Type-erased so a host can keep it
405
+ /// as plain data until the capability finishes; a reply for an occupancy
406
+ /// that was replaced, cancelled or retired delivers nothing.
407
+ pub const DesktopCapabilityCompletion = struct {
408
+ context: *anyopaque,
409
+ index: u32,
410
+ generation: u32,
411
+ complete_fn: *const fn (context: *anyopaque, index: u32, generation: u32, ok: bool, reply: []const u8) void,
412
+
413
+ pub fn complete(self: DesktopCapabilityCompletion, ok: bool, reply: []const u8) void {
414
+ self.complete_fn(self.context, self.index, self.generation, ok, reply);
415
+ }
416
+ };
417
+
396
418
  /// Type-erased handle to the embedding host's named-command services,
397
419
  /// bound onto the effects channel (`bindHostCalls`). This is the seam
398
420
  /// behind `hostRequest`/`hostSend` — the generic named host call a
@@ -4646,6 +4668,10 @@ pub fn Effects(comptime Msg: type) type {
4646
4668
  // Desktop calls can enter AppKit or remove runtime views. UiApp
4647
4669
  // executes them only after its current rebuild has settled.
4648
4670
  desktop_waiting: bool = false,
4671
+ /// Nonzero while an asynchronous desktop capability (a native
4672
+ /// dialog) is in flight: the handle `start_capability_fn`
4673
+ /// returned, used to cancel it.
4674
+ desktop_handle: u64 = 0,
4649
4675
  replay_window_reply_len: u8 = 0,
4650
4676
  replay_window_reply: [38]u8 = undefined,
4651
4677
  // ---- image-only fields (kind == .image) ----
@@ -9569,7 +9595,10 @@ pub fn Effects(comptime Msg: type) type {
9569
9595
  }
9570
9596
  // Tell the host first: a late answer for the old
9571
9597
  // occupancy must find nothing.
9572
- if (slot.kind == .host and state == .running and !slot.fake) self.notifyHostCancel(options.key);
9598
+ if (slot.kind == .host and state == .running and !slot.fake) {
9599
+ self.notifyHostCancel(options.key);
9600
+ self.notifyDesktopCancel(slot);
9601
+ }
9573
9602
  if ((slot.kind == .store or slot.kind == .credentials) and state == .draining) joinWorker(slot);
9574
9603
  self.releaseFetchSlot(slot);
9575
9604
  slot.generation = 0;
@@ -9609,6 +9638,7 @@ pub fn Effects(comptime Msg: type) type {
9609
9638
  slot.on_clipboard = null;
9610
9639
  slot.on_host = options.on_result;
9611
9640
  slot.desktop_waiting = false;
9641
+ slot.desktop_handle = 0;
9612
9642
  slot.replay_window_reply_len = 0;
9613
9643
  slot.on_credentials = credentials_fn;
9614
9644
  slot.cancel_requested.store(false, .release);
@@ -9728,6 +9758,18 @@ pub fn Effects(comptime Msg: type) type {
9728
9758
  }
9729
9759
  }
9730
9760
 
9761
+ /// `DesktopCapabilityCompletion.complete_fn`: the reply of an
9762
+ /// asynchronous capability, delivered only to the occupancy that
9763
+ /// started it.
9764
+ fn completeDesktopCapability(context: *anyopaque, index: u32, generation: u32, ok: bool, reply: []const u8) void {
9765
+ const self: *Self = @ptrCast(@alignCast(context));
9766
+ if (index >= self.slots.len) return;
9767
+ const slot = &self.slots[index];
9768
+ if (slot.generation != generation or slot.desktop_handle == 0) return;
9769
+ slot.desktop_handle = 0;
9770
+ self.feedDesktopResult(index, generation, ok, reply);
9771
+ }
9772
+
9731
9773
  fn feedDesktopResult(self: *Self, index: usize, generation: u32, ok: bool, bytes: []const u8) void {
9732
9774
  const slot = &self.slots[index];
9733
9775
  if (slot.kind != .host or slot.generation != generation or slot.state.load(.acquire) != .running) return;
@@ -9748,6 +9790,18 @@ pub fn Effects(comptime Msg: type) type {
9748
9790
  @memcpy(name, slot.hostName());
9749
9791
  const payload = self.allocator.dupe(u8, slot.fetchPayload()) catch return self.feedDesktopResult(index, generation, false, "unsupported");
9750
9792
  defer self.allocator.free(payload);
9793
+ if (binding.start_capability_fn) |start| {
9794
+ const started = start(binding.context, name, payload, .{
9795
+ .context = self,
9796
+ .index = @intCast(index),
9797
+ .generation = generation,
9798
+ .complete_fn = completeDesktopCapability,
9799
+ }) catch |err| return self.feedDesktopResult(index, generation, false, @errorName(err));
9800
+ if (started) |handle| {
9801
+ slot.desktop_handle = handle;
9802
+ return;
9803
+ }
9804
+ }
9751
9805
  const output = self.allocator.alloc(u8, max_effect_host_result_bytes) catch return self.feedDesktopResult(index, generation, false, "unsupported");
9752
9806
  defer self.allocator.free(output);
9753
9807
  const result = execute(binding.context, name, payload, output) catch |err| {
@@ -10247,7 +10301,10 @@ pub fn Effects(comptime Msg: type) type {
10247
10301
  slot.cancel_requested.store(true, .release);
10248
10302
  continue;
10249
10303
  }
10250
- if (slot.kind == .host and state == .running and !slot.fake) self.notifyHostCancel(key);
10304
+ if (slot.kind == .host and state == .running and !slot.fake) {
10305
+ self.notifyHostCancel(key);
10306
+ self.notifyDesktopCancel(slot);
10307
+ }
10251
10308
  if ((slot.kind == .store or slot.kind == .credentials) and state == .draining) joinWorker(slot);
10252
10309
  self.releaseFetchSlot(slot);
10253
10310
  // A queued result entry (fed, undrained) dies by
@@ -10264,6 +10321,18 @@ pub fn Effects(comptime Msg: type) type {
10264
10321
  cancel_fn(binding.context, key);
10265
10322
  }
10266
10323
 
10324
+ /// An asynchronous desktop capability lost its occupancy (replaced
10325
+ /// or cancelled): close it. Its late completion then fails the
10326
+ /// generation check and delivers nothing.
10327
+ fn notifyDesktopCancel(self: *Self, slot: *Slot) void {
10328
+ const handle = slot.desktop_handle;
10329
+ if (handle == 0) return;
10330
+ slot.desktop_handle = 0;
10331
+ const binding = self.system_services orelse return;
10332
+ const cancel_fn = binding.cancel_capability_fn orelse return;
10333
+ cancel_fn(binding.context, handle);
10334
+ }
10335
+
10267
10336
  fn rejectHost(self: *Self, key: u64, host_fn: ?HostMsgFn) void {
10268
10337
  self.deliverLoopHost(.{ .key = key, .ok = false, .bytes = "rejected" }, host_fn, true);
10269
10338
  }
@@ -11,6 +11,7 @@ const std = @import("std");
11
11
  const geometry = @import("geometry");
12
12
  const app_manifest = @import("app_manifest");
13
13
  const platform = @import("../platform/root.zig");
14
+ const security = @import("../security/root.zig");
14
15
  const core = @import("core.zig");
15
16
  const ui_app_mod = @import("ui_app.zig");
16
17
  const effects_mod = @import("effects.zig");
@@ -75,6 +76,7 @@ const HostMsg = union(enum) {
75
76
  send,
76
77
  start_timer,
77
78
  hold_file,
79
+ pick_folder,
78
80
  host_result: effects_mod.EffectHostResult,
79
81
  file_result: effects_mod.EffectFileResult,
80
82
  tick: effects_mod.EffectTimer,
@@ -87,7 +89,7 @@ const ask_key: u64 = 77;
87
89
  const other_key: u64 = 78;
88
90
  const timer_key: u64 = 9;
89
91
 
90
- // Set by tests before dispatching `.ask`/`.replace`.
92
+ // Set by tests before dispatching `.ask`/`.replace`/`.pick_folder`.
91
93
  var test_payload: []const u8 = "";
92
94
 
93
95
  fn failLaunchAtLoginStatus(_: ?*anyopaque) anyerror!platform.LaunchAtLoginStatus {
@@ -100,6 +102,12 @@ fn failSetLaunchAtLogin(_: ?*anyopaque, _: bool) anyerror!platform.LaunchAtLogin
100
102
 
101
103
  fn hostUpdate(model: *HostModel, msg: HostMsg, fx: *HostEffects) void {
102
104
  switch (msg) {
105
+ .pick_folder => fx.hostRequest(.{
106
+ .key = ask_key,
107
+ .name = "native-sdk.dialog.openDirectory",
108
+ .payload = test_payload,
109
+ .on_result = HostEffects.hostMsg(.host_result),
110
+ }),
103
111
  .ask => fx.hostRequest(.{
104
112
  .key = ask_key,
105
113
  .name = "svc.echo",
@@ -347,6 +355,66 @@ test "native system commands use runtime validation and platform services" {
347
355
  try std.testing.expectEqualStrings("2024-01-01 21:04:05", h.app_state.model.bytesPrefix());
348
356
  }
349
357
 
358
+ test "folder picker requests open an asynchronous dialog and answer on its wake" {
359
+ var h = try Harness.create();
360
+ defer h.destroy();
361
+ const dialog_permission = [_][]const u8{security.permission_dialog};
362
+ h.harness.runtime.options.security.permissions = &dialog_permission;
363
+ h.harness.null_platform.useAsyncDialogs(&h.harness.runtime.options.platform.services);
364
+
365
+ // Version-1 request: context byte 5, title "Pick", no starting path.
366
+ test_payload = &[_]u8{ 1, 5, 4, 0, 'P', 'i', 'c', 'k', 0, 0 };
367
+ try h.app_state.dispatch(&h.harness.runtime, 1, .pick_folder);
368
+ try std.testing.expectEqual(@as(usize, 1), h.harness.null_platform.presentedDialogCount());
369
+ try std.testing.expectEqualStrings("Pick", h.harness.null_platform.findPresentedDialog(1).?.titleText());
370
+ try std.testing.expectEqual(@as(u32, 0), h.app_state.model.result_count);
371
+
372
+ try h.harness.null_platform.finishDialog(1, .selected, "/picked", .primary);
373
+ while (h.harness.null_platform.takeWake()) |_| try h.wake();
374
+ try std.testing.expectEqual(@as(u32, 1), h.app_state.model.ok_count);
375
+ try std.testing.expectEqualStrings("\x01\x05\x00\x00\x07\x00/picked", h.app_state.model.bytesPrefix());
376
+ }
377
+
378
+ test "re-issuing a folder picker key closes the open picker and opens the new one" {
379
+ var h = try Harness.create();
380
+ defer h.destroy();
381
+ const dialog_permission = [_][]const u8{security.permission_dialog};
382
+ h.harness.runtime.options.security.permissions = &dialog_permission;
383
+ h.harness.null_platform.useAsyncDialogs(&h.harness.runtime.options.platform.services);
384
+
385
+ test_payload = &[_]u8{ 1, 1, 0, 0, 0, 0 };
386
+ try h.app_state.dispatch(&h.harness.runtime, 1, .pick_folder);
387
+ try std.testing.expect(h.harness.null_platform.findPresentedDialog(1) != null);
388
+ test_payload = &[_]u8{ 1, 2, 0, 0, 0, 0 };
389
+ try h.app_state.dispatch(&h.harness.runtime, 1, .pick_folder);
390
+ try std.testing.expectEqual(@as(usize, 1), h.harness.null_platform.dialog_cancel_count);
391
+ while (h.harness.null_platform.takeWake()) |_| try h.wake();
392
+ // The replaced picker's cancellation reaches no one; its successor shows.
393
+ try std.testing.expectEqual(@as(u32, 0), h.app_state.model.result_count);
394
+ try std.testing.expect(h.harness.null_platform.findPresentedDialog(2) != null);
395
+
396
+ try h.harness.null_platform.finishDialog(2, .cancelled, "", .primary);
397
+ while (h.harness.null_platform.takeWake()) |_| try h.wake();
398
+ try std.testing.expectEqual(@as(u32, 1), h.app_state.model.ok_count);
399
+ // A cancelled picker answers an empty selection.
400
+ try std.testing.expectEqualStrings("\x01\x02\x00\x00\x00\x00", h.app_state.model.bytesPrefix());
401
+ }
402
+
403
+ test "folder picker requests on a platform without asynchronous dialogs answer at once" {
404
+ var h = try Harness.create();
405
+ defer h.destroy();
406
+ const dialog_permission = [_][]const u8{security.permission_dialog};
407
+ h.harness.runtime.options.security.permissions = &dialog_permission;
408
+
409
+ test_payload = &[_]u8{ 1, 3, 4, 0, 'P', 'i', 'c', 'k', 0, 0 };
410
+ try h.app_state.dispatch(&h.harness.runtime, 1, .pick_folder);
411
+ try std.testing.expectEqual(@as(usize, 1), h.harness.null_platform.open_dialog_count);
412
+ try std.testing.expectEqual(@as(usize, 0), h.harness.runtime.pendingDialogCount());
413
+ try h.wake();
414
+ try std.testing.expectEqual(@as(u32, 1), h.app_state.model.ok_count);
415
+ try std.testing.expectEqualStrings("\x01\x03\x00\x00\x18\x00/tmp/native-sdk-open.txt", h.app_state.model.bytesPrefix());
416
+ }
417
+
350
418
  test "re-issuing a live host key replaces the pending request and drops the undelivered result" {
351
419
  var h = try Harness.create();
352
420
  defer h.destroy();