@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
@@ -25,6 +25,7 @@
25
25
  #include <cctype>
26
26
  #include <climits>
27
27
  #include <cmath>
28
+ #include <deque>
28
29
  #include <map>
29
30
  #include <memory>
30
31
  #include <mutex>
@@ -186,21 +187,21 @@ constexpr uint32_t kMenuCommandBase = 0x4000;
186
187
  constexpr uint32_t kTrayCommandBase = 0x5000;
187
188
  constexpr UINT kNotificationCallbackMessage = WM_APP + 42;
188
189
  /* Posted from any thread (effect worker threads) via
189
- * native_sdk_windows_wake; the window procedure emits kWake on the
190
- * message loop thread. */
190
+ * native_sdk_windows_wake; the loop window emits kWake on the message
191
+ * loop thread. */
191
192
  constexpr UINT kWakeMessage = WM_APP + 43;
192
- /* Posted from any thread via native_sdk_windows_request_frame; the
193
- * window procedure emits ONE kFrame on the message loop thread. The
194
- * automation arrival watcher uses it so a queued command wakes an idle
195
- * frame loop without depending on the 16 ms frame pump. */
193
+ /* Posted from any thread via native_sdk_windows_request_frame; the loop
194
+ * window emits ONE kFrame on the message loop thread. The automation
195
+ * arrival watcher uses it so a queued command wakes an idle frame loop
196
+ * without depending on the 16 ms frame pump. */
196
197
  constexpr UINT kRequestFrameMessage = WM_APP + 44;
197
198
  /* Posted from Media Foundation worker threads (the audio backend's event
198
- * pump and source resolver); the window procedure hands the distilled
199
- * note to audioHandleSessionMessage on the message loop thread. */
199
+ * pump and source resolver); the loop window hands the distilled note to
200
+ * audioHandleSessionMessage on the message loop thread. */
200
201
  constexpr UINT kAudioSessionMessage = WM_APP + 45;
201
- /* Posted from the spectrum capture thread every ~40 ms; the window
202
- * procedure snapshots the analysis bands and emits one SPECTRUM report
203
- * on the loop thread. A posted message rather than a loop-thread timer
202
+ /* Posted from the spectrum capture thread every ~40 ms; the loop window
203
+ * snapshots the analysis bands and emits one SPECTRUM report on the loop
204
+ * thread. A posted message rather than a loop-thread timer
204
205
  * on purpose: WM_TIMER is the lowest-priority message and a busy frame
205
206
  * loop starves it far below the contract cadence, while posted messages
206
207
  * keep their place in the queue. */
@@ -210,6 +211,10 @@ constexpr UINT kAudioSpectrumMessage = WM_APP + 46;
210
211
  * menu on a fresh loop turn — the same deferral the macOS host's
211
212
  * dispatch_async performs before popUpMenuPositioningItem. */
212
213
  constexpr UINT kShowContextMenuMessage = WM_APP + 47;
214
+ /* Posted by queueWebViewEvent when a WebView2 callback queued something
215
+ * for the app; the loop window delivers the queue on a fresh loop turn,
216
+ * after the WebView2 callback has returned. */
217
+ constexpr UINT kDeliverWebViewEventsMessage = WM_APP + 48;
213
218
  constexpr const char *kAssetVirtualOrigin = "https://native-sdk-app.localhost";
214
219
 
215
220
  constexpr int kViewWebView = 0;
@@ -370,6 +375,27 @@ struct WindowsMessageDialogOpts {
370
375
  size_t tertiary_button_len;
371
376
  };
372
377
 
378
+ /* An asynchronous dialog request: kind is kDialogOpen, kDialogSave or
379
+ * kDialogMessage, with the matching options set. */
380
+ struct WindowsDialogRequest {
381
+ uint64_t id;
382
+ uint64_t parent_window_id;
383
+ int kind;
384
+ const WindowsOpenDialogOpts *open_opts;
385
+ const WindowsSaveDialogOpts *save_opts;
386
+ const WindowsMessageDialogOpts *message_opts;
387
+ };
388
+
389
+ /* A finished asynchronous dialog: outcome is kDialogSelected,
390
+ * kDialogCancelled or kDialogFailed; paths are newline-joined. */
391
+ struct WindowsDialogResult {
392
+ uint64_t id;
393
+ int outcome;
394
+ int button;
395
+ size_t count;
396
+ size_t bytes_written;
397
+ };
398
+
373
399
  using EventCallback = void (*)(void *, const WindowsEvent *);
374
400
  using BridgeCallback = void (*)(void *, uint64_t, const char *, size_t, const char *, size_t, const char *, size_t);
375
401
 
@@ -608,14 +634,70 @@ struct TrayItem {
608
634
  bool enabled = true;
609
635
  };
610
636
 
637
+ /* Whether the host still exists, for WebView2 completions and events
638
+ * that arrive after native_sdk_windows_destroy (they hold a weak
639
+ * reference to this). WebView2 raises every callback on the thread that
640
+ * created it, the loop thread, so a plain flag is enough: nothing off
641
+ * the loop thread reads it. */
611
642
  struct HostLifetime {
612
- std::recursive_mutex mutex;
613
643
  bool alive = true;
614
644
  };
615
645
 
646
+ constexpr int kDialogOpen = 0;
647
+ constexpr int kDialogSave = 1;
648
+ constexpr int kDialogMessage = 2;
649
+ constexpr int kDialogSelected = 0;
650
+ constexpr int kDialogCancelled = 1;
651
+ constexpr int kDialogFailed = 2;
652
+
653
+ /* One dialog: its options, copied so they outlive the call that passed
654
+ * them, and for an asynchronous one (native_sdk_windows_request_dialog)
655
+ * the state its thread shares with the loop and the result it hands
656
+ * back. */
657
+ struct DialogRequest {
658
+ uint64_t id = 0;
659
+ uint64_t parent_window_id = 0;
660
+ int kind = kDialogOpen;
661
+ HWND owner = nullptr;
662
+ std::string title;
663
+ std::string default_path;
664
+ std::string default_name;
665
+ std::string extensions;
666
+ bool allow_directories = false;
667
+ bool allow_multiple = false;
668
+ int style = 0;
669
+ std::string message;
670
+ std::string informative_text;
671
+ std::string primary_button;
672
+ std::string secondary_button;
673
+ std::string tertiary_button;
674
+ /* Under DialogQueue::mutex: the dialog's thread while it runs (zero
675
+ * before and after), and whether the app asked to close it. */
676
+ DWORD thread_id = 0;
677
+ bool cancel_requested = false;
678
+ /* Written by the dialog's thread before it queues the request. */
679
+ int outcome = kDialogCancelled;
680
+ int button = 0;
681
+ std::vector<std::string> paths;
682
+ };
683
+
684
+ /* Where dialog threads report. Each asynchronous dialog runs modally on
685
+ * a thread of its own, owned by its app window, as Chromium's shell
686
+ * dialogs do: Windows disables the owner while it shows, and the loop
687
+ * thread keeps pumping timers, wakes and WebView2. The threads hold this
688
+ * and their request, never the Host, which may be destroyed first. */
689
+ struct DialogQueue {
690
+ std::mutex mutex;
691
+ /* Finished requests, oldest first, until poll takes them. */
692
+ std::deque<std::shared_ptr<DialogRequest>> finished;
693
+ /* The wake target, cleared when the host retires its loop window. */
694
+ HWND loop_window = nullptr;
695
+ };
696
+
616
697
  /* App timers (runtime `startTimer`) on the Win32 message loop: each slot
617
698
  * owns the SetTimer id kAppTimerIdBase + slot index, scheduled on the
618
- * first live top-level window so WM_TIMER lands in windowProc. */
699
+ * host's loop window so WM_TIMER lands in loopWindowProc and closing an
700
+ * app window never stops one. */
619
701
  constexpr size_t kMaxAppTimers = 64;
620
702
  constexpr UINT_PTR kAppTimerIdBase = 0x1000;
