@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
@@ -143,8 +143,8 @@ extern fn native_sdk_windows_create(app_name: [*]const u8, app_name_len: usize,
143
143
  extern fn native_sdk_windows_destroy(host: *WindowsHost) void;
144
144
  extern fn native_sdk_windows_run(host: *WindowsHost, callback: WindowsCallback, context: ?*anyopaque) void;
145
145
  extern fn native_sdk_windows_stop(host: *WindowsHost) void;
146
- extern fn native_sdk_windows_wake(host: *WindowsHost) void;
147
- extern fn native_sdk_windows_request_frame(host: *WindowsHost) void;
146
+ extern fn native_sdk_windows_wake(host: *WindowsHost) c_int;
147
+ extern fn native_sdk_windows_request_frame(host: *WindowsHost) c_int;
148
148
  extern fn native_sdk_windows_decode_image(bytes: [*]const u8, bytes_len: usize, pixels: [*]u8, pixels_len: usize, max_pixels: usize, out_width: *usize, out_height: *usize) c_int;
149
149
  extern fn native_sdk_windows_load_webview(host: *WindowsHost, source: [*]const u8, source_len: usize, source_kind: c_int, asset_root: [*]const u8, asset_root_len: usize, asset_entry: [*]const u8, asset_entry_len: usize, asset_origin: [*]const u8, asset_origin_len: usize, spa_fallback: c_int) void;
150
150
  extern fn native_sdk_windows_load_window_webview(host: *WindowsHost, window_id: u64, source: [*]const u8, source_len: usize, source_kind: c_int, asset_root: [*]const u8, asset_root_len: usize, asset_entry: [*]const u8, asset_entry_len: usize, asset_origin: [*]const u8, asset_origin_len: usize, spa_fallback: c_int) c_int;
@@ -197,6 +197,9 @@ extern fn native_sdk_windows_reveal_path(host: *WindowsHost, path: [*]const u8,
197
197
  extern fn native_sdk_windows_show_open_dialog(host: *WindowsHost, opts: *const WindowsOpenDialogOpts, buffer: [*]u8, buffer_len: usize) WindowsOpenDialogResult;
198
198
  extern fn native_sdk_windows_show_save_dialog(host: *WindowsHost, opts: *const WindowsSaveDialogOpts, buffer: [*]u8, buffer_len: usize) usize;
199
199
  extern fn native_sdk_windows_show_message_dialog(host: *WindowsHost, opts: *const WindowsMessageDialogOpts) c_int;
200
+ extern fn native_sdk_windows_request_dialog(host: *WindowsHost, request: *const WindowsDialogRequest) c_int;
201
+ extern fn native_sdk_windows_cancel_dialog(host: *WindowsHost, id: u64) void;
202
+ extern fn native_sdk_windows_poll_dialog_result(host: *WindowsHost, buffer: [*]u8, buffer_len: usize, result: *WindowsDialogResult) c_int;
200
203
  extern fn native_sdk_windows_show_notification(host: *WindowsHost, title: [*]const u8, title_len: usize, subtitle: [*]const u8, subtitle_len: usize, body: [*]const u8, body_len: usize, notification_id: [*]const u8, notification_id_len: usize, action_label: [*]const u8, action_label_len: usize, action_command: [*]const u8, action_command_len: usize) c_int;
201
204
  extern fn native_sdk_windows_create_tray(host: *WindowsHost, icon_path: [*]const u8, icon_path_len: usize, tooltip: [*]const u8, tooltip_len: usize, activation_command: [*]const u8, activation_command_len: usize, alternate_activation_command: [*]const u8, alternate_activation_command_len: usize, open_command: [*]const u8, open_command_len: usize) c_int;
202
205
  extern fn native_sdk_windows_update_tray_menu(host: *WindowsHost, item_ids: [*]const u32, labels: [*]const [*]const u8, label_lens: [*]const usize, separators: [*]const c_int, enabled_flags: [*]const c_int, details: [*]const [*]const u8, detail_lens: [*]const usize, roles: [*]const c_int, keys: [*]const [*]const u8, key_lens: [*]const usize, modifiers: [*]const u32, count: usize) c_int;
@@ -277,6 +280,23 @@ const WindowsMessageDialogOpts = extern struct {
277
280
  tertiary_button_len: usize,
278
281
  };
279
282
 
283
+ const WindowsDialogRequest = extern struct {
284
+ id: u64,
285
+ parent_window_id: u64,
286
+ kind: c_int,
287
+ open_opts: ?*const WindowsOpenDialogOpts,
288
+ save_opts: ?*const WindowsSaveDialogOpts,
289
+ message_opts: ?*const WindowsMessageDialogOpts,
290
+ };
291
+
292
+ const WindowsDialogResult = extern struct {
293
+ id: u64,
294
+ outcome: c_int,
295
+ button: c_int,
296
+ count: usize,
297
+ bytes_written: usize,
298
+ };
299
+
280
300
  /// The startup-window twin of the runtime's create-time `.hide` gate:
281
301
  /// on windows, hide-on-close is honest only when the app declares a
282
302
  /// tray (status item) — SW_HIDE removes the taskbar entry and windows
@@ -477,6 +497,9 @@ pub const WindowsPlatform = struct {
477
497
  .show_open_dialog_fn = showOpenDialog,
478
498
  .show_save_dialog_fn = showSaveDialog,
479
499
  .show_message_dialog_fn = showMessageDialog,
500
+ .request_dialog_fn = if (self.web_engine == .system) requestDialog else null,
501
+ .cancel_dialog_fn = if (self.web_engine == .system) cancelDialog else null,
502
+ .poll_dialog_result_fn = if (self.web_engine == .system) pollDialogResult else null,
480
503
  .show_notification_fn = showNotification,
481
504
  .create_tray_fn = createTray,
482
505
  .update_tray_menu_fn = updateTrayMenu,
@@ -901,13 +924,14 @@ fn emitWindowEvent(context: ?*anyopaque, window_id: platform_mod.WindowId, name:
901
924
  native_sdk_windows_emit_window_event(self.host, window_id, name.ptr, name.len, detail_json.ptr, detail_json.len);
902
925
  }
903
926
 
904
- /// Thread-safe: `PostMessageW` into the existing message loop (the
905
- /// enqueue-only shape the wake contract requires — never the
927
+ /// Thread-safe: `PostMessageW` to the host's loop window (the
928
+ /// enqueue-only shape the wake contract requires — no lock, never the
906
929
  /// synchronous `SendMessage`), whose window procedure emits `.wake` on
907
- /// the loop thread.
930
+ /// the loop thread. A wake that could not be posted is an error, so the
931
+ /// caller re-arms instead of assuming the nudge happened.
908
932
  fn wake(context: ?*anyopaque) anyerror!void {
909
933
  const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
910
- native_sdk_windows_wake(self.host);
934
+ if (native_sdk_windows_wake(self.host) == 0) return error.WakeNotPosted;
911
935
  }
912
936
 
913
937
  /// Teardown abandoned an in-flight channel wake call: latch the flag
@@ -918,13 +942,14 @@ fn noteChannelWakeAbandoned(context: ?*anyopaque) void {
918
942
  self.channel_wake_abandoned.store(true, .seq_cst);
919
943
  }
920
944
 
921
- /// Thread-safe like `wake`: `PostMessage` into the message loop, whose
922
- /// window procedure emits one `.frame` event on the loop thread. The
923
- /// automation arrival watcher calls this when a command lands so
924
- /// consumption never depends on the host's own frame pump.
945
+ /// Thread-safe like `wake`: `PostMessage` to the loop window, whose
946
+ /// window procedure emits one `.frame` event on the loop thread;
947
+ /// requests coalesce while one is queued. The automation arrival
948
+ /// watcher calls this when a command lands so consumption never depends
949
+ /// on the host's own frame pump.
925
950
  fn requestFrame(context: ?*anyopaque) anyerror!void {
926
951
  const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
927
- native_sdk_windows_request_frame(self.host);
952
+ if (native_sdk_windows_request_frame(self.host) == 0) return error.FrameRequestNotPosted;
928
953
  }
929
954
 
930
955
  /// Headless image codec for session replay: the SAME WIC decode a live
@@ -1481,12 +1506,9 @@ fn closeWebView(context: ?*anyopaque, window_id: platform_mod.WindowId, label: [
1481
1506
  if (native_sdk_windows_close_webview(self.host, window_id, label.ptr, label.len) == 0) return error.WebViewNotFound;
1482
1507
  }
1483
1508
 
1484
- fn showOpenDialog(context: ?*anyopaque, options: platform_mod.OpenDialogOptions, buffer: []u8) anyerror!platform_mod.OpenDialogResult {
1485
- const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
1486
- if (self.web_engine != .system) return error.UnsupportedService;
1487
- var ext_buf: [1024]u8 = undefined;
1488
- const ext_str = flattenFilters(options.filters, &ext_buf);
1489
- const opts = WindowsOpenDialogOpts{
1509
+ fn windowsOpenDialogOpts(options: platform_mod.OpenDialogOptions, ext_buf: []u8) WindowsOpenDialogOpts {
1510
+ const ext_str = flattenFilters(options.filters, ext_buf);
1511
+ return .{
1490
1512
  .title = options.title.ptr,
1491
1513
  .title_len = options.title.len,
1492
1514
  .default_path = options.default_path.ptr,
@@ -1496,17 +1518,11 @@ fn showOpenDialog(context: ?*anyopaque, options: platform_mod.OpenDialogOptions,
1496
1518
  .allow_directories = if (options.allow_directories) 1 else 0,
1497
1519
  .allow_multiple = if (options.allow_multiple) 1 else 0,
1498
1520
  };
1499
- const result = native_sdk_windows_show_open_dialog(self.host, &opts, buffer.ptr, buffer.len);
1500
- if (result.bytes_written > buffer.len) return error.NoSpaceLeft;
1501
- return .{ .count = result.count, .paths = buffer[0..result.bytes_written] };
1502
1521
  }
1503
1522
 
1504
- fn showSaveDialog(context: ?*anyopaque, options: platform_mod.SaveDialogOptions, buffer: []u8) anyerror!?[]const u8 {
1505
- const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
1506
- if (self.web_engine != .system) return error.UnsupportedService;
1507
- var ext_buf: [1024]u8 = undefined;
1508
- const ext_str = flattenFilters(options.filters, &ext_buf);
1509
- const opts = WindowsSaveDialogOpts{
1523
+ fn windowsSaveDialogOpts(options: platform_mod.SaveDialogOptions, ext_buf: []u8) WindowsSaveDialogOpts {
1524
+ const ext_str = flattenFilters(options.filters, ext_buf);
1525
+ return .{
1510
1526
  .title = options.title.ptr,
1511
1527
  .title_len = options.title.len,
1512
1528
  .default_path = options.default_path.ptr,
@@ -1516,16 +1532,10 @@ fn showSaveDialog(context: ?*anyopaque, options: platform_mod.SaveDialogOptions,
1516
1532
  .extensions = ext_str.ptr,
1517
1533
  .extensions_len = ext_str.len,
1518
1534
  };
1519
- const written = native_sdk_windows_show_save_dialog(self.host, &opts, buffer.ptr, buffer.len);
1520
- if (written > buffer.len) return error.NoSpaceLeft;
1521
- if (written == 0) return null;
1522
- return buffer[0..written];
1523
1535
  }
1524
1536
 
1525
- fn showMessageDialog(context: ?*anyopaque, options: platform_mod.MessageDialogOptions) anyerror!platform_mod.MessageDialogResult {
1526
- const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
1527
- if (self.web_engine != .system) return error.UnsupportedService;
1528
- const opts = WindowsMessageDialogOpts{
1537
+ fn windowsMessageDialogOpts(options: platform_mod.MessageDialogOptions) WindowsMessageDialogOpts {
1538
+ return .{
1529
1539
  .style = @intFromEnum(options.style),
1530
1540
  .title = options.title.ptr,
1531
1541
  .title_len = options.title.len,
@@ -1540,7 +1550,97 @@ fn showMessageDialog(context: ?*anyopaque, options: platform_mod.MessageDialogOp
1540
1550
  .tertiary_button = options.tertiary_button.ptr,
1541
1551
  .tertiary_button_len = options.tertiary_button.len,
1542
1552
  };
1543
- return @enumFromInt(native_sdk_windows_show_message_dialog(self.host, &opts));
1553
+ }
1554
+
1555
+ fn showOpenDialog(context: ?*anyopaque, options: platform_mod.OpenDialogOptions, buffer: []u8) anyerror!platform_mod.OpenDialogResult {
1556
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
1557
+ if (self.web_engine != .system) return error.UnsupportedService;
1558
+ var ext_buf: [1024]u8 = undefined;
1559
+ const opts = windowsOpenDialogOpts(options, &ext_buf);
1560
+ const result = native_sdk_windows_show_open_dialog(self.host, &opts, buffer.ptr, buffer.len);
1561
+ if (result.bytes_written > buffer.len) return error.NoSpaceLeft;
1562
+ return .{ .count = result.count, .paths = buffer[0..result.bytes_written] };
1563
+ }
1564
+
1565
+ fn showSaveDialog(context: ?*anyopaque, options: platform_mod.SaveDialogOptions, buffer: []u8) anyerror!?[]const u8 {
1566
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
1567
+ if (self.web_engine != .system) return error.UnsupportedService;
1568
+ var ext_buf: [1024]u8 = undefined;
1569
+ const opts = windowsSaveDialogOpts(options, &ext_buf);
1570
+ const written = native_sdk_windows_show_save_dialog(self.host, &opts, buffer.ptr, buffer.len);
1571
+ if (written > buffer.len) return error.NoSpaceLeft;
1572
+ if (written == 0) return null;
1573
+ return buffer[0..written];
1574
+ }
1575
+
1576
+ fn showMessageDialog(context: ?*anyopaque, options: platform_mod.MessageDialogOptions) anyerror!platform_mod.MessageDialogResult {
1577
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
1578
+ if (self.web_engine != .system) return error.UnsupportedService;
1579
+ const opts = windowsMessageDialogOpts(options);
1580
+ // Negative: a session end left the dialog unanswered (the host
1581
+ // cancelled it to unwind this dispatch); no button stands in for it.
1582
+ const pressed = native_sdk_windows_show_message_dialog(self.host, &opts);
1583
+ if (pressed < 0) return error.SessionEnding;
1584
+ return @enumFromInt(pressed);
1585
+ }
1586
+
1587
+ // The host copies every option before returning, so they may live on this
1588
+ // stack; the result comes back through pollDialogResult on a later wake.
1589
+ fn requestDialog(context: ?*anyopaque, request: platform_mod.DialogRequest) anyerror!void {
1590
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
1591
+ var ext_buf: [1024]u8 = undefined;
1592
+ var open_opts: WindowsOpenDialogOpts = undefined;
1593
+ var save_opts: WindowsSaveDialogOpts = undefined;
1594
+ var message_opts: WindowsMessageDialogOpts = undefined;
1595
+ var native_request = WindowsDialogRequest{
1596
+ .id = request.id,
1597
+ .parent_window_id = request.parent_window_id orelse 0,
1598
+ .kind = @intFromEnum(std.meta.activeTag(request.options)),
1599
+ .open_opts = null,
1600
+ .save_opts = null,
1601
+ .message_opts = null,
1602
+ };
1603
+ switch (request.options) {
1604
+ .open => |options| {
1605
+ open_opts = windowsOpenDialogOpts(options, &ext_buf);
1606
+ native_request.open_opts = &open_opts;
1607
+ },
1608
+ .save => |options| {
1609
+ save_opts = windowsSaveDialogOpts(options, &ext_buf);
1610
+ native_request.save_opts = &save_opts;
1611
+ },
1612
+ .message => |options| {
1613
+ message_opts = windowsMessageDialogOpts(options);
1614
+ native_request.message_opts = &message_opts;
1615
+ },
1616
+ }
1617
+ if (native_sdk_windows_request_dialog(self.host, &native_request) == 0) return error.DialogUnavailable;
1618
+ }
1619
+
1620
+ fn cancelDialog(context: ?*anyopaque, id: platform_mod.DialogRequestId) void {
1621
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
1622
+ native_sdk_windows_cancel_dialog(self.host, id);
1623
+ }
1624
+
1625
+ fn pollDialogResult(context: ?*anyopaque, buffer: []u8) ?platform_mod.DialogResult {
1626
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
1627
+ var result: WindowsDialogResult = undefined;
1628
+ if (native_sdk_windows_poll_dialog_result(self.host, buffer.ptr, buffer.len, &result) == 0) return null;
1629
+ return .{
1630
+ .id = result.id,
1631
+ .outcome = switch (result.outcome) {
1632
+ 0 => .selected,
1633
+ 1 => .cancelled,
1634
+ else => .failed,
1635
+ },
1636
+ .paths = buffer[0..@min(result.bytes_written, buffer.len)],
1637
+ .count = result.count,
1638
+ .button = switch (result.button) {
1639
+ 1 => .secondary,
1640
+ 2 => .tertiary,
1641
+ else => .primary,
1642
+ },
1643
+ };
1544
1644
  }
1545
1645
 
1546
1646
  fn showNotification(context: ?*anyopaque, options: platform_mod.NotificationOptions) anyerror!void {
@@ -2274,7 +2374,9 @@ test "windows tray carries lifecycle commands rich rows and key equivalents into
2274
2374
  try std.testing.expect(std.mem.indexOf(u8, host_source, "display_label += shortcutSuffix(item.key, item.modifiers);") != null);
2275
2375
  try std.testing.expect(std.mem.indexOf(u8, host_source, "const std::vector<TrayItem> displayed_items = host->tray_items;") != null);
2276
2376
  try std.testing.expect(std.mem.indexOf(u8, host_source, "emitTrayActionForCommandId(host, displayed_items, command_id);") != null);
2277
- try std.testing.expect(std.mem.indexOf(u8, host_source, "return emitTrayActionForCommandId(host, host->tray_items, item.command_id);") != null);
2377
+ try std.testing.expect(std.mem.indexOf(u8, host_source, "out->kind = kTrayAction;") != null);
2378
+ try std.testing.expect(std.mem.indexOf(u8, host_source, "out->tray_item_id = item.id;") != null);
2379
+ try std.testing.expect(std.mem.indexOf(u8, host_source, "event.tray_item_id = match.tray_item_id;") != null);
2278
2380
  try std.testing.expect(std.mem.indexOf(u8, host_source, "tray_event == NIN_SELECT || tray_event == NIN_KEYSELECT || tray_event == WM_LBUTTONUP") != null);
2279
2381
  }
2280
2382
 
@@ -2336,6 +2438,87 @@ test "windows WM_CLOSE hide consults the live tray state and downgrades to a rea
2336
2438
  try std.testing.expect(std.mem.indexOf(u8, close_hook, "downgraded to a real close") != null);
2337
2439
  }
2338
2440
 
2441
+ test "windows session end runs the stop hook once and bypasses the close policy" {
2442
+ // C++ this suite cannot execute, pinned textually like the WM_CLOSE
2443
+ // hook above. A confirmed WM_ENDSESSION (logoff, system shutdown, a
2444
+ // Restart Manager close) never goes through the close-policy hide;
2445
+ // the query only says yes; the run loop's exit and the session end
2446
+ // share one exactly-once delivery.
2447
+ const host_source = @embedFile("webview2_host.cpp");
2448
+ const query_at = std.mem.indexOf(u8, host_source, "case WM_QUERYENDSESSION:") orelse return error.TestExpectedEqual;
2449
+ const end_at = std.mem.indexOfPos(u8, host_source, query_at, "case WM_ENDSESSION:") orelse return error.TestExpectedEqual;
2450
+ const close_at = std.mem.indexOfPos(u8, host_source, end_at, "case WM_CLOSE:") orelse return error.TestExpectedEqual;
2451
+ try std.testing.expect(std.mem.indexOf(u8, host_source[query_at..end_at], "return TRUE;") != null);
2452
+ try std.testing.expect(std.mem.indexOf(u8, host_source[end_at..close_at], "if (host && wparam) endSession(host);") != null);
2453
+ // While a session end unwinds a dispatch, closes are ignored before
2454
+ // the close policy is consulted.
2455
+ const ending_close_at = std.mem.indexOfPos(u8, host_source, close_at, "if (host && host->session_ending) return 0;") orelse return error.TestExpectedEqual;
2456
+ const hide_at = std.mem.indexOfPos(u8, host_source, close_at, "ShowWindow(hwnd, SW_HIDE);") orelse return error.TestExpectedEqual;
2457
+ try std.testing.expect(ending_close_at < hide_at);
2458
+
2459
+ // Outside a runtime dispatch the shutdown runs synchronously, then
2460
+ // the tray icon goes and the process ends. Inside one, the runtime
2461
+ // queues the shutdown behind it, so the host marks the session
2462
+ // ending, hands the shutdown over, stops the loop, and cancels the
2463
+ // dialogs holding the dispatch, without ending the process.
2464
+ const session_at = std.mem.indexOf(u8, host_source, "static void endSession(Host *host) {") orelse return error.TestExpectedEqual;
2465
+ const session_end = std.mem.indexOfPos(u8, host_source, session_at, "\n}\n") orelse return error.TestExpectedEqual;
2466
+ const session_fn = host_source[session_at..session_end];
2467
+ const order = [_][]const u8{
2468
+ "if (host->shutdown_delivered) return;",
2469
+ "if (host->runtime_dispatch_depth == 0) {",
2470
+ "deliverShutdown(host);",
2471
+ "removeNotificationIcon(host);",
2472
+ "TerminateProcess(GetCurrentProcess(), 0);",
2473
+ "host->session_ending = true;",
2474
+ "deliverShutdown(host);",
2475
+ "host->running = false;",
2476
+ "EnumThreadWindows(GetCurrentThreadId(), closeOwnedDialog,",
2477
+ };
2478
+ var cursor: usize = 0;
2479
+ for (order) |needle| cursor = (std.mem.indexOfPos(u8, session_fn, cursor, needle) orelse return error.TestExpectedEqual) + needle.len;
2480
+ try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, session_fn, "TerminateProcess("));
2481
+
2482
+ const deliver_fn_at = std.mem.indexOf(u8, host_source, "static void deliverShutdown(Host *host) {") orelse return error.TestExpectedEqual;
2483
+ const flag_at = std.mem.indexOfPos(u8, host_source, deliver_fn_at, "host->shutdown_delivered = true;") orelse return error.TestExpectedEqual;
2484
+ const callback_at = std.mem.indexOfPos(u8, host_source, deliver_fn_at, "host->callback(host->callback_context, &shutdown);") orelse return error.TestExpectedEqual;
2485
+ // The flag latches before the callback: a session end arriving while
2486
+ // the stop hook runs (a modal loop inside it) must not deliver again.
2487
+ try std.testing.expect(flag_at < callback_at);
2488
+ try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, host_source, "kind = kShutdown;"));
2489
+
2490
+ // The dispatch depth sees every call into the runtime: events through
2491
+ // the counting trampoline the run loop installs, Bridge messages
2492
+ // through their one delivery site.
2493
+ const run_at = std.mem.indexOf(u8, host_source, "void native_sdk_windows_run(") orelse return error.TestExpectedEqual;
2494
+ const run_end = std.mem.indexOfPos(u8, host_source, run_at, "\n}\n") orelse return error.TestExpectedEqual;
2495
+ const run_fn = host_source[run_at..run_end];
2496
+ try std.testing.expect(std.mem.indexOf(u8, run_fn, "host->callback = dispatchEventToRuntime;") != null);
2497
+ try std.testing.expect(std.mem.indexOf(u8, run_fn, "deliverShutdown(host);") != null);
2498
+ const trampoline_at = std.mem.indexOf(u8, host_source, "static void dispatchEventToRuntime(") orelse return error.TestExpectedEqual;
2499
+ const scope_at = std.mem.indexOfPos(u8, host_source, trampoline_at, "RuntimeDispatchScope scope(host);") orelse return error.TestExpectedEqual;
2500
+ const forward_at = std.mem.indexOfPos(u8, host_source, trampoline_at, "host->runtime_callback(host->runtime_context, event);") orelse return error.TestExpectedEqual;
2501
+ try std.testing.expect(scope_at < forward_at);
2502
+ // Nothing enters the runtime after the shutdown was handed over, on
2503
+ // every path: the stop hook's own events and a closing dialog's.
2504
+ const gate_at = std.mem.indexOfPos(u8, host_source, trampoline_at, "if (host->shutdown_delivered && event->kind != kShutdown) return;") orelse return error.TestExpectedEqual;
2505
+ try std.testing.expect(gate_at < forward_at);
2506
+ try std.testing.expect(std.mem.indexOf(u8, host_source, "if (!host->bridge_callback || host->shutdown_delivered) return;") != null);
2507
+ try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, host_source, "host->bridge_callback("));
2508
+ const bridge_at = std.mem.indexOf(u8, host_source, "host->bridge_callback(") orelse return error.TestExpectedEqual;
2509
+ const bridge_scope_at = std.mem.lastIndexOf(u8, host_source[0..bridge_at], "RuntimeDispatchScope scope(host);") orelse return error.TestExpectedEqual;
2510
+ try std.testing.expect(bridge_at - bridge_scope_at < 128);
2511
+
2512
+ // Dialogs the unwinding cancels, through any chain of owners, report
2513
+ // no answer rather than a button, and new ones are not shown;
2514
+ // session_end_test.cpp drives this on a Windows host.
2515
+ try std.testing.expect(std.mem.indexOf(u8, host_source, "for (HWND owner = GetWindow(hwnd, GW_OWNER); owner; owner = GetWindow(owner, GW_OWNER)) {") != null);
2516
+ const message_at = std.mem.indexOf(u8, host_source, "int native_sdk_windows_show_message_dialog(Host *host, const WindowsMessageDialogOpts *opts) {") orelse return error.TestExpectedEqual;
2517
+ const message_end = std.mem.indexOfPos(u8, host_source, message_at, "\n}\n") orelse return error.TestExpectedEqual;
2518
+ try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, host_source[message_at..message_end], "if (host->session_ending) return -1;"));
2519
+ try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, host_source, "|| buffer_len == 0 || host->session_ending) return"));
2520
+ }
2521
+
2339
2522
  test "windows passive show never foregrounds or focuses the window" {
2340
2523
  const host_source = @embedFile("webview2_host.cpp");
2341
2524
  const show_at = std.mem.indexOf(u8, host_source, "int native_sdk_windows_show_window(") orelse return error.TestExpectedEqual;
@@ -2720,11 +2903,14 @@ test "windows window focus includes focused native child views" {
2720
2903
  test "windows webview focus reports the focused child label" {
2721
2904
  // WebView2 owns the page HWND and its input; GotFocus is the
2722
2905
  // controller-level edge that mirrors keyboard ownership back into
2723
- // the runtime's per-view register.
2906
+ // the runtime's per-view register. The callback queues the focused
2907
+ // view, and the loop window reports it once the callback returns.
2724
2908
  const host_source = @embedFile("webview2_host.cpp");
2725
2909
  try std.testing.expect(std.mem.indexOf(u8, host_source, "add_GotFocus(Callback<ICoreWebView2FocusChangedEventHandler>") != null);
2726
- try std.testing.expect(std.mem.indexOf(u8, host_source, "event.kind = kViewFocused;") != null);
2727
- try std.testing.expect(std.mem.indexOf(u8, host_source, "event.view_label = focused->second.label.c_str();") != null);
2910
+ try std.testing.expect(std.mem.indexOf(u8, host_source, "event.kind = DeferredWebViewEvent::Kind::Focus;") != null);
2911
+ try std.testing.expect(std.mem.indexOf(u8, host_source, "event.label = focused->second.label;") != null);
2912
+ try std.testing.expect(std.mem.indexOf(u8, host_source, "focused.kind = kViewFocused;") != null);
2913
+ try std.testing.expect(std.mem.indexOf(u8, host_source, "focused.view_label = event.label.c_str();") != null);
2728
2914
  }
2729
2915
 
2730
2916
  test "windows webview2 keeps its user data with the app's per-user data" {
@@ -0,0 +1,303 @@
1
+ /* Session end against the production Windows host, across processes:
2
+ * the test runs itself again as the app (--child <scenario>) and plays
3
+ * the sender, the way logoff and Restart Manager reach a GUI process:
4
+ * WM_QUERYENDSESSION, then WM_ENDSESSION, sent to its top-level window.
5
+ * The app's only window is hidden, like one behind a tray icon.
6
+ *
7
+ * flat No app dispatch is running. A cancelled end (wParam FALSE)
8
+ * changes nothing; a confirmed one delivers one shutdown and
9
+ * ends the process inside the message.
10
+ * nested The app is inside a dispatch, in a modal dialog. The dialog
11
+ * is cancelled and reports no answer instead of a button,
12
+ * later dialogs are refused, no event follows the shutdown, a
13
+ * follow-up WM_CLOSE (Restart Manager sends one) is ignored,
14
+ * and the loop returns into the regular teardown.
15
+ * quit No session end: the app quits on its own, through the loop's
16
+ * exit, which shares the exactly-once shutdown.
17
+ *
18
+ * In every scenario the stop hook raises an event of its own while it
19
+ * runs, and no event may reach the app after its shutdown. The app
20
+ * prints one line per observation and the sender checks them. A
21
+ * watchdog bounds every wait. */
22
+ #include "webview2_host.cpp"
23
+
24
+ #include <cstdarg>
25
+ #include <cstring>
26
+
27
+ namespace {
28
+
29
+ void say(const char *format, ...) {
30
+ va_list args;
31
+ va_start(args, format);
32
+ vprintf(format, args);
33
+ va_end(args);
34
+ putchar('\n');
35
+ fflush(stdout);
36
+ }
37
+
38
+ /* ------------------------------------------------------------ the app */
39
+
40
+ Host *app = nullptr;
41
+ bool nested = false;
42
+ bool quitting = false;
43
+ bool shut_down = false;
44
+
45
+ void appEvent(void *, const WindowsEvent *incoming) {
46
+ if (shut_down) {
47
+ say("FAIL event %d reached the app after its shutdown", static_cast<int>(incoming->kind));
48
+ return;
49
+ }
50
+ switch (incoming->kind) {
51
+ case kStart: {
52
+ auto window = app->windows.find(1);
53
+ say("ready %llu", static_cast<unsigned long long>(reinterpret_cast<uintptr_t>(window->second.hwnd)));
54
+ if (quitting) native_sdk_windows_stop(app);
55
+ if (!nested) break;
56
+ /* A confirmation whose primary button is the destructive
57
+ * one: cancelling it must not read as that button. */
58
+ WindowsMessageDialogOpts confirm = {};
59
+ confirm.title = "Session End Test";
60
+ confirm.title_len = strlen(confirm.title);
61
+ confirm.message = "Delete everything?";
62
+ confirm.message_len = strlen(confirm.message);
63
+ confirm.primary_button = "Delete";
64
+ confirm.primary_button_len = strlen(confirm.primary_button);
65
+ confirm.secondary_button = "Keep";
66
+ confirm.secondary_button_len = strlen(confirm.secondary_button);
67
+ say("dialog %d", native_sdk_windows_show_message_dialog(app, &confirm));
68
+ say("second dialog %d", native_sdk_windows_show_message_dialog(app, &confirm));
69
+ char path[MAX_PATH] = {};
70
+ WindowsOpenDialogOpts open = {};
71
+ say("open dialog %zu", native_sdk_windows_show_open_dialog(app, &open, path, sizeof(path)).count);
72
+ break;
73
+ }
74
+ case kShutdown:
75
+ shut_down = true;
76
+ say("shutdown");
77
+ /* Work in the stop hook can raise an event (here a wake the
78
+ * loop window takes synchronously); it must not reach the
79
+ * app, whose shutdown already ran. */
80
+ SendMessageW(app->loop_window.load(), kWakeMessage, 0, 0);
81
+ break;
82
+ default:
83
+ break;
84
+ }
85
+ }
86
+
87
+ int runApp(const char *scenario) {
88
+ nested = strcmp(scenario, "nested") == 0;
89
+ quitting = strcmp(scenario, "quit") == 0;
90
+ app = native_sdk_windows_create(
91
+ "session-end-test", 16, "Session End Test", 16,
92
+ "dev.native-sdk.session-end-test", sizeof("dev.native-sdk.session-end-test") - 1,
93
+ nullptr, 0, "main", 4, 0, 0, 320, 240,
94
+ 0, 0, 0, 1, 0, 0, 0, 2, 0);
95
+ if (!app) {
96
+ say("FAIL create host");
97
+ return 1;
98
+ }
99
+ native_sdk_windows_run(app, appEvent, nullptr);
100
+ say("run returned");
101
+ native_sdk_windows_destroy(app);
102
+ return 0;
103
+ }
104
+
105
+ /* --------------------------------------------------------- the sender */
106
+
107
+ const char *stage = "starting";
108
+ HANDLE child_process = nullptr;
109
+
110
+ void require(bool condition, const char *message) {
111
+ if (condition) return;
112
+ fprintf(stderr, "FAIL (%s): %s\n", stage, message);
113
+ if (child_process) TerminateProcess(child_process, 1);
114
+ ExitProcess(1);
115
+ }
116
+
117
+ struct Child {
118
+ PROCESS_INFORMATION process = {};
119
+ HANDLE output = nullptr;
120
+ std::string lines;
121
+ };
122
+
123
+ Child spawn(const char *scenario) {
124
+ SECURITY_ATTRIBUTES inherit = { sizeof(inherit), nullptr, TRUE };
125
+ HANDLE read_end = nullptr;
126
+ HANDLE write_end = nullptr;
127
+ require(CreatePipe(&read_end, &write_end, &inherit, 0) != 0, "output pipe");
128
+ SetHandleInformation(read_end, HANDLE_FLAG_INHERIT, 0);
129
+ wchar_t exe[MAX_PATH] = {};
130
+ GetModuleFileNameW(nullptr, exe, MAX_PATH);
131
+ std::wstring command = L"\"" + std::wstring(exe) + L"\" --child " + widen(scenario);
132
+ STARTUPINFOW startup = {};
133
+ startup.cb = sizeof(startup);
134
+ startup.dwFlags = STARTF_USESTDHANDLES;
135
+ startup.hStdOutput = write_end;
136
+ startup.hStdError = write_end;
137
+ Child child;
138
+ require(CreateProcessW(exe, command.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &startup, &child.process) != 0, "start the app");
139
+ CloseHandle(write_end);
140
+ child.output = read_end;
141
+ child_process = child.process.hProcess;
142
+ return child;
143
+ }
144
+
145
+ /* The app's stdout is in text mode, so lines arrive ending in CRLF. */
146
+ void appendOutput(Child &child, const char *bytes, DWORD count) {
147
+ for (DWORD index = 0; index < count; ++index) {
148
+ if (bytes[index] != '\r') child.lines.push_back(bytes[index]);
149
+ }
150
+ }
151
+
152
+ /* Reads until a line starting with `prefix` arrives; returns its rest. */
153
+ std::string waitForLine(Child &child, const char *prefix) {
154
+ const std::string wanted = std::string("\n") + prefix;
155
+ for (;;) {
156
+ const std::string text = "\n" + child.lines;
157
+ const size_t at = text.find(wanted);
158
+ if (at != std::string::npos) {
159
+ const size_t end = text.find('\n', at + 1);
160
+ if (end != std::string::npos) return text.substr(at + wanted.size(), end - at - wanted.size());
161
+ }
162
+ char buffer[512];
163
+ DWORD read = 0;
164
+ require(ReadFile(child.output, buffer, sizeof(buffer), &read, nullptr) && read > 0, "the app's output ended early");
165
+ appendOutput(child, buffer, read);
166
+ }
167
+ }
168
+
169
+ /* Waits for the app to exit and collects the rest of its output. */
170
+ DWORD finish(Child &child, DWORD timeout_ms) {
171
+ require(WaitForSingleObject(child.process.hProcess, timeout_ms) == WAIT_OBJECT_0, "the app exited");
172
+ char buffer[512];
173
+ DWORD read = 0;
174
+ while (ReadFile(child.output, buffer, sizeof(buffer), &read, nullptr) && read > 0) appendOutput(child, buffer, read);
175
+ DWORD code = 1;
176
+ GetExitCodeProcess(child.process.hProcess, &code);
177
+ CloseHandle(child.output);
178
+ CloseHandle(child.process.hThread);
179
+ CloseHandle(child.process.hProcess);
180
+ child_process = nullptr;
181
+ return code;
182
+ }
183
+
184
+ size_t countLines(const Child &child, const char *line) {
185
+ const std::string text = "\n" + child.lines;
186
+ const std::string wanted = std::string("\n") + line + "\n";
187
+ size_t count = 0;
188
+ for (size_t at = text.find(wanted); at != std::string::npos; at = text.find(wanted, at + 1)) count += 1;
189
+ return count;
190
+ }
191
+
192
+ void requireLine(const Child &child, const char *line) {
193
+ if (countLines(child, line) == 1) return;
194
+ fprintf(stderr, "app output:\n%s\n", child.lines.c_str());
195
+ require(false, line);
196
+ }
197
+
198
+ void requireClean(const Child &child) {
199
+ if (child.lines.find("FAIL") == std::string::npos) return;
200
+ fprintf(stderr, "app output:\n%s\n", child.lines.c_str());
201
+ require(false, "the app reported a failure");
202
+ }
203
+
204
+ LRESULT sendToApp(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
205
+ DWORD_PTR answer = 0;
206
+ SendMessageTimeoutW(hwnd, message, wparam, lparam, SMTO_ABORTIFHUNG, 10000, &answer);
207
+ return static_cast<LRESULT>(answer);
208
+ }
209
+
210
+ HWND appWindow(Child &child) {
211
+ const std::string handle = waitForLine(child, "ready ");
212
+ HWND hwnd = reinterpret_cast<HWND>(static_cast<uintptr_t>(std::stoull(handle)));
213
+ require(IsWindow(hwnd) && !IsWindowVisible(hwnd), "the app's window exists, hidden");
214
+ return hwnd;
215
+ }
216
+
217
+ void runFlat() {
218
+ stage = "flat";
219
+ Child child = spawn("flat");
220
+ HWND hwnd = appWindow(child);
221
+ require(sendToApp(hwnd, WM_QUERYENDSESSION, 0, ENDSESSION_LOGOFF) == TRUE, "the query is answered yes");
222
+ sendToApp(hwnd, WM_ENDSESSION, FALSE, ENDSESSION_LOGOFF);
223
+ require(WaitForSingleObject(child.process.hProcess, 300) == WAIT_TIMEOUT, "a cancelled session end leaves the app running");
224
+ require(sendToApp(hwnd, WM_QUERYENDSESSION, 0, ENDSESSION_LOGOFF) == TRUE, "the query is answered yes");
225
+ sendToApp(hwnd, WM_ENDSESSION, TRUE, ENDSESSION_LOGOFF);
226
+ require(finish(child, 2000) == 0, "the app ended with exit code 0");
227
+ requireClean(child);
228
+ requireLine(child, "shutdown");
229
+ require(countLines(child, "run returned") == 0, "the process ended inside WM_ENDSESSION, not through the loop");
230
+ }
231
+
232
+ struct DialogSearch {
233
+ DWORD process_id;
234
+ HWND found;
235
+ };
236
+
237
+ BOOL CALLBACK findDialog(HWND hwnd, LPARAM lparam) {
238
+ auto *search = reinterpret_cast<DialogSearch *>(lparam);
239
+ DWORD process_id = 0;
240
+ GetWindowThreadProcessId(hwnd, &process_id);
241
+ wchar_t class_name[16] = {};
242
+ GetClassNameW(hwnd, class_name, 16);
243
+ if (process_id != search->process_id || !IsWindowVisible(hwnd) || wcscmp(class_name, L"#32770") != 0) return TRUE;
244
+ search->found = hwnd;
245
+ return FALSE;
246
+ }
247
+
248
+ void runNested() {
249
+ stage = "nested";
250
+ Child child = spawn("nested");
251
+ HWND hwnd = appWindow(child);
252
+ DialogSearch search = { child.process.dwProcessId, nullptr };
253
+ for (int attempt = 0; attempt < 200 && !search.found; ++attempt) {
254
+ Sleep(50);
255
+ EnumWindows(findDialog, reinterpret_cast<LPARAM>(&search));
256
+ }
257
+ require(search.found != nullptr, "the app's dialog is open");
258
+ require(sendToApp(hwnd, WM_QUERYENDSESSION, 0, ENDSESSION_CLOSEAPP) == TRUE, "the query is answered yes");
259
+ sendToApp(hwnd, WM_ENDSESSION, TRUE, ENDSESSION_CLOSEAPP);
260
+ sendToApp(hwnd, WM_CLOSE, 0, 0);
261
+ require(finish(child, 10000) == 0, "the app exited with exit code 0");
262
+ requireClean(child);
263
+ requireLine(child, "shutdown");
264
+ requireLine(child, "dialog -1");
265
+ requireLine(child, "second dialog -1");
266
+ requireLine(child, "open dialog 0");
267
+ requireLine(child, "run returned");
268
+ }
269
+
270
+ void runQuit() {
271
+ stage = "quit";
272
+ Child child = spawn("quit");
273
+ appWindow(child);
274
+ require(finish(child, 10000) == 0, "the app exited with exit code 0");
275
+ requireClean(child);
276
+ requireLine(child, "shutdown");
277
+ requireLine(child, "run returned");
278
+ }
279
+
280
+ } // namespace
281
+
282
+ int main(int argc, char **argv) {
283
+ if (argc == 3 && strcmp(argv[1], "--child") == 0) {
284
+ setvbuf(stdout, nullptr, _IONBF, 0);
285
+ return runApp(argv[2]);
286
+ }
287
+ HANDLE finished = CreateEventW(nullptr, TRUE, FALSE, nullptr);
288
+ require(finished != nullptr, "watchdog event");
289
+ std::thread watchdog([finished] {
290
+ if (WaitForSingleObject(finished, 60000) == WAIT_OBJECT_0) return;
291
+ fprintf(stderr, "FAIL: stalled in %s\n", stage);
292
+ if (child_process) TerminateProcess(child_process, 1);
293
+ TerminateProcess(GetCurrentProcess(), 1);
294
+ });
295
+ runFlat();
296
+ runNested();
297
+ runQuit();
298
+ SetEvent(finished);
299
+ watchdog.join();
300
+ CloseHandle(finished);
301
+ puts("PASS: Windows session end");
302
+ return 0;
303
+ }