@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.
- package/build/app.zig +50 -34
- package/package.json +9 -9
- package/packages/core/package-lock.json +2 -2
- package/packages/core/package.json +1 -1
- package/skill-data/core/references/bridge-security-native-capabilities.md +4 -0
- package/src/platform/linux/cef_host.cpp +22 -0
- package/src/platform/linux/gtk_host.c +317 -34
- package/src/platform/linux/gtk_host.h +37 -0
- package/src/platform/linux/root.zig +114 -20
- package/src/platform/macos/appkit_host.h +44 -0
- package/src/platform/macos/appkit_host.m +301 -67
- package/src/platform/macos/cef_host.mm +21 -0
- package/src/platform/macos/root.zig +117 -23
- package/src/platform/null_platform.zig +136 -0
- package/src/platform/root.zig +6 -0
- package/src/platform/types.zig +81 -0
- package/src/platform/windows/dialog_test.cpp +303 -0
- package/src/platform/windows/host_loop_test.cpp +169 -0
- package/src/platform/windows/root.zig +225 -39
- package/src/platform/windows/session_end_test.cpp +303 -0
- package/src/platform/windows/webview2_host.cpp +1064 -280
- package/src/root.zig +7 -0
- package/src/runtime/builtin_bridge.zig +108 -13
- package/src/runtime/core.zig +24 -3
- package/src/runtime/dialog_tests.zig +367 -0
- package/src/runtime/dialogs.zig +385 -0
- package/src/runtime/effects.zig +71 -2
- package/src/runtime/effects_host_tests.zig +69 -1
- package/src/runtime/flow.zig +14 -6
- package/src/runtime/flow_reference.zig +14 -6
- package/src/runtime/platform_bridge_tests.zig +1 -0
- package/src/runtime/root.zig +5 -0
- package/src/runtime/system_services.zig +64 -82
- package/src/runtime/ui_app.zig +67 -0
- 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
|
+
}
|
package/src/runtime/effects.zig
CHANGED
|
@@ -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)
|
|
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)
|
|
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();
|