621
703
  /* The 16 ms per-window frame-pump timer (SetTimer id on each top-level
@@ -683,11 +765,21 @@ struct AudioCaptureState {
683
765
  std::thread worker;
684
766
  };
685
767
 
768
+ /* One URL load's hand-over slot. The Media Foundation resolver (worker
769
+ * thread) leaves the resolved source here and posts a note; the loop
770
+ * thread collects it, or closes the slot when the load is replaced or
771
+ * stopped and releases whatever arrives late. The mutex covers only the
772
+ * hand-over itself, never a call out of the host. */
773
+ struct AudioSourceMailbox {
774
+ std::mutex mutex;
775
+ bool open = true;
776
+ IMFMediaSource *source = nullptr;
777
+ };
778
+
686
779
  /* The app's single audio player (see the audio section further down for
687
780
  * the backend rationale). All fields are message-loop-thread state; the
688
- * lifetime mutex additionally guards `generation` and `source` because
689
- * the asynchronous URL source resolver hands its result over from a
690
- * Media Foundation worker thread. */
781
+ * asynchronous URL source resolver hands its result over through
782
+ * `resolve_mailbox` instead of writing here. */
691
783
  struct AudioState {
692
784
  /* Bumped on every load/stop: worker-thread stragglers (resolver
693
785
  * completions, retired-session events) carry the generation they
@@ -715,6 +807,7 @@ struct AudioState {
715
807
  IMFMediaSource *source = nullptr;
716
808
  IMFPresentationClock *clock = nullptr;
717
809
  std::shared_ptr<AudioDownloadCancel> download_cancel;
810
+ std::shared_ptr<AudioSourceMailbox> resolve_mailbox;
718
811
  };
719
812
 
720
813
  /* The one pending app context menu (menus are modal, so one at a time):
@@ -738,6 +831,38 @@ struct PendingContextMenu {
738
831
  std::vector<Item> items;
739
832
  };
740
833
 
834
+ /* A keydown resolved against the app's key bindings, owned so the event
835
+ * can be emitted after the callback that saw the key has returned. The
836
+ * match itself happens while the key is current: the modifier state is
837
+ * read then, and a later turn could see different keys held. */
838
+ struct ShortcutMatch {
839
+ int kind = 0; // kShortcut, kMenuCommand, or kTrayAction
840
+ uint64_t window_id = 0;
841
+ std::string shortcut_id;
842
+ std::string key;
843
+ uint32_t modifiers = 0;
844
+ std::string command;
845
+ uint32_t tray_item_id = 0;
846
+ };
847
+
848
+ /* Something a WebView2 callback learned that the app must see, copied out
849
+ * of the callback's arguments. WebView2 does not support running a nested
850
+ * message loop or modal UI inside its callbacks, and its other callbacks
851
+ * cannot run until the current one returns, so app code never runs there:
852
+ * the callback queues one of these and the loop window delivers it on a
853
+ * later turn (deliverWebViewEvents). */
854
+ struct DeferredWebViewEvent {
855
+ enum class Kind { Bridge, Focus, Shortcut, OpenExternal };
856
+ Kind kind = Kind::Bridge;
857
+ uint64_t window_id = 0;
858
+ std::string view_key;
859
+ std::string label;
860
+ std::string message;
861
+ std::string origin;
862
+ ShortcutMatch shortcut;
863
+ std::wstring url;
864
+ };
865
+
741
866
  struct Host {
742
867
  bool window_drag_initialization_failed = false;
743
868
  uint64_t next_window_drag_generation = 1;
@@ -751,11 +876,27 @@ struct Host {
751
876
  * means WebView2's own default. */
752
877
  bool webview_user_data_folder_resolved = false;
753
878
  std::wstring webview_user_data_folder;
879
+ /* While the loop runs, callback is dispatchEventToRuntime with this
880
+ * host as its context; it forwards to runtime_callback. */
754
881
  EventCallback callback = nullptr;
755
882
  void *callback_context = nullptr;
883
+ EventCallback runtime_callback = nullptr;
884
+ void *runtime_context = nullptr;
756
885
  BridgeCallback bridge_callback = nullptr;
757
886
  void *bridge_context = nullptr;
758
887
  bool running = false;
888
+ /* Runtime dispatches on the stack (RuntimeDispatchScope). Above
889
+ * zero, the runtime queues a new event until the dispatch in
890
+ * progress commits, so nothing it is handed runs synchronously. */
891
+ int runtime_dispatch_depth = 0;
892
+ /* kShutdown has been delivered (or is being delivered). The run
893
+ * loop's exit and a session end can both reach it, and the app's
894
+ * stop hook runs exactly once (deliverShutdown). */
895
+ bool shutdown_delivered = false;
896
+ /* A session end arrived inside a runtime dispatch: its shutdown is
897
+ * queued behind that dispatch, and the loop exits once it unwinds
898
+ * (endSession). */
899
+ bool session_ending = false;
759
900
  std::map<uint64_t, Window> windows;
760
901
  std::map<std::string, ChildWebView> webviews;
761
902
  std::shared_ptr<WindowsGpuRenderer> gpu_renderer;
@@ -796,9 +937,34 @@ struct Host {
796
937
  AudioCaptureState captures[2];
797
938
  PendingContextMenu context_menu;
798
939
  std::shared_ptr<HostLifetime> lifetime = std::make_shared<HostLifetime>();
940
+ /* The message loop's own target: a message-only window created with
941
+ * the host on the loop thread and destroyed with it, so nothing
942
+ * posted to the loop depends on which app windows are open. Read
943
+ * from any thread (wake, frame requests), hence atomic; destroy
944
+ * clears it before tearing the window down. */
945
+ std::atomic<HWND> loop_window{nullptr};
946
+ /* A posted kRequestFrameMessage is still queued: requests coalesce
947
+ * until the loop window takes it. */
948
+ std::atomic<bool> frame_request_pending{false};
949
+ /* WebView2 events waiting for the app, in arrival order. One delivery
950
+ * runs at a time: an event queued while the app is inside an earlier
951
+ * one (a Bridge command showing a modal dialog) waits for it, the way
952
+ * WebView2 itself holds later callbacks while one is running. */
953
+ std::deque<DeferredWebViewEvent> webview_events;
954
+ bool webview_events_posted = false;
955
+ bool delivering_webview_events = false;
956
+ /* WebView2 callbacks currently on the stack; app callbacks run only
957
+ * at zero (see DeferredWebViewEvent). */
958
+ int webview_callback_depth = 0;
959
+ /* Asynchronous dialogs not yet polled, by id (loop thread only), and
960
+ * the queue their threads report to. */
961
+ std::map<uint64_t, std::shared_ptr<DialogRequest>> dialogs;
962
+ std::shared_ptr<DialogQueue> dialog_queue = std::make_shared<DialogQueue>();
799
963
  };
800
964
 
801
965
  static std::string webViewKey(uint64_t window_id, const std::string &label);
966
+ static void cancelDialogsForWindow(Host *host, uint64_t window_id);
967
+ static void cancelAllDialogs(Host *host);
802
968
  static void gpuSurfaceRefreshFrameWakeTimer(Host *host);
803
969
  static void gpuSurfaceDestroyFrameWakeTimer(Host *host);
804
970
 
@@ -865,6 +1031,13 @@ static HWND parentWindow(Host *host) {
865
1031
  return nullptr;
866
1032
  }
867
1033
 
1034
+ /* Where loop-bound work goes: posted notes from worker threads and the
1035
+ * app-level timers. Never an app window — those close independently of
1036
+ * the loop, and their timers and queued posts die with them. */
1037
+ static HWND loopWindow(Host *host) {
1038
+ return host ? host->loop_window.load(std::memory_order_acquire) : nullptr;
1039
+ }
1040
+
868
1041
  static void copyWideField(wchar_t *dest, size_t dest_len, const std::wstring &value) {
869
1042
  if (!dest || dest_len == 0) return;
870
1043
  size_t count = std::min(value.size(), dest_len - 1);
@@ -1767,7 +1940,10 @@ static void showTrayMenu(Host *host, HWND hwnd) {
1767
1940
  PostMessageW(hwnd, WM_NULL, 0, 0);
1768
1941
  }
1769
1942
 
1770
- static bool emitShortcutForWindow(Host *host, const Window *window, WPARAM wparam) {
1943
+ /* Resolve a keydown against the app's shortcuts, then its menu and tray
1944
+ * key equivalents. True when a binding claims the key; `out` then holds
1945
+ * the event to emit. */
1946
+ static bool matchShortcutForWindow(Host *host, const Window *window, WPARAM wparam, ShortcutMatch *out) {
1771
1947
  if (!host || (host->shortcuts.empty() && host->menus.empty() && host->tray_items.empty())) return false;
1772
1948
  if (!window) return false;
1773
1949
  std::string key = shortcutKeyFromWParam(wparam);
@@ -1779,16 +1955,11 @@ static bool emitShortcutForWindow(Host *host, const Window *window, WPARAM wpara
1779
1955
  for (const Shortcut &shortcut : host->shortcuts) {
1780
1956
  if (shortcut.key != key) continue;
1781
1957
  if (!shortcutModifiersMatch(shortcut.modifiers, allow_implicit_shift)) continue;
1782
- if (!host->callback) return true;
1783
- WindowsEvent event = {};
1784
- event.kind = kShortcut;
1785
- event.window_id = window->id;
1786
- event.shortcut_id = shortcut.id.c_str();
1787
- event.shortcut_id_len = shortcut.id.size();
1788
- event.shortcut_key = shortcut.key.c_str();
1789
- event.shortcut_key_len = shortcut.key.size();
1790
- event.shortcut_modifiers = shortcut.modifiers;
1791
- host->callback(host->callback_context, &event);
1958
+ out->kind = kShortcut;
1959
+ out->window_id = window->id;
1960
+ out->shortcut_id = shortcut.id;
1961
+ out->key = shortcut.key;
1962
+ out->modifiers = shortcut.modifiers;
1792
1963
  return true;
1793
1964
  }
1794
1965
  /* Menu accelerators dispatch from the same keydown path: a menu
@@ -1801,13 +1972,9 @@ static bool emitShortcutForWindow(Host *host, const Window *window, WPARAM wpara
1801
1972
  if (item.separator || !item.enabled || item.key.empty()) continue;
1802
1973
  if (item.key != key) continue;
1803
1974
  if (!shortcutModifiersMatch(item.modifiers, allow_implicit_shift)) continue;
1804
- if (!host->callback) return true;
1805
- WindowsEvent event = {};
1806
- event.kind = kMenuCommand;
1807
- event.window_id = window->id;
1808
- event.command_name = item.command.c_str();
1809
- event.command_name_len = item.command.size();
1810
- host->callback(host->callback_context, &event);
1975
+ out->kind = kMenuCommand;
1976
+ out->window_id = window->id;
1977
+ out->command = item.command;
1811
1978
  return true;
1812
1979
  }
1813
1980
  }
@@ -1818,18 +1985,145 @@ static bool emitShortcutForWindow(Host *host, const Window *window, WPARAM wpara
1818
1985
  if (item.separator || !item.enabled || item.key.empty()) continue;
1819
1986
  if (item.key != key) continue;
1820
1987
  if (!shortcutModifiersMatch(item.modifiers, allow_implicit_shift)) continue;
1821
- return emitTrayActionForCommandId(host, host->tray_items, item.command_id);
1988
+ out->kind = kTrayAction;
1989
+ out->window_id = window->id;
1990
+ out->tray_item_id = item.id;
1991
+ /* A tray binding claims the key only while someone listens. */
1992
+ return host->callback != nullptr;
1822
1993
  }
1823
1994
  }
1824
1995
  return false;
1825
1996
  }
1826
1997
 
1998
+ static void emitShortcutMatch(Host *host, const ShortcutMatch &match) {
1999
+ if (!host || !host->callback) return;
2000
+ WindowsEvent event = {};
2001
+ event.kind = match.kind;
2002
+ if (match.kind == kShortcut) {
2003
+ event.window_id = match.window_id;
2004
+ event.shortcut_id = match.shortcut_id.c_str();
2005
+ event.shortcut_id_len = match.shortcut_id.size();
2006
+ event.shortcut_key = match.key.c_str();
2007
+ event.shortcut_key_len = match.key.size();
2008
+ event.shortcut_modifiers = match.modifiers;
2009
+ } else if (match.kind == kMenuCommand) {
2010
+ event.window_id = match.window_id;
2011
+ event.command_name = match.command.c_str();
2012
+ event.command_name_len = match.command.size();
2013
+ } else {
2014
+ event.tray_item_id = match.tray_item_id;
2015
+ }
2016
+ host->callback(host->callback_context, &event);
2017
+ }
2018
+
2019
+ static bool emitShortcutForWindow(Host *host, const Window *window, WPARAM wparam) {
2020
+ ShortcutMatch match;
2021
+ if (!matchShortcutForWindow(host, window, wparam, &match)) return false;
2022
+ emitShortcutMatch(host, match);
2023
+ return true;
2024
+ }
2025
+
1827
2026
  static bool emitShortcutForHwnd(Host *host, HWND hwnd, WPARAM wparam) {
1828
2027
  return emitShortcutForWindow(host, windowForHwnd(host, hwnd), wparam);
1829
2028
  }
1830
2029
 
1831
- static bool emitShortcutForWindowId(Host *host, uint64_t window_id, WPARAM wparam) {
1832
- return emitShortcutForWindow(host, windowForId(host, window_id), wparam);
2030
+ /* Marks a runtime dispatch on the stack for its duration. */
2031
+ struct RuntimeDispatchScope {
2032
+ Host *host;
2033
+ explicit RuntimeDispatchScope(Host *host_in) : host(host_in) { host->runtime_dispatch_depth += 1; }
2034
+ ~RuntimeDispatchScope() { host->runtime_dispatch_depth -= 1; }
2035
+ RuntimeDispatchScope(const RuntimeDispatchScope &) = delete;
2036
+ RuntimeDispatchScope &operator=(const RuntimeDispatchScope &) = delete;
2037
+ };
2038
+
2039
+ /* host->callback while the loop runs: every event the host emits enters
2040
+ * the runtime here. Once the shutdown is handed over, nothing else
2041
+ * reaches the app, as on macOS and Linux: an event raised by the stop
2042
+ * hook's own work, or by a dialog closing while a session end unwinds a
2043
+ * dispatch, would queue behind the shutdown and arrive after the hook. */
2044
+ static void dispatchEventToRuntime(void *context, const WindowsEvent *event) {
2045
+ Host *host = static_cast<Host *>(context);
2046
+ if (host->shutdown_delivered && event->kind != kShutdown) return;
2047
+ RuntimeDispatchScope scope(host);
2048
+ host->runtime_callback(host->runtime_context, event);
2049
+ }
2050
+
2051
+ /* Marks a WebView2 callback on the stack for its duration. */
2052
+ struct WebViewCallbackScope {
2053
+ Host *host;
2054
+ explicit WebViewCallbackScope(Host *host_in) : host(host_in) { host->webview_callback_depth += 1; }
2055
+ ~WebViewCallbackScope() { host->webview_callback_depth -= 1; }
2056
+ WebViewCallbackScope(const WebViewCallbackScope &) = delete;
2057
+ WebViewCallbackScope &operator=(const WebViewCallbackScope &) = delete;
2058
+ };
2059
+
2060
+ /* Called from WebView2 callbacks: queue the event and make sure a
2061
+ * delivery is posted to the loop window. */
2062
+ static void queueWebViewEvent(Host *host, DeferredWebViewEvent event) {
2063
+ host->webview_events.push_back(std::move(event));
2064
+ if (host->webview_events_posted) return;
2065
+ HWND target = loopWindow(host);
2066
+ host->webview_events_posted = target && PostMessageW(target, kDeliverWebViewEventsMessage, 0, 0);
2067
+ }
2068
+
2069
+ static void deliverWebViewEvent(Host *host, const DeferredWebViewEvent &event) {
2070
+ switch (event.kind) {
2071
+ case DeferredWebViewEvent::Kind::Bridge: {
2072
+ /* Delivered even if the view closed after sending it: the
2073
+ * page sent the message while it was live, and a reply to a
2074
+ * view that is gone is dropped where it is sent. None goes
2075
+ * to an app whose shutdown was handed over. */
2076
+ if (!host->bridge_callback || host->shutdown_delivered) return;
2077
+ RuntimeDispatchScope scope(host);
2078
+ host->bridge_callback(
2079
+ host->bridge_context,
2080
+ event.window_id,
2081
+ event.label.c_str(),
2082
+ event.label.size(),
2083
+ event.message.c_str(),
2084
+ event.message.size(),
2085
+ event.origin.c_str(),
2086
+ event.origin.size());
2087
+ return;
2088
+ }
2089
+ case DeferredWebViewEvent::Kind::Focus: {
2090
+ /* Focus is state, not input: a view that is gone by now has
2091
+ * nothing to report. */
2092
+ if (!host->callback || host->webviews.find(event.view_key) == host->webviews.end()) return;
2093
+ WindowsEvent focused = {};
2094
+ focused.kind = kViewFocused;
2095
+ focused.window_id = event.window_id;
2096
+ focused.view_label = event.label.c_str();
2097
+ focused.view_label_len = event.label.size();
2098
+ host->callback(host->callback_context, &focused);
2099
+ return;
2100
+ }
2101
+ case DeferredWebViewEvent::Kind::Shortcut:
2102
+ emitShortcutMatch(host, event.shortcut);
2103
+ return;
2104
+ case DeferredWebViewEvent::Kind::OpenExternal:
2105
+ /* The shell may show UI of its own (an app picker), which is
2106
+ * why this waits for the loop rather than running inside
2107
+ * NavigationStarting. */
2108
+ ShellExecuteW(nullptr, L"open", event.url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
2109
+ return;
2110
+ }
2111
+ }
2112
+
2113
+ /* The loop window's kDeliverWebViewEventsMessage. A delivery that starts
2114
+ * while another is running (the app is inside a modal loop it opened from
2115
+ * an earlier event) leaves the queue to that outer delivery, so the app
2116
+ * sees these events one at a time and in order. */
2117
+ static void deliverWebViewEvents(Host *host) {
2118
+ host->webview_events_posted = false;
2119
+ if (host->delivering_webview_events) return;
2120
+ host->delivering_webview_events = true;
2121
+ while (!host->webview_events.empty()) {
2122
+ DeferredWebViewEvent event = std::move(host->webview_events.front());
2123
+ host->webview_events.pop_front();
2124
+ deliverWebViewEvent(host, event);
2125
+ }
2126
+ host->delivering_webview_events = false;
1833
2127
  }
1834
2128
 
1835
2129
  static std::string webViewKey(uint64_t window_id, const std::string &label) {
@@ -3957,8 +4251,7 @@ constexpr WPARAM kAudioNoteBufferingStarted = 6;
3957
4251
  constexpr WPARAM kAudioNoteBufferingStopped = 7;
3958
4252
  constexpr WPARAM kAudioNoteError = 8;
3959
4253
 
3960
- /* Position tick on the first top-level window, outside the app-timer id
3961
- * range; 500 ms is the shared coarse cadence (macOS and the null
4254
+ /* Position tick on the loop window, outside the app-timer id range; 500 ms is the shared coarse cadence (macOS and the null
3962
4255
  * platform tick the same), so frame-clock scrubber interpolation
3963
4256
  * behaves identically across hosts. */
3964
4257
  constexpr UINT_PTR kAudioPositionTimerId = 0x3000;
@@ -4081,20 +4374,20 @@ struct AudioSessionEventForwarder final : public IMFAsyncCallback {
4081
4374
  }
4082
4375
  };
4083
4376
 
4084
- /* Completes an asynchronous URL source resolution (worker thread) and
4085
- * hands the media source to the loop thread under the host lifetime
4086
- * mutex; a stale generation — the playback was replaced or stopped
4087
- * mid-resolve — shuts the source down right here instead. */
4377
+ /* Completes an asynchronous URL source resolution on a Media Foundation
4378
+ * worker thread. The source goes into its load's mailbox and a note goes
4379
+ * to the loop window; nothing here touches host state. A closed mailbox
4380
+ * (the playback was replaced or stopped mid-resolve) means the source is
4381
+ * shut down right here instead. */
4088
4382
  struct AudioSourceResolveForwarder final : public IMFAsyncCallback {
4089
4383
  LONG refs = 1;
4090
4384
  IMFSourceResolver *resolver = nullptr;
4091
- Host *host = nullptr;
4092
- std::shared_ptr<HostLifetime> lifetime;
4385
+ std::shared_ptr<AudioSourceMailbox> mailbox;
4093
4386
  HWND hwnd = nullptr;
4094
4387
  uint64_t generation = 0;
4095
4388
 
4096
- AudioSourceResolveForwarder(IMFSourceResolver *resolver_in, Host *host_in, std::shared_ptr<HostLifetime> lifetime_in, HWND hwnd_in, uint64_t generation_in)
4097
- : resolver(resolver_in), host(host_in), lifetime(std::move(lifetime_in)), hwnd(hwnd_in), generation(generation_in) {
4389
+ AudioSourceResolveForwarder(IMFSourceResolver *resolver_in, std::shared_ptr<AudioSourceMailbox> mailbox_in, HWND hwnd_in, uint64_t generation_in)
4390
+ : resolver(resolver_in), mailbox(std::move(mailbox_in)), hwnd(hwnd_in), generation(generation_in) {
4098
4391
  resolver->AddRef();
4099
4392
  }
4100
4393
  ~AudioSourceResolveForwarder() { resolver->Release(); }
@@ -4126,23 +4419,24 @@ struct AudioSourceResolveForwarder final : public IMFAsyncCallback {
4126
4419
  IMFMediaSource *source = nullptr;
4127
4420
  if (SUCCEEDED(hr) && object) object->QueryInterface(kNativeSdkIID_IMFMediaSource, reinterpret_cast<void **>(&source));
4128
4421
  if (object) object->Release();
4129
- std::lock_guard<std::recursive_mutex> guard(lifetime->mutex);
4130
- const bool current = lifetime->alive && host->audio.active && host->audio.generation == generation;
4131
- if (!current) {
4132
- if (source) {
4133
- source->Shutdown();
4134
- source->Release();
4422
+ bool open = false;
4423
+ bool kept = false;
4424
+ {
4425
+ std::lock_guard<std::mutex> guard(mailbox->mutex);
4426
+ open = mailbox->open;
4427
+ if (open && source) {
4428
+ /* Reference transferred to the mailbox. */
4429
+ mailbox->source = source;
4430
+ kept = true;
4135
4431
  }
4136
- return S_OK;
4137
4432
  }
4138
- if (!source) {
4139
- PostMessageW(hwnd, kAudioSessionMessage, kAudioNoteSourceFailed, (LPARAM)generation);
4140
- return S_OK;
4433
+ if (!kept && source) {
4434
+ source->Shutdown();
4435
+ source->Release();
4141
4436
  }
4142
- /* Reference transferred; the loop thread attaches or (if it
4143
- * bumps the generation first) releases it in teardown. */
4144
- host->audio.source = source;
4145
- PostMessageW(hwnd, kAudioSessionMessage, kAudioNoteSourceResolved, (LPARAM)generation);
4437
+ /* A destroyed loop window makes this a harmless no-op; a kept
4438
+ * source then stays in the mailbox, which teardown closes. */
4439
+ if (open) PostMessageW(hwnd, kAudioSessionMessage, kept ? kAudioNoteSourceResolved : kAudioNoteSourceFailed, (LPARAM)generation);
4146
4440
  return S_OK;
4147
4441
  }
4148
4442
  };
@@ -4316,7 +4610,7 @@ static void audioStopPositionTimer(Host *host) {
4316
4610
  static void audioStartPositionTimer(Host *host) {
4317
4611
  AudioState &audio = host->audio;
4318
4612
  if (audio.position_timer_armed) return;
4319
- HWND hwnd = parentWindow(host);
4613
+ HWND hwnd = loopWindow(host);
4320
4614
  if (!hwnd) return;
4321
4615
  if (SetTimer(hwnd, kAudioPositionTimerId, kAudioPositionIntervalMs, nullptr)) {
4322
4616
  audio.timer_hwnd = hwnd;
@@ -4713,7 +5007,7 @@ static void audioSpectrumStartCapture(Host *host) {
4713
5007
  AudioSpectrumState &spectrum = host->spectrum;
4714
5008
  if (spectrum.shared) return;
4715
5009
  if (!audioSpectrumSupported()) return;
4716
- HWND hwnd = parentWindow(host);
5010
+ HWND hwnd = loopWindow(host);
4717
5011
  if (!hwnd) return;
4718
5012
  spectrum.generation += 1;
4719
5013
  spectrum.shared = std::make_shared<AudioSpectrumShared>();
@@ -4899,7 +5193,6 @@ static void audioCaptureStopAll(Host *host) {
4899
5193
  * letting a straggler finish installs the cache entry the completed
4900
5194
  * play earned. */
4901
5195
  static void audioReleaseSession(Host *host, bool cancel_download) {
4902
- std::lock_guard<std::recursive_mutex> guard(host->lifetime->mutex);
4903
5196
  AudioState &audio = host->audio;
4904
5197
  audioStopPositionTimer(host);
4905
5198
  audioSpectrumStopCapture(host);
@@ -4926,6 +5219,22 @@ static void audioReleaseSession(Host *host, bool cancel_download) {
4926
5219
  audio.source->Release();
4927
5220
  audio.source = nullptr;
4928
5221
  }
5222
+ if (audio.resolve_mailbox) {
5223
+ /* Close the load's mailbox: a resolution still in flight now
5224
+ * shuts its source down itself, and one that already arrived but
5225
+ * was never collected is released here. */
5226
+ IMFMediaSource *uncollected = nullptr;
5227
+ {
5228
+ std::lock_guard<std::mutex> guard(audio.resolve_mailbox->mutex);
5229
+ audio.resolve_mailbox->open = false;
5230
+ uncollected = audio.resolve_mailbox->source;
5231
+ audio.resolve_mailbox->source = nullptr;
5232
+ }
5233
+ if (uncollected) {
5234
+ uncollected->Shutdown();
5235
+ uncollected->Release();
5236
+ }
5237
+ }
4929
5238
  if (cancel_download && audio.download_cancel) audio.download_cancel->cancelled.store(true);
4930
5239
  const uint64_t generation = audio.generation;
4931
5240
  audio = AudioState{};
@@ -4940,7 +5249,7 @@ static bool audioAttachSession(Host *host) {
4940
5249
  AudioState &audio = host->audio;
4941
5250
  IMFMediaSource *source = audio.source;
4942
5251
  if (!source) return false;
4943
- HWND hwnd = parentWindow(host);
5252
+ HWND hwnd = loopWindow(host);
4944
5253
  if (!hwnd) return false;
4945
5254
  IMFMediaSession *session = nullptr;
4946
5255
  if (FAILED(MFCreateMediaSession(nullptr, &session)) || !session) return false;
@@ -5105,7 +5414,7 @@ static int audioLoadUrlInternal(Host *host, const std::string &url, const std::s
5105
5414
  }
5106
5415
  }
5107
5416
  if (!audioEnsureMediaFoundation()) return 2;
5108
- HWND hwnd = parentWindow(host);
5417
+ HWND hwnd = loopWindow(host);
5109
5418
  if (!hwnd) return 2;
5110
5419
  IMFSourceResolver *resolver = nullptr;
5111
5420
  if (FAILED(MFCreateSourceResolver(&resolver)) || !resolver) return 2;
@@ -5115,7 +5424,8 @@ static int audioLoadUrlInternal(Host *host, const std::string &url, const std::s
5115
5424
  /* A fresh stream has no bytes yet: buffering starts true and drops
5116
5425
  * when the session actually starts rolling. */
5117
5426
  audio.buffering = true;
5118
- AudioSourceResolveForwarder *forwarder = new AudioSourceResolveForwarder(resolver, host, host->lifetime, hwnd, audio.generation);
5427
+ audio.resolve_mailbox = std::make_shared<AudioSourceMailbox>();
5428
+ AudioSourceResolveForwarder *forwarder = new AudioSourceResolveForwarder(resolver, audio.resolve_mailbox, hwnd, audio.generation);
5119
5429
  const HRESULT hr = resolver->BeginCreateObjectFromURL(widen(url).c_str(), MF_RESOLUTION_MEDIASOURCE, nullptr, nullptr, forwarder, nullptr);
5120
5430
  forwarder->Release();
5121
5431
  resolver->Release();
@@ -5170,16 +5480,26 @@ static void audioFailed(Host *host) {
5170
5480
  audioEmitReport(host, kAudioEventFailed, 0, 0, 0, 0);
5171
5481
  }
5172
5482
 
5483
+ /* Loop thread, on kAudioNoteSourceResolved: take the resolved source
5484
+ * out of the current load's mailbox. */
5485
+ static bool audioCollectResolvedSource(Host *host) {
5486
+ AudioState &audio = host->audio;
5487
+ if (!audio.resolve_mailbox) return false;
5488
+ std::lock_guard<std::mutex> guard(audio.resolve_mailbox->mutex);
5489
+ audio.source = audio.resolve_mailbox->source;
5490
+ audio.resolve_mailbox->source = nullptr;
5491
+ return audio.source != nullptr;
5492
+ }
5493
+
5173
5494
  /* Distilled session notes, marshalled from Media Foundation worker
5174
5495
  * threads via PostMessage; loop thread. Stale generations (a replaced
5175
5496
  * or stopped playback's stragglers) are dropped here. */
5176
5497
  static void audioHandleSessionMessage(Host *host, WPARAM note, LPARAM generation) {
5177
- std::lock_guard<std::recursive_mutex> guard(host->lifetime->mutex);
5178
5498
  AudioState &audio = host->audio;
5179
5499
  if (!audio.active || (uint64_t)generation != audio.generation) return;
5180
5500
  switch (note) {
5181
5501
  case kAudioNoteSourceResolved:
5182
- if (!audioAttachSession(host)) audioFailed(host);
5502
+ if (!audioCollectResolvedSource(host) || !audioAttachSession(host)) audioFailed(host);
5183
5503
  break;
5184
5504
  case kAudioNoteSourceFailed:
5185
5505
  case kAudioNoteError:
@@ -5685,8 +6005,8 @@ static bool createChildWebView(Host *host, const std::string &key) {
5685
6005
  [host, key, parent, lifetime, generation](HRESULT result, ICoreWebView2Environment *environment) -> HRESULT {
5686
6006
  auto token = lifetime.lock();
5687
6007
  if (!token) return S_OK;
5688
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5689
6008
  if (!token->alive) return S_OK;
6009
+ WebViewCallbackScope scope(host);
5690
6010
  auto found = host->webviews.find(key);
5691
6011
  if (found == host->webviews.end() || found->second.hwnd != parent || found->second.window_drag_generation != generation || !IsWindow(parent)) return S_OK;
5692
6012
  if (FAILED(result) || !environment) {
@@ -5702,11 +6022,11 @@ static bool createChildWebView(Host *host, const std::string &key) {
5702
6022
  if (controller) controller->Close();
5703
6023
  return S_OK;
5704
6024
  }
5705
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5706
6025
  if (!token->alive) {
5707
6026
  if (controller) controller->Close();
5708
6027
  return S_OK;
5709
6028
  }
6029
+ WebViewCallbackScope scope(host);
5710
6030
  auto found = host->webviews.find(key);
5711
6031
  if (found == host->webviews.end() || found->second.window_drag_generation != generation) {
5712
6032
  if (controller) controller->Close();
@@ -5733,16 +6053,16 @@ static bool createChildWebView(Host *host, const std::string &key) {
5733
6053
  [host, key, lifetime](ICoreWebView2Controller *, IUnknown *) -> HRESULT {
5734
6054
  auto token = lifetime.lock();
5735
6055
  if (!token) return S_OK;
5736
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5737
6056
  if (!token->alive || !host->callback) return S_OK;
6057
+ WebViewCallbackScope scope(host);
5738
6058
  auto focused = host->webviews.find(key);
5739
6059
  if (focused == host->webviews.end()) return S_OK;
5740
- WindowsEvent event = {};
5741
- event.kind = kViewFocused;
6060
+ DeferredWebViewEvent event;
6061
+ event.kind = DeferredWebViewEvent::Kind::Focus;
5742
6062
  event.window_id = focused->second.window_id;
5743
- event.view_label = focused->second.label.c_str();
5744
- event.view_label_len = focused->second.label.size();
5745
- host->callback(host->callback_context, &event);
6063
+ event.view_key = key;
6064
+ event.label = focused->second.label;
6065
+ queueWebViewEvent(host, std::move(event));
5746
6066
  return S_OK;
5747
6067
  }).Get(), &focus_token);
5748
6068
  if (found->second.webview) {
@@ -5771,8 +6091,8 @@ static bool createChildWebView(Host *host, const std::string &key) {
5771
6091
  [host, key, lifetime, drag_generation](HRESULT result, LPCWSTR) -> HRESULT {
5772
6092
  auto token = lifetime.lock();
5773
6093
  if (!token) return S_OK;
5774
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5775
6094
  if (!token->alive) return S_OK;
6095
+ WebViewCallbackScope scope(host);
5776
6096
  auto current = host->webviews.find(key);
5777
6097
  if (current == host->webviews.end() || current->second.window_drag_generation != drag_generation) return S_OK;
5778
6098
  if (FAILED(result)) {
@@ -5800,8 +6120,8 @@ static bool createChildWebView(Host *host, const std::string &key) {
5800
6120
  [host, key, bridge_window_id, bridge_label, lifetime](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT {
5801
6121
  auto token = lifetime.lock();
5802
6122
  if (!token) return S_OK;
5803
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5804
6123
  if (!token->alive || !args || !host->bridge_callback) return S_OK;
6124
+ WebViewCallbackScope scope(host);
5805
6125
 
5806
6126
  LPWSTR message_bytes = nullptr;
5807
6127
  if (FAILED(args->TryGetWebMessageAsString(&message_bytes)) || !message_bytes) return S_OK;
@@ -5821,15 +6141,14 @@ static bool createChildWebView(Host *host, const std::string &key) {
5821
6141
  : bridgeOriginForWebViewUrl(source_webview->second, source_url);
5822
6142
  }
5823
6143
 
5824
- host->bridge_callback(
5825
- host->bridge_context,
5826
- bridge_window_id,
5827
- bridge_label.c_str(),
5828
- bridge_label.size(),
5829
- message.c_str(),
5830
- message.size(),
5831
- origin.c_str(),
5832
- origin.size());
6144
+ DeferredWebViewEvent event;
6145
+ event.kind = DeferredWebViewEvent::Kind::Bridge;
6146
+ event.window_id = bridge_window_id;
6147
+ event.view_key = key;
6148
+ event.label = bridge_label;
6149
+ event.message = std::move(message);
6150
+ event.origin = std::move(origin);
6151
+ queueWebViewEvent(host, std::move(event));
5833
6152
  return S_OK;
5834
6153
  }).Get(), &bridge_token);
5835
6154
  }
@@ -5839,16 +6158,21 @@ static bool createChildWebView(Host *host, const std::string &key) {
5839
6158
  [host, accelerator_window_id, lifetime](ICoreWebView2Controller *, ICoreWebView2AcceleratorKeyPressedEventArgs *args) -> HRESULT {
5840
6159
  auto token = lifetime.lock();
5841
6160
  if (!token) return S_OK;
5842
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5843
6161
  if (!token->alive || !args) return S_OK;
6162
+ WebViewCallbackScope scope(host);
5844
6163
  COREWEBVIEW2_KEY_EVENT_KIND kind = COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN;
5845
6164
  if (FAILED(args->get_KeyEventKind(&kind))) return S_OK;
5846
6165
  if (kind != COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN && kind != COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN) return S_OK;
5847
6166
  UINT virtual_key = 0;
5848
6167
  if (FAILED(args->get_VirtualKey(&virtual_key))) return S_OK;
5849
- if (emitShortcutForWindowId(host, accelerator_window_id, virtual_key)) {
5850
- args->put_Handled(TRUE);
5851
- }
6168
+ /* Handled is decided now, from the key state
6169
+ * of this keydown; the app hears it later. */
6170
+ DeferredWebViewEvent event;
6171
+ event.kind = DeferredWebViewEvent::Kind::Shortcut;
6172
+ if (!matchShortcutForWindow(host, windowForId(host, accelerator_window_id), virtual_key, &event.shortcut)) return S_OK;
6173
+ args->put_Handled(TRUE);
6174
+ event.window_id = accelerator_window_id;
6175
+ queueWebViewEvent(host, std::move(event));
5852
6176
  return S_OK;
5853
6177
  }).Get(), &accelerator_token);
5854
6178
 
@@ -5858,8 +6182,8 @@ static bool createChildWebView(Host *host, const std::string &key) {
5858
6182
  [host, key, environment_ref, lifetime](ICoreWebView2 *, ICoreWebView2WebResourceRequestedEventArgs *args) -> HRESULT {
5859
6183
  auto token = lifetime.lock();
5860
6184
  if (!token) return S_OK;
5861
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5862
6185
  if (!token->alive || !args) return S_OK;
6186
+ WebViewCallbackScope scope(host);
5863
6187
  auto found = host->webviews.find(key);
5864
6188
  if (found == host->webviews.end()) return S_OK;
5865
6189
  ComPtr<ICoreWebView2WebResourceRequest> request;
@@ -5880,8 +6204,8 @@ static bool createChildWebView(Host *host, const std::string &key) {
5880
6204
  [host, key, lifetime](ICoreWebView2 *, ICoreWebView2NavigationStartingEventArgs *args) -> HRESULT {
5881
6205
  auto token = lifetime.lock();
5882
6206
  if (!token) return S_OK;
5883
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5884
6207
  if (!token->alive) return S_OK;
6208
+ WebViewCallbackScope scope(host);
5885
6209
  LPWSTR uri_bytes = nullptr;
5886
6210
  if (!args || FAILED(args->get_Uri(&uri_bytes))) return S_OK;
5887
6211
  std::wstring uri_wide = uri_bytes ? std::wstring(uri_bytes) : std::wstring();
@@ -5891,7 +6215,10 @@ static bool createChildWebView(Host *host, const std::string &key) {
5891
6215
  if (found != host->webviews.end() && isInternalAssetUrl(found->second, uri)) return S_OK;
5892
6216
  if (uri.empty() || uri.rfind("about:", 0) == 0 || policyListMatches(host->allowed_origins, uri)) return S_OK;
5893
6217
  if (host->external_link_action == 1 && policyListMatches(host->allowed_external_urls, uri)) {
5894
- ShellExecuteW(nullptr, L"open", uri_wide.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
6218
+ DeferredWebViewEvent event;
6219
+ event.kind = DeferredWebViewEvent::Kind::OpenExternal;
6220
+ event.url = uri_wide;
6221
+ queueWebViewEvent(host, std::move(event));
5895
6222
  }
5896
6223
  args->put_Cancel(TRUE);
5897
6224
  return S_OK;
@@ -6078,6 +6405,145 @@ static void clampChromelessMaximizedFrame(Host *host, HWND hwnd, WINDOWPOS *pos)
6078
6405
  pos->flags &= ~(SWP_NOMOVE | SWP_NOSIZE);
6079
6406
  }
6080
6407
 
6408
+ /* The loop window's procedure: everything marshalled onto the message
6409
+ * loop lands here rather than on an app window, which may close at any
6410
+ * time. Posted messages reach it from nested modal loops (dialogs, menus,
6411
+ * the move/size loop) too, because those dispatch every window message of
6412
+ * the thread; a thread message (PostThreadMessage) would be dropped by
6413
+ * them, and a waitable handle would not be serviced by them at all. */
6414
+ static LRESULT CALLBACK loopWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
6415
+ if (message == WM_NCCREATE) {
6416
+ auto *create = reinterpret_cast<CREATESTRUCTW *>(lparam);
6417
+ SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(create->lpCreateParams));
6418
+ }
6419
+ Host *host = hostFromWindow(hwnd);
6420
+ switch (message) {
6421
+ case kWakeMessage:
6422
+ if (host && host->callback) {
6423
+ WindowsEvent wake = {};
6424
+ wake.kind = kWake;
6425
+ wake.window_id = 1;
6426
+ host->callback(host->callback_context, &wake);
6427
+ }
6428
+ return 0;
6429
+ case kRequestFrameMessage:
6430
+ if (host) {
6431
+ /* Cleared before emitting: a request made while this frame
6432
+ * runs posts again instead of folding into a finished one. */
6433
+ host->frame_request_pending.store(false, std::memory_order_release);
6434
+ if (host->callback) {
6435
+ WindowsEvent frame = {};
6436
+ frame.kind = kFrame;
6437
+ frame.window_id = 1;
6438
+ host->callback(host->callback_context, &frame);
6439
+ }
6440
+ }
6441
+ return 0;
6442
+ case kAudioSessionMessage:
6443
+ if (host) audioHandleSessionMessage(host, wparam, lparam);
6444
+ return 0;
6445
+ case kAudioSpectrumMessage:
6446
+ if (host) audioHandleSpectrumMessage(host, wparam);
6447
+ return 0;
6448
+ case kDeliverWebViewEventsMessage:
6449
+ if (host) deliverWebViewEvents(host);
6450
+ return 0;
6451
+ case WM_TIMER:
6452
+ if (host && handleAppTimerMessage(host, wparam)) return 0;
6453
+ if (host && handleAudioTimerMessage(host, wparam)) return 0;
6454
+ return 0;
6455
+ default:
6456
+ break;
6457
+ }
6458
+ return DefWindowProcW(hwnd, message, wparam, lparam);
6459
+ }
6460
+
6461
+ static HWND createLoopWindow(Host *host) {
6462
+ static bool registered = false;
6463
+ if (!registered) {
6464
+ WNDCLASSEXW wc = {};
6465
+ wc.cbSize = sizeof(wc);
6466
+ wc.lpfnWndProc = loopWindowProc;
6467
+ wc.hInstance = host->instance;
6468
+ wc.lpszClassName = L"NativeSdkLoopWindow";
6469
+ registered = RegisterClassExW(&wc) != 0;
6470
+ }
6471
+ return CreateWindowExW(0, L"NativeSdkLoopWindow", L"", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, host->instance, host);
6472
+ }
6473
+
6474
+ /* Deliver the app's shutdown at most once: the run loop's exit (a
6475
+ * last-window close, quitApp) and a session end (endSession) both lead
6476
+ * here, and the runtime's stop hook must run exactly once. */
6477
+ static void deliverShutdown(Host *host) {
6478
+ if (host->shutdown_delivered || !host->callback) return;
6479
+ host->shutdown_delivered = true;
6480
+ WindowsEvent shutdown = {};
6481
+ shutdown.kind = kShutdown;
6482
+ shutdown.window_id = 1;
6483
+ host->callback(host->callback_context, &shutdown);
6484
+ }
6485
+
6486
+ /* Cancel a dialog the app's windows own, directly or through another
6487
+ * dialog (the overwrite prompt a save dialog raises): its modal loop
6488
+ * holds the dispatch that opened it. The enumeration runs top of the
6489
+ * z-order first, so an inner dialog closes before its owner. */
6490
+ static BOOL CALLBACK closeOwnedDialog(HWND hwnd, LPARAM lparam) {
6491
+ Host *host = reinterpret_cast<Host *>(lparam);
6492
+ if (!IsWindowVisible(hwnd) || windowForHwnd(host, hwnd)) return TRUE;
6493
+ for (HWND owner = GetWindow(hwnd, GW_OWNER); owner; owner = GetWindow(owner, GW_OWNER)) {
6494
+ if (!windowForHwnd(host, owner)) continue;
6495
+ PostMessageW(hwnd, WM_CLOSE, 0, 0);
6496
+ break;
6497
+ }
6498
+ return TRUE;
6499
+ }
6500
+
6501
+ /* A confirmed session end (WM_ENDSESSION, wParam TRUE): logoff, system
6502
+ * shutdown or restart, or a Restart Manager close (ENDSESSION_CLOSEAPP)
6503
+ * from an installer that needs the app's files. It is not the user's
6504
+ * close, so close_policy does not apply: a window hidden behind the
6505
+ * tray gets the message like a visible one.
6506
+ *
6507
+ * Every top-level window receives the message, so only the first acts,
6508
+ * and a quit whose shutdown already ran finishes its own teardown.
6509
+ *
6510
+ * Outside any runtime dispatch (the common case) it delivers the same
6511
+ * shutdown event a quit does, synchronously: at logoff or shutdown
6512
+ * Windows may end the process any time after the message returns, so
6513
+ * the stop hook, and the persistence flush and journal seal that run in
6514
+ * the shutdown dispatch, finish first. The process then ends here
6515
+ * instead of returning to the loop. Restart Manager waits for it to
6516
+ * exit and sends WM_CLOSE as soon as this returns, and Windows considers
6517
+ * a process that hides or destroys its windows ready to be killed, so
6518
+ * the regular teardown would stop at an arbitrary point anyway.
6519
+ * Chromium (SessionEnding), Electron, and tao end the process the same
6520
+ * way. The tray icon goes first: the shell keeps a dead process's icon
6521
+ * until the pointer crosses it, and after a Restart Manager close the
6522
+ * session goes on.
6523
+ *
6524
+ * Inside a runtime dispatch (a dialog the app opened is in its modal
6525
+ * loop) the runtime queues the shutdown until that dispatch commits, to
6526
+ * keep events in causal order, so ending the process here would lose
6527
+ * the stop hook. The host lets the dispatch unwind instead: it cancels
6528
+ * the dialogs the app's windows own, ignores further closes, and
6529
+ * returns. The dispatch finishes, the queued shutdown runs the stop
6530
+ * hook, and the loop exits into the regular teardown. A Restart Manager
6531
+ * close waits for that; at logoff or shutdown it races the session
6532
+ * ending after this returns. */
6533
+ static void endSession(Host *host) {
6534
+ if (host->shutdown_delivered) return;
6535
+ if (host->runtime_dispatch_depth == 0) {
6536
+ deliverShutdown(host);
6537
+ removeNotificationIcon(host);
6538
+ TerminateProcess(GetCurrentProcess(), 0);
6539
+ return;
6540
+ }
6541
+ host->session_ending = true;
6542
+ deliverShutdown(host);
6543
+ host->running = false;
6544
+ EnumThreadWindows(GetCurrentThreadId(), closeOwnedDialog, reinterpret_cast<LPARAM>(host));
6545
+ }
6546
+
6081
6547
  static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
6082
6548
  if (message == WM_NCCREATE) {
6083
6549
  auto *create = reinterpret_cast<CREATESTRUCTW *>(lparam);
@@ -6210,28 +6676,6 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6210
6676
  }
6211
6677
  }
6212
6678
  switch (message) {
6213
- case kWakeMessage:
6214
- if (host) {
6215
- WindowsEvent wake = {};
6216
- wake.kind = kWake;
6217
- wake.window_id = 1;
6218
- if (host->callback) host->callback(host->callback_context, &wake);
6219
- }
6220
- return 0;
6221
- case kRequestFrameMessage:
6222
- if (host) {
6223
- WindowsEvent frame = {};
6224
- frame.kind = kFrame;
6225
- frame.window_id = 1;
6226
- if (host->callback) host->callback(host->callback_context, &frame);
6227
- }
6228
- return 0;
6229
- case kAudioSessionMessage:
6230
- if (host) audioHandleSessionMessage(host, wparam, lparam);
6231
- return 0;
6232
- case kAudioSpectrumMessage:
6233
- if (host) audioHandleSpectrumMessage(host, wparam);
6234
- return 0;
6235
6679
  case kShowContextMenuMessage:
6236
6680
  if (host) showAppContextMenu(host, hwnd);
6237
6681
  return 0;
@@ -6415,8 +6859,6 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6415
6859
  }
6416
6860
  break;
6417
6861
  case WM_TIMER:
6418
- if (host && handleAppTimerMessage(host, wparam)) return 0;
6419
- if (host && handleAudioTimerMessage(host, wparam)) return 0;
6420
6862
  if (host && wparam == kFrameTimerId) {
6421
6863
  for (auto &entry : host->windows) {
6422
6864
  if (entry.second.hwnd == hwnd) {
@@ -6472,7 +6914,21 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6472
6914
  case WM_SYSCOLORCHANGE:
6473
6915
  if (host) emitAppearanceIfChanged(host, false);
6474
6916
  break;
6917
+ case WM_QUERYENDSESSION:
6918
+ /* Always yes: the SDK has no quit veto (close_policy governs
6919
+ * the user's close only). Nothing happens until WM_ENDSESSION
6920
+ * confirms the end; another application may still cancel. */
6921
+ return TRUE;
6922
+ case WM_ENDSESSION:
6923
+ /* wParam FALSE: the session goes on. Nothing was done at the
6924
+ * query, so there is nothing to undo. */
6925
+ if (host && wparam) endSession(host);
6926
+ return 0;
6475
6927
  case WM_CLOSE:
6928
+ /* A session end is unwinding a dispatch (endSession): the
6929
+ * loop exits once it does, so Restart Manager's follow-up
6930
+ * close neither hides nor destroys anything. */
6931
+ if (host && host->session_ending) return 0;
6476
6932
  /* close_policy .hide: the user's close (caption X, Alt+F4)
6477
6933
  * hides the window instead of destroying it — the hwnd and
6478
6934
  * every view stay live, the app keeps running behind its
@@ -6511,6 +6967,7 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6511
6967
  if (host) {
6512
6968
  for (auto &entry : host->windows) {
6513
6969
  if (entry.second.hwnd == hwnd) {
6970
+ cancelDialogsForWindow(host, entry.first);
6514
6971
  destroyNativeViewsForWindow(host, entry.first);
6515
6972
  destroyChildWebViewsForWindow(host, entry.first);
6516
6973
  entry.second.hwnd = nullptr;
@@ -6652,6 +7109,327 @@ static bool createNativeWindow(Host *host, Window &window) {
6652
7109
  return true;
6653
7110
  }
6654
7111
 
7112
+ /* ------------------------------------------------------------- dialogs */
7113
+
7114
+ static DialogRequest openDialogSpec(const WindowsOpenDialogOpts *opts) {
7115
+ DialogRequest spec;
7116
+ spec.kind = kDialogOpen;
7117
+ spec.title = slice(opts->title, opts->title_len);
7118
+ spec.default_path = slice(opts->default_path, opts->default_path_len);
7119
+ spec.extensions = slice(opts->extensions, opts->extensions_len);
7120
+ spec.allow_directories = opts->allow_directories != 0;
7121
+ spec.allow_multiple = opts->allow_multiple != 0;
7122
+ return spec;
7123
+ }
7124
+
7125
+ static DialogRequest saveDialogSpec(const WindowsSaveDialogOpts *opts) {
7126
+ DialogRequest spec;
7127
+ spec.kind = kDialogSave;
7128
+ spec.title = slice(opts->title, opts->title_len);
7129
+ spec.default_path = slice(opts->default_path, opts->default_path_len);
7130
+ spec.default_name = slice(opts->default_name, opts->default_name_len);
7131
+ spec.extensions = slice(opts->extensions, opts->extensions_len);
7132
+ return spec;
7133
+ }
7134
+
7135
+ static DialogRequest messageDialogSpec(const WindowsMessageDialogOpts *opts) {
7136
+ DialogRequest spec;
7137
+ spec.kind = kDialogMessage;
7138
+ spec.style = opts->style;
7139
+ spec.title = slice(opts->title, opts->title_len);
7140
+ spec.message = slice(opts->message, opts->message_len);
7141
+ spec.informative_text = slice(opts->informative_text, opts->informative_text_len);
7142
+ spec.primary_button = slice(opts->primary_button, opts->primary_button_len);
7143
+ spec.secondary_button = slice(opts->secondary_button, opts->secondary_button_len);
7144
+ spec.tertiary_button = slice(opts->tertiary_button, opts->tertiary_button_len);
7145
+ return spec;
7146
+ }
7147
+
7148
+ /* Show an open dialog modally on the calling thread, which has COM
7149
+ * initialized. S_OK with the chosen paths, or the failure Show reported
7150
+ * (HRESULT_FROM_WIN32(ERROR_CANCELLED) when the user cancelled). */
7151
+ static HRESULT showOpenDialogOn(HWND owner, const DialogRequest &spec, std::vector<std::wstring> *paths) {
7152
+ IFileOpenDialog *dialog = nullptr;
7153
+ HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog));
7154
+ if (FAILED(hr) || !dialog) return FAILED(hr) ? hr : E_FAIL;
7155
+
7156
+ DWORD options = 0;
7157
+ if (SUCCEEDED(dialog->GetOptions(&options))) {
7158
+ options |= FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST;
7159
+ if (spec.allow_directories) options |= FOS_PICKFOLDERS;
7160
+ else options |= FOS_FILEMUSTEXIST;
7161
+ if (spec.allow_multiple) options |= FOS_ALLOWMULTISELECT;
7162
+ dialog->SetOptions(options);
7163
+ }
7164
+ setDialogTitle(dialog, spec.title.data(), spec.title.size());
7165
+ setDialogFolder(dialog, spec.default_path.data(), spec.default_path.size());
7166
+ if (!spec.allow_directories) setDialogFilters(dialog, spec.extensions.data(), spec.extensions.size());
7167
+
7168
+ hr = dialog->Show(owner);
7169
+ if (SUCCEEDED(hr)) {
7170
+ IShellItemArray *items = nullptr;
7171
+ if (SUCCEEDED(dialog->GetResults(&items)) && items) {
7172
+ DWORD count = 0;
7173
+ items->GetCount(&count);
7174
+ for (DWORD index = 0; index < count; ++index) {
7175
+ IShellItem *item = nullptr;
7176
+ if (SUCCEEDED(items->GetItemAt(index, &item)) && item) {
7177
+ PWSTR path = nullptr;
7178
+ if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path)) && path) {
7179
+ paths->push_back(std::wstring(path));
7180
+ CoTaskMemFree(path);
7181
+ }
7182
+ item->Release();
7183
+ }
7184
+ }
7185
+ items->Release();
7186
+ }
7187
+ }
7188
+ dialog->Release();
7189
+ return hr;
7190
+ }
7191
+
7192
+ /* The save dialog's counterpart of showOpenDialogOn. */
7193
+ static HRESULT showSaveDialogOn(HWND owner, const DialogRequest &spec, std::wstring *path) {
7194
+ IFileSaveDialog *dialog = nullptr;
7195
+ HRESULT hr = CoCreateInstance(CLSID_FileSaveDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog));
7196
+ if (FAILED(hr) || !dialog) return FAILED(hr) ? hr : E_FAIL;
7197
+
7198
+ DWORD options = 0;
7199
+ if (SUCCEEDED(dialog->GetOptions(&options))) dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
7200
+ setDialogTitle(dialog, spec.title.data(), spec.title.size());
7201
+ setDialogFolder(dialog, spec.default_path.data(), spec.default_path.size());
7202
+ setDialogFilters(dialog, spec.extensions.data(), spec.extensions.size());
7203
+ std::wstring default_name = widen(spec.default_name);
7204
+ if (!default_name.empty()) dialog->SetFileName(default_name.c_str());
7205
+
7206
+ hr = dialog->Show(owner);
7207
+ if (SUCCEEDED(hr)) {
7208
+ IShellItem *item = nullptr;
7209
+ if (SUCCEEDED(dialog->GetResult(&item)) && item) {
7210
+ PWSTR chosen = nullptr;
7211
+ if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &chosen)) && chosen) {
7212
+ *path = std::wstring(chosen);
7213
+ CoTaskMemFree(chosen);
7214
+ }
7215
+ item->Release();
7216
+ }
7217
+ }
7218
+ dialog->Release();
7219
+ return hr;
7220
+ }
7221
+
7222
+ /* TaskDialogIndirect is a comctl32 v6 export: it only resolves when the
7223
+ * process activates the v6 side-by-side assembly (application manifest).
7224
+ * A static import would abort the whole process at load time with
7225
+ * STATUS_ENTRYPOINT_NOT_FOUND on the system default v5, so it is resolved
7226
+ * dynamically, and null sends callers to MessageBoxW (fixed button
7227
+ * captions, same 0/1/2 result contract). */
7228
+ using TaskDialogIndirectFn = HRESULT(WINAPI *)(const TASKDIALOGCONFIG *, int *, int *, BOOL *);
7229
+
7230
+ static TaskDialogIndirectFn taskDialogIndirect() {
7231
+ static TaskDialogIndirectFn task_dialog = reinterpret_cast<TaskDialogIndirectFn>(
7232
+ reinterpret_cast<void *>(GetProcAddress(GetModuleHandleW(L"comctl32.dll"), "TaskDialogIndirect")));
7233
+ return task_dialog;
7234
+ }
7235
+
7236
+ /* Run a message dialog as a task dialog. *pressed is 100, 101 or 102 for
7237
+ * the primary, secondary or tertiary button, and IDCANCEL when it was
7238
+ * closed without one (Esc, the close button, WM_CLOSE). */
7239
+ static HRESULT runTaskDialog(TaskDialogIndirectFn task_dialog, HWND owner, const DialogRequest &spec, int *pressed) {
7240
+ std::wstring title = widen(spec.title);
7241
+ std::wstring message = widen(spec.message);
7242
+ std::wstring informative = widen(spec.informative_text);
7243
+ std::wstring primary = widen(spec.primary_button);
7244
+ std::wstring secondary = widen(spec.secondary_button);
7245
+ std::wstring tertiary = widen(spec.tertiary_button);
7246
+ if (primary.empty()) primary = L"OK";
7247
+
7248
+ TASKDIALOG_BUTTON buttons[3] = {};
7249
+ int button_count = 0;
7250
+ buttons[button_count++] = { 100, primary.c_str() };
7251
+ if (!secondary.empty()) buttons[button_count++] = { 101, secondary.c_str() };
7252
+ if (!tertiary.empty()) buttons[button_count++] = { 102, tertiary.c_str() };
7253
+
7254
+ TASKDIALOGCONFIG config = {};
7255
+ config.cbSize = sizeof(config);
7256
+ config.hwndParent = owner;
7257
+ config.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION;
7258
+ config.pszWindowTitle = title.empty() ? L"native-sdk" : title.c_str();
7259
+ config.pszMainInstruction = message.empty() ? config.pszWindowTitle : message.c_str();
7260
+ config.pszContent = informative.empty() ? nullptr : informative.c_str();
7261
+ config.cButtons = static_cast<UINT>(button_count);
7262
+ config.pButtons = buttons;
7263
+ config.nDefaultButton = 100;
7264
+ config.pszMainIcon = spec.style == 2 ? TD_ERROR_ICON : (spec.style == 1 ? TD_WARNING_ICON : TD_INFORMATION_ICON);
7265
+ *pressed = 100;
7266
+ return task_dialog(&config, pressed, nullptr, nullptr);
7267
+ }
7268
+
7269
+ /* The comctl32 v5 fallback: MessageBoxW, returning its IDOK-style code
7270
+ * (0 when it failed). messageBoxButton maps that to 0/1/2. */
7271
+ static int runMessageBox(HWND owner, const DialogRequest &spec) {
7272
+ const int button_count = 1 + (spec.secondary_button.empty() ? 0 : 1) + (spec.tertiary_button.empty() ? 0 : 1);
7273
+ UINT type = MB_OK;
7274
+ if (button_count == 2) type = MB_OKCANCEL;
7275
+ if (button_count == 3) type = MB_YESNOCANCEL;
7276
+ type |= spec.style == 2 ? MB_ICONERROR : (spec.style == 1 ? MB_ICONWARNING : MB_ICONINFORMATION);
7277
+ std::wstring title = widen(spec.title);
7278
+ std::wstring text = widen(spec.message);
7279
+ std::wstring informative = widen(spec.informative_text);
7280
+ if (!informative.empty()) {
7281
+ if (!text.empty()) text += L"\n\n";
7282
+ text += informative;
7283
+ }
7284
+ return MessageBoxW(owner, text.c_str(), title.empty() ? L"native-sdk" : title.c_str(), type);
7285
+ }
7286
+
7287
+ static int messageBoxButton(const DialogRequest &spec, int result) {
7288
+ if (!spec.secondary_button.empty() && !spec.tertiary_button.empty()) {
7289
+ if (result == IDNO) return 1;
7290
+ if (result == IDCANCEL) return 2;
7291
+ return 0;
7292
+ }
7293
+ if (result == IDCANCEL) return 1;
7294
+ return 0;
7295
+ }
7296
+
7297
+ /* The request a dialog thread is running, for dialogCreateHook. */
7298
+ thread_local DialogRequest *t_dialog_request = nullptr;
7299
+ thread_local DialogQueue *t_dialog_queue = nullptr;
7300
+
7301
+ /* A dialog thread's WH_CALLWNDPROC hook: a dialog the app cancelled
7302
+ * before its window existed closes as soon as that window is set up. A
7303
+ * cancel after this point finds the window (cancelDialog). */
7304
+ static LRESULT CALLBACK dialogCreateHook(int code, WPARAM wparam, LPARAM lparam) {
7305
+ if (code == HC_ACTION && t_dialog_request && t_dialog_queue) {
7306
+ const CWPSTRUCT *sent = reinterpret_cast<const CWPSTRUCT *>(lparam);
7307
+ if (sent->message == WM_INITDIALOG && (GetWindowLongPtrW(sent->hwnd, GWL_STYLE) & WS_CHILD) == 0) {
7308
+ std::lock_guard<std::mutex> lock(t_dialog_queue->mutex);
7309
+ if (t_dialog_request->cancel_requested) PostMessageW(sent->hwnd, WM_CLOSE, 0, 0);
7310
+ }
7311
+ }
7312
+ return CallNextHookEx(nullptr, code, wparam, lparam);
7313
+ }
7314
+
7315
+ static void runDialogRequest(DialogRequest &request) {
7316
+ if (request.kind == kDialogOpen || request.kind == kDialogSave) {
7317
+ std::vector<std::wstring> paths;
7318
+ HRESULT hr = S_OK;
7319
+ if (request.kind == kDialogOpen) {
7320
+ hr = showOpenDialogOn(request.owner, request, &paths);
7321
+ } else {
7322
+ std::wstring path;
7323
+ hr = showSaveDialogOn(request.owner, request, &path);
7324
+ if (!path.empty()) paths.push_back(path);
7325
+ }
7326
+ for (const std::wstring &path : paths) {
7327
+ std::string utf8 = narrow(path);
7328
+ if (!utf8.empty()) request.paths.push_back(utf8);
7329
+ }
7330
+ if (SUCCEEDED(hr)) {
7331
+ request.outcome = request.paths.empty() ? kDialogCancelled : kDialogSelected;
7332
+ } else {
7333
+ request.outcome = hr == HRESULT_FROM_WIN32(ERROR_CANCELLED) ? kDialogCancelled : kDialogFailed;
7334
+ }
7335
+ return;
7336
+ }
7337
+ if (TaskDialogIndirectFn task_dialog = taskDialogIndirect()) {
7338
+ int pressed = 100;
7339
+ const HRESULT hr = runTaskDialog(task_dialog, request.owner, request, &pressed);
7340
+ if (FAILED(hr)) {
7341
+ request.outcome = kDialogFailed;
7342
+ } else if (pressed >= 100 && pressed <= 102) {
7343
+ request.outcome = kDialogSelected;
7344
+ request.button = pressed - 100;
7345
+ } else {
7346
+ request.outcome = kDialogCancelled;
7347
+ }
7348
+ return;
7349
+ }
7350
+ const int result = runMessageBox(request.owner, request);
7351
+ if (result == 0) {
7352
+ request.outcome = kDialogFailed;
7353
+ } else {
7354
+ // A message box closed by WM_CLOSE reports a button anyway; one
7355
+ // the app cancelled is no answer.
7356
+ std::lock_guard<std::mutex> lock(t_dialog_queue->mutex);
7357
+ request.outcome = request.cancel_requested ? kDialogCancelled : kDialogSelected;
7358
+ request.button = messageBoxButton(request, result);
7359
+ }
7360
+ }
7361
+
7362
+ /* A dialog thread: a single-threaded apartment of its own, running the
7363
+ * dialog modally, then queueing the result and waking the loop. */
7364
+ static void runDialogThread(std::shared_ptr<DialogQueue> queue, std::shared_ptr<DialogRequest> request) {
7365
+ bool cancel_requested = false;
7366
+ {
7367
+ std::lock_guard<std::mutex> lock(queue->mutex);
7368
+ request->thread_id = GetCurrentThreadId();
7369
+ cancel_requested = request->cancel_requested;
7370
+ }
7371
+ if (!cancel_requested) {
7372
+ bool uninitialize = false;
7373
+ if (initializeCom(&uninitialize)) {
7374
+ t_dialog_request = request.get();
7375
+ t_dialog_queue = queue.get();
7376
+ HHOOK hook = SetWindowsHookExW(WH_CALLWNDPROC, dialogCreateHook, nullptr, GetCurrentThreadId());
7377
+ runDialogRequest(*request);
7378
+ if (hook) UnhookWindowsHookEx(hook);
7379
+ t_dialog_request = nullptr;
7380
+ t_dialog_queue = nullptr;
7381
+ finishCom(uninitialize);
7382
+ } else {
7383
+ request->outcome = kDialogFailed;
7384
+ }
7385
+ }
7386
+ std::lock_guard<std::mutex> lock(queue->mutex);
7387
+ request->thread_id = 0;
7388
+ queue->finished.push_back(request);
7389
+ if (queue->loop_window) PostMessageW(queue->loop_window, kWakeMessage, 0, 0);
7390
+ }
7391
+
7392
+ /* Posted to every dialog window of a dialog thread, the top one first,
7393
+ * so a save dialog's overwrite prompt closes before the dialog that
7394
+ * raised it. Other top-level windows of the thread (COM's) are left
7395
+ * alone. */
7396
+ static BOOL CALLBACK closeDialogWindow(HWND hwnd, LPARAM lparam) {
7397
+ (void)lparam;
7398
+ wchar_t class_name[8] = {};
7399
+ if (GetClassNameW(hwnd, class_name, 8) > 0 && wcscmp(class_name, L"#32770") == 0) PostMessageW(hwnd, WM_CLOSE, 0, 0);
7400
+ return TRUE;
7401
+ }
7402
+
7403
+ /* Close a dialog early; it reports through its thread like any other
7404
+ * finish. One whose window does not exist yet closes from
7405
+ * dialogCreateHook, and one whose thread has not started never shows. */
7406
+ static void cancelDialog(Host *host, DialogRequest &dialog) {
7407
+ DWORD thread_id = 0;
7408
+ {
7409
+ std::lock_guard<std::mutex> lock(host->dialog_queue->mutex);
7410
+ dialog.cancel_requested = true;
7411
+ thread_id = dialog.thread_id;
7412
+ }
7413
+ if (thread_id != 0) EnumThreadWindows(thread_id, closeDialogWindow, 0);
7414
+ }
7415
+
7416
+ /* A closing window takes its dialogs down with it. */
7417
+ static void cancelDialogsForWindow(Host *host, uint64_t window_id) {
7418
+ for (auto &entry : host->dialogs) {
7419
+ if (entry.second->parent_window_id == window_id) cancelDialog(host, *entry.second);
7420
+ }
7421
+ }
7422
+
7423
+ /* Host teardown: every dialog closes, and the threads finish without the
7424
+ * loop. Results already queued are dropped with the queue. */
7425
+ static void cancelAllDialogs(Host *host) {
7426
+ for (auto &entry : host->dialogs) cancelDialog(host, *entry.second);
7427
+ host->dialogs.clear();
7428
+ std::lock_guard<std::mutex> lock(host->dialog_queue->mutex);
7429
+ host->dialog_queue->loop_window = nullptr;
7430
+ host->dialog_queue->finished.clear();
7431
+ }
7432
+
6655
7433
  } // namespace
6656
7434
 
6657
7435
  extern "C" {
@@ -6682,6 +7460,20 @@ Host *native_sdk_windows_create(const char *app_name, size_t app_name_len, const
6682
7460
  * RPC_E_CHANGED_MODE (an MTA got there first) leaves nothing to
6683
7461
  * balance. */
6684
7462
  host->com_initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE));
7463
+ /* The loop window exists before any app window and for the host's
7464
+ * whole life, so wakes and timers have a target from the start. */
7465
+ HWND loop_window = createLoopWindow(host);
7466
+ if (!loop_window) {
7467
+ const bool com_initialized = host->com_initialized;
7468
+ delete host;
7469
+ if (com_initialized) CoUninitialize();
7470
+ return nullptr;
7471
+ }
7472
+ host->loop_window.store(loop_window, std::memory_order_release);
7473
+ {
7474
+ std::lock_guard<std::mutex> lock(host->dialog_queue->mutex);
7475
+ host->dialog_queue->loop_window = loop_window;
7476
+ }
6685
7477
  /* One hardware Direct2D/DirectWrite resource domain for every canvas
6686
7478
  * child. Creation also requires the in-memory font and constrained
6687
7479
  * fallback seams: without them packet text cannot match engine layout,
@@ -6725,9 +7517,14 @@ Host *native_sdk_windows_create(const char *app_name, size_t app_name_len, const
6725
7517
  void native_sdk_windows_destroy(Host *host) {
6726
7518
  if (!host) return;
6727
7519
  audioCaptureStopAll(host);
6728
- std::shared_ptr<HostLifetime> lifetime = host->lifetime;
6729
- std::lock_guard<std::recursive_mutex> guard(lifetime->mutex);
6730
- lifetime->alive = false;
7520
+ /* Retire the loop window first: a wake or frame request from here on
7521
+ * finds no target and reports that, instead of posting into a queue
7522
+ * nobody will pump again. */
7523
+ HWND loop_window = host->loop_window.exchange(nullptr, std::memory_order_acq_rel);
7524
+ host->lifetime->alive = false;
7525
+ /* Dialog threads outlive the host: close their dialogs and let them
7526
+ * finish on their own; their results have nowhere to go. */
7527
+ cancelAllDialogs(host);
6731
7528
  /* DestroyWindow below dispatches WM_DESTROY/WM_ACTIVATEAPP
6732
7529
  * synchronously through windowProc; the run loop's handler state is
6733
7530
  * already gone by the time destroy is called, so those teardown
@@ -6747,6 +7544,7 @@ void native_sdk_windows_destroy(Host *host) {
6747
7544
  removeNotificationIcon(host);
6748
7545
  destroyAllWindows(host);
6749
7546
  gpuSurfaceDestroyFrameWakeTimer(host);
7547
+ if (loop_window) DestroyWindow(loop_window);
6750
7548
  const bool com_initialized = host->com_initialized;
6751
7549
  delete host;
6752
7550
  if (com_initialized) CoUninitialize();
@@ -6754,14 +7552,16 @@ void native_sdk_windows_destroy(Host *host) {
6754
7552
 
6755
7553
  void native_sdk_windows_run(Host *host, EventCallback callback, void *context) {
6756
7554
  if (!host) return;
6757
- host->callback = callback;
6758
- host->callback_context = context;
7555
+ host->runtime_callback = callback;
7556
+ host->runtime_context = context;
7557
+ host->callback = dispatchEventToRuntime;
7558
+ host->callback_context = host;
6759
7559
  host->running = true;
6760
7560
  if (!host->windows.empty()) createNativeWindow(host, host->windows.begin()->second);
6761
7561
  WindowsEvent start = {};
6762
7562
  start.kind = kStart;
6763
7563
  start.window_id = 1;
6764
- callback(context, &start);
7564
+ host->callback(host->callback_context, &start);
6765
7565
  emitAppearanceIfChanged(host, true);
6766
7566
  for (auto &entry : host->windows) {
6767
7567
  emit(host, entry.second, kResize);
@@ -6786,10 +7586,7 @@ void native_sdk_windows_run(Host *host, EventCallback callback, void *context) {
6786
7586
  DispatchMessageW(&message);
6787
7587
  if (host->running) gpuSurfaceDrainDueFrameEmissions(host);
6788
7588
  }
6789
- WindowsEvent shutdown = {};
6790
- shutdown.kind = kShutdown;
6791
- shutdown.window_id = 1;
6792
- callback(context, &shutdown);
7589
+ deliverShutdown(host);
6793
7590
  }
6794
7591
 
6795
7592
  void native_sdk_windows_stop(Host *host) {
@@ -6798,30 +7595,30 @@ void native_sdk_windows_stop(Host *host) {
6798
7595
  PostQuitMessage(0);
6799
7596
  }
6800
7597
 
6801
- /* Thread-safe wake: posts kWakeMessage into the message loop, which
6802
- * emits the kWake event on the loop thread. The lifetime mutex guards
6803
- * the window map against concurrent create/destroy. */
6804
- void native_sdk_windows_wake(Host *host) {
6805
- if (!host) return;
6806
- std::shared_ptr<HostLifetime> lifetime = host->lifetime;
6807
- std::lock_guard<std::recursive_mutex> guard(lifetime->mutex);
6808
- if (!lifetime->alive || !host->running) return;
6809
- HWND hwnd = parentWindow(host);
6810
- if (!hwnd) return;
6811
- PostMessageW(hwnd, kWakeMessage, 0, 0);
7598
+ /* Thread-safe wake: posts kWakeMessage to the loop window, which emits
7599
+ * the kWake event on the loop thread. Enqueue-only, as the wake contract
7600
+ * requires (PlatformServices.wake_fn): it takes no lock the loop thread
7601
+ * could be holding, so a thread the loop is joining can always finish
7602
+ * its wake. The host itself outlives every caller — the runtime fences
7603
+ * its wake paths before teardown. Returns 0 when nothing was posted (the
7604
+ * host is being destroyed, or the thread's queue is full). */
7605
+ int native_sdk_windows_wake(Host *host) {
7606
+ if (!host) return 0;
7607
+ HWND target = host->loop_window.load(std::memory_order_acquire);
7608
+ return target && PostMessageW(target, kWakeMessage, 0, 0) ? 1 : 0;
6812
7609
  }
6813
7610
 
6814
- /* Thread-safe frame request: posts kRequestFrameMessage into the message
6815
- * loop, which emits one kFrame event on the loop thread — the automation
6816
- * arrival watcher's wake, guarded exactly like native_sdk_windows_wake. */
6817
- void native_sdk_windows_request_frame(Host *host) {
6818
- if (!host) return;
6819
- std::shared_ptr<HostLifetime> lifetime = host->lifetime;
6820
- std::lock_guard<std::recursive_mutex> guard(lifetime->mutex);
6821
- if (!lifetime->alive || !host->running) return;
6822
- HWND hwnd = parentWindow(host);
6823
- if (!hwnd) return;
6824
- PostMessageW(hwnd, kRequestFrameMessage, 0, 0);
7611
+ /* Thread-safe frame request: posts kRequestFrameMessage to the loop
7612
+ * window, which emits one kFrame event on the loop thread — the
7613
+ * automation arrival watcher's wake, enqueue-only like
7614
+ * native_sdk_windows_wake. Requests coalesce while one is queued. */
7615
+ int native_sdk_windows_request_frame(Host *host) {
7616
+ if (!host) return 0;
7617
+ if (host->frame_request_pending.exchange(true, std::memory_order_acq_rel)) return 1;
7618
+ HWND target = host->loop_window.load(std::memory_order_acquire);
7619
+ if (target && PostMessageW(target, kRequestFrameMessage, 0, 0)) return 1;
7620
+ host->frame_request_pending.store(false, std::memory_order_release);
7621
+ return 0;
6825
7622
  }
6826
7623
 
6827
7624
  /* Platform image decoder: WIC (Windows Imaging Component) handles PNG,
@@ -7167,7 +7964,7 @@ int native_sdk_windows_create_window(Host *host, uint64_t window_id, const char
7167
7964
  void native_sdk_windows_start_timer(Host *host, uint64_t timer_id, uint64_t interval_ns, int repeats) {
7168
7965
  if (!host) return;
7169
7966
  native_sdk_windows_cancel_timer(host, timer_id);
7170
- HWND hwnd = parentWindow(host);
7967
+ HWND hwnd = loopWindow(host);
7171
7968
  if (!hwnd) return;
7172
7969
  AppTimer *slot = nullptr;
7173
7970
  UINT_PTR win32_id = 0;
@@ -7932,167 +8729,154 @@ int native_sdk_windows_close_view(Host *host, uint64_t window_id, const char *la
7932
8729
 
7933
8730
  WindowsOpenDialogResult native_sdk_windows_show_open_dialog(Host *host, const WindowsOpenDialogOpts *opts, char *buffer, size_t buffer_len) {
7934
8731
  WindowsOpenDialogResult result = {};
7935
- if (!host || !opts || !buffer || buffer_len == 0) return result;
8732
+ /* A session end is unwinding the app's dispatch (endSession): a new
8733
+ * dialog would hold it again, so it answers as cancelled. */
8734
+ if (!host || !opts || !buffer || buffer_len == 0 || host->session_ending) return result;
7936
8735
  bool uninitialize = false;
7937
8736
  if (!initializeCom(&uninitialize)) return result;
7938
8737
 
7939
- IFileOpenDialog *dialog = nullptr;
7940
- HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog));
7941
- if (FAILED(hr) || !dialog) {
7942
- finishCom(uninitialize);
7943
- return result;
7944
- }
7945
-
7946
- DWORD options = 0;
7947
- if (SUCCEEDED(dialog->GetOptions(&options))) {
7948
- options |= FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST;
7949
- if (opts->allow_directories) options |= FOS_PICKFOLDERS;
7950
- else options |= FOS_FILEMUSTEXIST;
7951
- if (opts->allow_multiple) options |= FOS_ALLOWMULTISELECT;
7952
- dialog->SetOptions(options);
7953
- }
7954
- setDialogTitle(dialog, opts->title, opts->title_len);
7955
- setDialogFolder(dialog, opts->default_path, opts->default_path_len);
7956
- if (!opts->allow_directories) setDialogFilters(dialog, opts->extensions, opts->extensions_len);
7957
-
7958
- if (SUCCEEDED(dialog->Show(parentWindow(host)))) {
7959
- IShellItemArray *items = nullptr;
7960
- if (SUCCEEDED(dialog->GetResults(&items)) && items) {
7961
- DWORD count = 0;
7962
- items->GetCount(&count);
7963
- size_t offset = 0;
7964
- size_t written_count = 0;
7965
- for (DWORD index = 0; index < count; ++index) {
7966
- IShellItem *item = nullptr;
7967
- if (SUCCEEDED(items->GetItemAt(index, &item)) && item) {
7968
- bool overflow = false;
7969
- PWSTR path = nullptr;
7970
- if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path)) && path) {
7971
- overflow = !appendPathToBuffer(buffer, buffer_len, &offset, &written_count, std::wstring(path));
7972
- CoTaskMemFree(path);
7973
- }
7974
- item->Release();
7975
- if (overflow) break;
7976
- }
7977
- }
7978
- result.count = written_count;
7979
- result.bytes_written = offset;
7980
- items->Release();
8738
+ std::vector<std::wstring> paths;
8739
+ if (SUCCEEDED(showOpenDialogOn(parentWindow(host), openDialogSpec(opts), &paths))) {
8740
+ size_t offset = 0;
8741
+ size_t written_count = 0;
8742
+ for (const std::wstring &path : paths) {
8743
+ if (!appendPathToBuffer(buffer, buffer_len, &offset, &written_count, path)) break;
7981
8744
  }
8745
+ result.count = written_count;
8746
+ result.bytes_written = offset;
7982
8747
  }
7983
8748
 
7984
- dialog->Release();
7985
8749
  finishCom(uninitialize);
7986
8750
  return result;
7987
8751
  }
7988
8752
 
7989
8753
  size_t native_sdk_windows_show_save_dialog(Host *host, const WindowsSaveDialogOpts *opts, char *buffer, size_t buffer_len) {
7990
- if (!host || !opts || !buffer || buffer_len == 0) return 0;
8754
+ /* Cancelled during a session end, like the open dialog. */
8755
+ if (!host || !opts || !buffer || buffer_len == 0 || host->session_ending) return 0;
7991
8756
  bool uninitialize = false;
7992
8757
  if (!initializeCom(&uninitialize)) return 0;
7993
8758
 
7994
- IFileSaveDialog *dialog = nullptr;
7995
- HRESULT hr = CoCreateInstance(CLSID_FileSaveDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog));
7996
- if (FAILED(hr) || !dialog) {
7997
- finishCom(uninitialize);
7998
- return 0;
7999
- }
8000
-
8001
- DWORD options = 0;
8002
- if (SUCCEEDED(dialog->GetOptions(&options))) dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
8003
- setDialogTitle(dialog, opts->title, opts->title_len);
8004
- setDialogFolder(dialog, opts->default_path, opts->default_path_len);
8005
- setDialogFilters(dialog, opts->extensions, opts->extensions_len);
8006
- std::wstring default_name = widen(slice(opts->default_name, opts->default_name_len));
8007
- if (!default_name.empty()) dialog->SetFileName(default_name.c_str());
8008
-
8009
8759
  size_t written = 0;
8010
- if (SUCCEEDED(dialog->Show(parentWindow(host)))) {
8011
- IShellItem *item = nullptr;
8012
- if (SUCCEEDED(dialog->GetResult(&item)) && item) {
8013
- PWSTR path = nullptr;
8014
- if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path)) && path) {
8015
- std::string utf8_path = narrow(std::wstring(path));
8016
- if (utf8_path.size() > buffer_len) {
8017
- written = overflowSize(buffer_len);
8018
- } else {
8019
- written = utf8_path.size();
8020
- if (written > 0) memcpy(buffer, utf8_path.data(), written);
8021
- }
8022
- CoTaskMemFree(path);
8023
- }
8024
- item->Release();
8760
+ std::wstring path;
8761
+ if (SUCCEEDED(showSaveDialogOn(parentWindow(host), saveDialogSpec(opts), &path)) && !path.empty()) {
8762
+ std::string utf8_path = narrow(path);
8763
+ if (utf8_path.size() > buffer_len) {
8764
+ written = overflowSize(buffer_len);
8765
+ } else {
8766
+ written = utf8_path.size();
8767
+ if (written > 0) memcpy(buffer, utf8_path.data(), written);
8025
8768
  }
8026
8769
  }
8027
8770
 
8028
- dialog->Release();
8029
8771
  finishCom(uninitialize);
8030
8772
  return written;
8031
8773
  }
8032
8774
 
8775
+ /* Returns the pressed button (0 primary, 1 secondary, 2 tertiary), or -1
8776
+ * when a session end left the dialog unanswered: it was cancelled to let
8777
+ * the app's dispatch unwind (endSession), or never shown because one is
8778
+ * unwinding. Closing a task dialog otherwise reports the primary button,
8779
+ * which must not stand in for an answer nobody gave. */
8033
8780
  int native_sdk_windows_show_message_dialog(Host *host, const WindowsMessageDialogOpts *opts) {
8034
8781
  if (!host || !opts) return 0;
8035
- std::wstring title = widen(slice(opts->title, opts->title_len));
8036
- std::wstring message = widen(slice(opts->message, opts->message_len));
8037
- std::wstring informative = widen(slice(opts->informative_text, opts->informative_text_len));
8038
- std::wstring primary = widen(slice(opts->primary_button, opts->primary_button_len));
8039
- std::wstring secondary = widen(slice(opts->secondary_button, opts->secondary_button_len));
8040
- std::wstring tertiary = widen(slice(opts->tertiary_button, opts->tertiary_button_len));
8041
- if (primary.empty()) primary = L"OK";
8042
-
8043
- TASKDIALOG_BUTTON buttons[3] = {};
8044
- int button_count = 0;
8045
- buttons[button_count++] = { 100, primary.c_str() };
8046
- if (!secondary.empty()) buttons[button_count++] = { 101, secondary.c_str() };
8047
- if (!tertiary.empty()) buttons[button_count++] = { 102, tertiary.c_str() };
8048
-
8049
- TASKDIALOGCONFIG config = {};
8050
- config.cbSize = sizeof(config);
8051
- config.hwndParent = parentWindow(host);
8052
- config.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION;
8053
- config.pszWindowTitle = title.empty() ? L"native-sdk" : title.c_str();
8054
- config.pszMainInstruction = message.empty() ? config.pszWindowTitle : message.c_str();
8055
- config.pszContent = informative.empty() ? nullptr : informative.c_str();
8056
- config.cButtons = static_cast<UINT>(button_count);
8057
- config.pButtons = buttons;
8058
- config.nDefaultButton = 100;
8059
- config.pszMainIcon = opts->style == 2 ? TD_ERROR_ICON : (opts->style == 1 ? TD_WARNING_ICON : TD_INFORMATION_ICON);
8060
-
8061
- /* TaskDialogIndirect is a comctl32 v6 export: it only resolves when
8062
- * the process activates the v6 side-by-side assembly (application
8063
- * manifest). A static import would abort the whole process at load
8064
- * time with STATUS_ENTRYPOINT_NOT_FOUND on the system default v5, so
8065
- * resolve it dynamically and fall back to MessageBoxW (fixed button
8066
- * captions, same 0/1/2 result contract) when only v5 is available. */
8067
- using TaskDialogIndirectFn = HRESULT(WINAPI *)(const TASKDIALOGCONFIG *, int *, int *, BOOL *);
8068
- static TaskDialogIndirectFn task_dialog = reinterpret_cast<TaskDialogIndirectFn>(
8069
- reinterpret_cast<void *>(GetProcAddress(GetModuleHandleW(L"comctl32.dll"), "TaskDialogIndirect")));
8070
- if (task_dialog) {
8782
+ if (host->session_ending) return -1;
8783
+ const DialogRequest spec = messageDialogSpec(opts);
8784
+ if (TaskDialogIndirectFn task_dialog = taskDialogIndirect()) {
8071
8785
  int pressed = 100;
8072
- HRESULT hr = task_dialog(&config, &pressed, nullptr, nullptr);
8786
+ HRESULT hr = runTaskDialog(task_dialog, parentWindow(host), spec, &pressed);
8787
+ if (host->session_ending) return -1;
8073
8788
  if (FAILED(hr)) return 0;
8074
8789
  if (pressed == 101) return 1;
8075
8790
  if (pressed == 102) return 2;
8076
8791
  return 0;
8077
8792
  }
8078
-
8079
- UINT type = MB_OK;
8080
- if (button_count == 2) type = MB_OKCANCEL;
8081
- if (button_count == 3) type = MB_YESNOCANCEL;
8082
- type |= opts->style == 2 ? MB_ICONERROR : (opts->style == 1 ? MB_ICONWARNING : MB_ICONINFORMATION);
8083
- std::wstring text = message;
8084
- if (!informative.empty()) {
8085
- if (!text.empty()) text += L"\n\n";
8086
- text += informative;
8793
+ const int result = runMessageBox(parentWindow(host), spec);
8794
+ if (host->session_ending) return -1;
8795
+ return messageBoxButton(spec, result);
8796
+ }
8797
+
8798
+ /* Show a dialog without blocking: it runs on a thread of its own and
8799
+ * reports through native_sdk_windows_poll_dialog_result after a wake.
8800
+ * The owner is the request's window unless something else already
8801
+ * disabled it (another modal on it), as Electron does: this dialog would
8802
+ * otherwise re-enable a window still held by that one when it closes.
8803
+ * Returns 0 when nothing was started. */
8804
+ int native_sdk_windows_request_dialog(Host *host, const WindowsDialogRequest *request) {
8805
+ if (!host || !request || request->id == 0 || host->session_ending || host->shutdown_delivered) return 0;
8806
+ if (host->dialogs.count(request->id) != 0) return 0;
8807
+ auto dialog = std::make_shared<DialogRequest>();
8808
+ switch (request->kind) {
8809
+ case kDialogOpen:
8810
+ if (!request->open_opts) return 0;
8811
+ *dialog = openDialogSpec(request->open_opts);
8812
+ break;
8813
+ case kDialogSave:
8814
+ if (!request->save_opts) return 0;
8815
+ *dialog = saveDialogSpec(request->save_opts);
8816
+ break;
8817
+ case kDialogMessage:
8818
+ if (!request->message_opts) return 0;
8819
+ *dialog = messageDialogSpec(request->message_opts);
8820
+ break;
8821
+ default:
8822
+ return 0;
8087
8823
  }
8088
- const int result = MessageBoxW(parentWindow(host), text.c_str(), title.empty() ? L"native-sdk" : title.c_str(), type);
8089
- if (button_count == 3) {
8090
- if (result == IDNO) return 1;
8091
- if (result == IDCANCEL) return 2;
8824
+ dialog->id = request->id;
8825
+ auto window = host->windows.find(request->parent_window_id);
8826
+ if (request->parent_window_id != 0 && window != host->windows.end() && window->second.hwnd && IsWindowEnabled(window->second.hwnd)) {
8827
+ dialog->owner = window->second.hwnd;
8828
+ dialog->parent_window_id = request->parent_window_id;
8829
+ }
8830
+ try {
8831
+ std::thread(runDialogThread, host->dialog_queue, dialog).detach();
8832
+ } catch (...) {
8092
8833
  return 0;
8093
8834
  }
8094
- if (result == IDCANCEL) return 1;
8095
- return 0;
8835
+ host->dialogs[dialog->id] = dialog;
8836
+ return 1;
8837
+ }
8838
+
8839
+ void native_sdk_windows_cancel_dialog(Host *host, uint64_t id) {
8840
+ if (!host) return;
8841
+ auto found = host->dialogs.find(id);
8842
+ if (found != host->dialogs.end()) cancelDialog(host, *found->second);
8843
+ }
8844
+
8845
+ /* Hand back the oldest finished dialog. Returns 0 when none is waiting. */
8846
+ int native_sdk_windows_poll_dialog_result(Host *host, char *buffer, size_t buffer_len, WindowsDialogResult *result) {
8847
+ if (!host || !result) return 0;
8848
+ std::shared_ptr<DialogRequest> dialog;
8849
+ {
8850
+ std::lock_guard<std::mutex> lock(host->dialog_queue->mutex);
8851
+ if (host->dialog_queue->finished.empty()) return 0;
8852
+ dialog = host->dialog_queue->finished.front();
8853
+ host->dialog_queue->finished.pop_front();
8854
+ }
8855
+ host->dialogs.erase(dialog->id);
8856
+ *result = {};
8857
+ result->id = dialog->id;
8858
+ result->outcome = dialog->outcome;
8859
+ result->button = dialog->button;
8860
+ size_t offset = 0;
8861
+ size_t count = 0;
8862
+ for (const std::string &path : dialog->paths) {
8863
+ const size_t needed = path.size() + (count > 0 ? 1 : 0);
8864
+ if (!buffer || needed > buffer_len - offset) {
8865
+ /* More paths than the runtime holds: a failure, not a
8866
+ * silently shortened selection. */
8867
+ result->outcome = kDialogFailed;
8868
+ offset = 0;
8869
+ count = 0;
8870
+ break;
8871
+ }
8872
+ if (count > 0) buffer[offset++] = '\n';
8873
+ memcpy(buffer + offset, path.data(), path.size());
8874
+ offset += path.size();
8875
+ count += 1;
8876
+ }
8877
+ result->count = count;
8878
+ result->bytes_written = offset;
8879
+ return 1;
8096
8880
  }
8097
8881
 
8098
8882
  int native_sdk_windows_show_notification(Host *host, const char *title, size_t title_len, const char *subtitle, size_t subtitle_len, const char *body, size_t body_len, const char *notification_id, size_t notification_id_len, const char *action_label, size_t action_label_len, const char *action_command, size_t action_command_len) {