@zogien/native-sdk 0.11.0-fork.3 → 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 (67) hide show
  1. package/README.md +1 -1
  2. package/build/app.zig +53 -34
  3. package/build/cli.zig +22 -0
  4. package/package.json +10 -10
  5. package/packages/core/package-lock.json +2 -2
  6. package/packages/core/package.json +1 -1
  7. package/schemas/app.schema.json +9 -1
  8. package/skill-data/core/references/bridge-security-native-capabilities.md +4 -0
  9. package/skill-data/ts-core/SKILL.md +2 -0
  10. package/src/app_runner/app_assets.zig +85 -1
  11. package/src/app_runner/root.zig +63 -3
  12. package/src/app_runner/ts_core_main.zig +8 -0
  13. package/src/platform/linux/cef_host.cpp +22 -0
  14. package/src/platform/linux/gtk_host.c +317 -34
  15. package/src/platform/linux/gtk_host.h +37 -0
  16. package/src/platform/linux/root.zig +114 -20
  17. package/src/platform/macos/appkit_host.h +53 -0
  18. package/src/platform/macos/appkit_host.m +667 -67
  19. package/src/platform/macos/cef_host.mm +21 -0
  20. package/src/platform/macos/root.zig +146 -29
  21. package/src/platform/null_platform.zig +136 -0
  22. package/src/platform/root.zig +7 -0
  23. package/src/platform/types.zig +90 -0
  24. package/src/platform/windows/dialog_test.cpp +303 -0
  25. package/src/platform/windows/host_loop_test.cpp +169 -0
  26. package/src/platform/windows/root.zig +262 -39
  27. package/src/platform/windows/session_end_test.cpp +303 -0
  28. package/src/platform/windows/webview2_host.cpp +1435 -306
  29. package/src/primitives/app_manifest/root.zig +1 -0
  30. package/src/primitives/app_manifest/tests.zig +35 -0
  31. package/src/primitives/app_manifest/types.zig +12 -0
  32. package/src/primitives/app_manifest/validation.zig +44 -6
  33. package/src/root.zig +8 -0
  34. package/src/runtime/builtin_bridge.zig +108 -13
  35. package/src/runtime/channel_sources.zig +49 -1
  36. package/src/runtime/core.zig +24 -3
  37. package/src/runtime/dialog_tests.zig +367 -0
  38. package/src/runtime/dialogs.zig +385 -0
  39. package/src/runtime/effects.zig +83 -3
  40. package/src/runtime/effects_host_tests.zig +69 -1
  41. package/src/runtime/flow.zig +20 -6
  42. package/src/runtime/flow_reference.zig +20 -6
  43. package/src/runtime/platform_bridge_tests.zig +1 -0
  44. package/src/runtime/root.zig +9 -0
  45. package/src/runtime/single_instance.zig +893 -0
  46. package/src/runtime/system_services.zig +64 -82
  47. package/src/runtime/ui_app.zig +67 -0
  48. package/src/tooling/manifest.zig +111 -3
  49. package/src/tooling/package.zig +98 -8
  50. package/src/tooling/raw_manifest.zig +7 -0
  51. package/src/tooling/root.zig +2 -0
  52. package/src/tooling/templates.zig +44 -2
  53. package/src/tooling/update.zig +228 -10
  54. package/src/tooling/windows_installer.zig +245 -0
  55. package/src/tooling/zip_writer.zig +211 -0
  56. package/src/updater/engine.zig +936 -0
  57. package/src/updater/feed.zig +261 -12
  58. package/src/updater/macos.zig +187 -0
  59. package/src/updater/root.zig +41 -0
  60. package/src/updater/windows.zig +470 -0
  61. package/src/windows_install/config.zig +247 -0
  62. package/src/windows_install/main.zig +690 -0
  63. package/src/windows_install/payload.zig +146 -0
  64. package/src/windows_install/switch.zig +184 -0
  65. package/src/windows_install/tests.zig +11 -0
  66. package/src/windows_install/win32.zig +248 -0
  67. package/tools/native-sdk/main.zig +10 -9
@@ -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
 
@@ -421,6 +447,9 @@ struct Window {
421
447
  /* The maximized state last reported to the runtime, so WM_SIZE emits
422
448
  * a frame event only when it changes. */
423
449
  bool maximized = false;
450
+ /* Same-thread child kept above content children across the top
451
+ * resize band (see syncTopResizeStrip); created on first need. */
452
+ HWND top_resize_strip = nullptr;
424
453
  };
425
454
 
426
455
  /* One rectangle of a canvas view's window-drag mirror (runtime push,
@@ -605,14 +634,70 @@ struct TrayItem {
605
634
  bool enabled = true;
606
635
  };
607
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. */
608
642
  struct HostLifetime {
609
- std::recursive_mutex mutex;
610
643
  bool alive = true;
611
644
  };
612
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
+
613
697
  /* App timers (runtime `startTimer`) on the Win32 message loop: each slot
614
698
  * owns the SetTimer id kAppTimerIdBase + slot index, scheduled on the
615
- * 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. */
616
701
  constexpr size_t kMaxAppTimers = 64;
617
702
  constexpr UINT_PTR kAppTimerIdBase = 0x1000;
618
703
  /* The 16 ms per-window frame-pump timer (SetTimer id on each top-level
@@ -680,11 +765,21 @@ struct AudioCaptureState {
680
765
  std::thread worker;
681
766
  };
682
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
+
683
779
  /* The app's single audio player (see the audio section further down for
684
780
  * the backend rationale). All fields are message-loop-thread state; the
685
- * lifetime mutex additionally guards `generation` and `source` because
686
- * the asynchronous URL source resolver hands its result over from a
687
- * Media Foundation worker thread. */
781
+ * asynchronous URL source resolver hands its result over through
782
+ * `resolve_mailbox` instead of writing here. */
688
783
  struct AudioState {
689
784
  /* Bumped on every load/stop: worker-thread stragglers (resolver
690
785
  * completions, retired-session events) carry the generation they
@@ -712,6 +807,7 @@ struct AudioState {
712
807
  IMFMediaSource *source = nullptr;
713
808
  IMFPresentationClock *clock = nullptr;
714
809
  std::shared_ptr<AudioDownloadCancel> download_cancel;
810
+ std::shared_ptr<AudioSourceMailbox> resolve_mailbox;
715
811
  };
716
812
 
717
813
  /* The one pending app context menu (menus are modal, so one at a time):
@@ -735,6 +831,38 @@ struct PendingContextMenu {
735
831
  std::vector<Item> items;
736
832
  };
737
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
+
738
866
  struct Host {
739
867
  bool window_drag_initialization_failed = false;
740
868
  uint64_t next_window_drag_generation = 1;
@@ -743,11 +871,32 @@ struct Host {
743
871
  std::string window_title;
744
872
  std::string bundle_id;
745
873
  std::string icon_path;
874
+ /* The WebView2 user data folder every environment in this host
875
+ * shares, resolved once on first use (webViewUserDataFolder). Empty
876
+ * means WebView2's own default. */
877
+ bool webview_user_data_folder_resolved = false;
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. */
746
881
  EventCallback callback = nullptr;
747
882
  void *callback_context = nullptr;
883
+ EventCallback runtime_callback = nullptr;
884
+ void *runtime_context = nullptr;
748
885
  BridgeCallback bridge_callback = nullptr;
749
886
  void *bridge_context = nullptr;
750
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;
751
900
  std::map<uint64_t, Window> windows;
752
901
  std::map<std::string, ChildWebView> webviews;
753
902
  std::shared_ptr<WindowsGpuRenderer> gpu_renderer;
@@ -788,9 +937,34 @@ struct Host {
788
937
  AudioCaptureState captures[2];
789
938
  PendingContextMenu context_menu;
790
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>();
791
963
  };
792
964
 
793
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);
794
968
  static void gpuSurfaceRefreshFrameWakeTimer(Host *host);
795
969
  static void gpuSurfaceDestroyFrameWakeTimer(Host *host);
796
970
 
@@ -857,6 +1031,13 @@ static HWND parentWindow(Host *host) {
857
1031
  return nullptr;
858
1032
  }
859
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
+
860
1041
  static void copyWideField(wchar_t *dest, size_t dest_len, const std::wstring &value) {
861
1042
  if (!dest || dest_len == 0) return;
862
1043
  size_t count = std::min(value.size(), dest_len - 1);
@@ -1171,8 +1352,57 @@ static std::string bridgeOriginForWebViewUrl(const ChildWebView &webview, const
1171
1352
  return isInternalAssetUrl(webview, url) ? assetOrigin(webview) : originForUrl(url);
1172
1353
  }
1173
1354
 
1355
+ /* The resources\ directory of a packaged Windows artifact: `native
1356
+ * package` stages the executable in bin\ and the app-relative asset tree
1357
+ * in resources\ beside it. Empty when the executable is not in a bin\
1358
+ * directory. */
1359
+ static const std::wstring &packagedResourcesDirectory() {
1360
+ static const std::wstring directory = [] {
1361
+ std::wstring path(MAX_PATH, L'\0');
1362
+ for (;;) {
1363
+ const DWORD length = GetModuleFileNameW(nullptr, path.data(), (DWORD)path.size());
1364
+ if (length == 0) return std::wstring();
1365
+ if (length < path.size()) {
1366
+ path.resize(length);
1367
+ break;
1368
+ }
1369
+ if (path.size() >= 32768) return std::wstring();
1370
+ path.resize(path.size() * 2);
1371
+ }
1372
+ const size_t exe_slash = path.find_last_of(L"\\/");
1373
+ if (exe_slash == std::wstring::npos) return std::wstring();
1374
+ const std::wstring bin = path.substr(0, exe_slash);
1375
+ const size_t bin_slash = bin.find_last_of(L"\\/");
1376
+ if (bin_slash == std::wstring::npos || CompareStringOrdinal(bin.c_str() + bin_slash + 1, -1, L"bin", -1, TRUE) != CSTR_EQUAL) return std::wstring();
1377
+ return bin.substr(0, bin_slash) + L"\\resources";
1378
+ }();
1379
+ return directory;
1380
+ }
1381
+
1382
+ /* A relative WebView asset root names a directory of the packaged asset
1383
+ * tree when the app runs from a package, the tree the macOS host finds
1384
+ * in its bundle's Resources. In development it stays relative to the
1385
+ * working directory. */
1386
+ static std::string assetRootPath(const std::string &root) {
1387
+ const bool absolute = !root.empty() && (root[0] == '\\' || root[0] == '/' || (root.size() > 1 && root[1] == ':'));
1388
+ if (!absolute) {
1389
+ const std::wstring &resources = packagedResourcesDirectory();
1390
+ if (!resources.empty()) {
1391
+ std::wstring candidate = resources;
1392
+ if (!root.empty() && root != ".") {
1393
+ std::wstring relative = widen(root);
1394
+ std::replace(relative.begin(), relative.end(), L'/', L'\\');
1395
+ candidate += L"\\" + relative;
1396
+ }
1397
+ const DWORD attributes = GetFileAttributesW(candidate.c_str());
1398
+ if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) return narrow(candidate);
1399
+ }
1400
+ }
1401
+ return root.empty() ? std::string(".") : root;
1402
+ }
1403
+
1174
1404
  static std::wstring assetFilePath(const ChildWebView &webview, const std::string &relative) {
1175
- std::string path = webview.asset_root.empty() ? std::string(".") : webview.asset_root;
1405
+ std::string path = assetRootPath(webview.asset_root);
1176
1406
  if (!path.empty() && path.back() != '/' && path.back() != '\\') path.push_back('\\');
1177
1407
  std::string native_relative = relative;
1178
1408
  std::replace(native_relative.begin(), native_relative.end(), '/', '\\');
@@ -1710,7 +1940,10 @@ static void showTrayMenu(Host *host, HWND hwnd) {
1710
1940
  PostMessageW(hwnd, WM_NULL, 0, 0);
1711
1941
  }
1712
1942
 
1713
- 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) {
1714
1947
  if (!host || (host->shortcuts.empty() && host->menus.empty() && host->tray_items.empty())) return false;
1715
1948
  if (!window) return false;
1716
1949
  std::string key = shortcutKeyFromWParam(wparam);
@@ -1722,16 +1955,11 @@ static bool emitShortcutForWindow(Host *host, const Window *window, WPARAM wpara
1722
1955
  for (const Shortcut &shortcut : host->shortcuts) {
1723
1956
  if (shortcut.key != key) continue;
1724
1957
  if (!shortcutModifiersMatch(shortcut.modifiers, allow_implicit_shift)) continue;
1725
- if (!host->callback) return true;
1726
- WindowsEvent event = {};
1727
- event.kind = kShortcut;
1728
- event.window_id = window->id;
1729
- event.shortcut_id = shortcut.id.c_str();
1730
- event.shortcut_id_len = shortcut.id.size();
1731
- event.shortcut_key = shortcut.key.c_str();
1732
- event.shortcut_key_len = shortcut.key.size();
1733
- event.shortcut_modifiers = shortcut.modifiers;
1734
- 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;
1735
1963
  return true;
1736
1964
  }
1737
1965
  /* Menu accelerators dispatch from the same keydown path: a menu
@@ -1744,13 +1972,9 @@ static bool emitShortcutForWindow(Host *host, const Window *window, WPARAM wpara
1744
1972
  if (item.separator || !item.enabled || item.key.empty()) continue;
1745
1973
  if (item.key != key) continue;
1746
1974
  if (!shortcutModifiersMatch(item.modifiers, allow_implicit_shift)) continue;
1747
- if (!host->callback) return true;
1748
- WindowsEvent event = {};
1749
- event.kind = kMenuCommand;
1750
- event.window_id = window->id;
1751
- event.command_name = item.command.c_str();
1752
- event.command_name_len = item.command.size();
1753
- host->callback(host->callback_context, &event);
1975
+ out->kind = kMenuCommand;
1976
+ out->window_id = window->id;
1977
+ out->command = item.command;
1754
1978
  return true;
1755
1979
  }
1756
1980
  }
@@ -1761,18 +1985,145 @@ static bool emitShortcutForWindow(Host *host, const Window *window, WPARAM wpara
1761
1985
  if (item.separator || !item.enabled || item.key.empty()) continue;
1762
1986
  if (item.key != key) continue;
1763
1987
  if (!shortcutModifiersMatch(item.modifiers, allow_implicit_shift)) continue;
1764
- 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;
1765
1993
  }
1766
1994
  }
1767
1995
  return false;
1768
1996
  }
1769
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
+
1770
2026
  static bool emitShortcutForHwnd(Host *host, HWND hwnd, WPARAM wparam) {
1771
2027
  return emitShortcutForWindow(host, windowForHwnd(host, hwnd), wparam);
1772
2028
  }
1773
2029
 
1774
- static bool emitShortcutForWindowId(Host *host, uint64_t window_id, WPARAM wparam) {
1775
- 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;
1776
2127
  }
1777
2128
 
1778
2129
  static std::string webViewKey(uint64_t window_id, const std::string &label) {
@@ -2014,6 +2365,8 @@ static void applyNativeViewState(NativeView &view, bool update_text, const std::
2014
2365
  applyNativeViewAccessibility(view);
2015
2366
  }
2016
2367
 
2368
+ static void syncTopResizeStrip(Host *host, uint64_t window_id);
2369
+
2017
2370
  static void reorderWindowChildren(Host *host, uint64_t window_id) {
2018
2371
  if (!host) return;
2019
2372
  struct LayerItem {
@@ -2041,6 +2394,8 @@ static void reorderWindowChildren(Host *host, uint64_t window_id) {
2041
2394
  for (const LayerItem &item : items) {
2042
2395
  SetWindowPos(item.hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
2043
2396
  }
2397
+ /* Content layers never rise above the window's own resize band. */
2398
+ syncTopResizeStrip(host, window_id);
2044
2399
  }
2045
2400
 
2046
2401
  static bool emitNativeCommandForHwnd(Host *host, HWND hwnd, UINT notification_code) {
@@ -2328,6 +2683,25 @@ static bool windowIsChromeless(const Window &window) {
2328
2683
  return window.titlebar_style == 3;
2329
2684
  }
2330
2685
 
2686
+ /* Windows whose top resize frame lives INSIDE the client, answered by
2687
+ * the window's own WM_NCHITTEST and the top resize strip: the hidden
2688
+ * styles, and chromeless windows. On Windows 10 a caption-less frame
2689
+ * with any non-client top draws it as a blank white bar, so chromeless
2690
+ * keeps real left/right/bottom borders (resize and shadow) and hands
2691
+ * the client the top edge, the frame Tauri gives its undecorated
2692
+ * windows. Two chromeless shapes keep the plain popup frame: transparent
2693
+ * windows, whose per-pixel present seeds hit-testable resize bands
2694
+ * around the client, and windows carrying a menu bar, which needs the
2695
+ * non-client top. */
2696
+ static bool windowReclaimsTopFrame(const Window &window, bool has_menu) {
2697
+ if (windowUsesHiddenTitlebar(window)) return true;
2698
+ return windowIsChromeless(window) && !window.transparent && !has_menu;
2699
+ }
2700
+
2701
+ static bool windowReclaimsTopFrame(const Window &window) {
2702
+ return windowReclaimsTopFrame(window, window.hwnd && GetMenu(window.hwnd) != nullptr);
2703
+ }
2704
+
2331
2705
  static Window *hiddenTitlebarWindowForHwnd(Host *host, HWND hwnd) {
2332
2706
  if (!host || !hwnd) return nullptr;
2333
2707
  for (auto &entry : host->windows) {
@@ -2438,6 +2812,168 @@ static int hiddenFrameTopThickness(HWND hwnd) {
2438
2812
  return systemMetricForDpi(SM_CYSIZEFRAME, dpi) + systemMetricForDpi(SM_CXPADDEDBORDER, dpi);
2439
2813
  }
2440
2814
 
2815
+ /* The top resize band's hit code for a client point: `thickness` rows
2816
+ * tall, with corner slivers as wide as the side borders so the diagonal
2817
+ * grips survive at the very top; HTNOWHERE outside the band. */
2818
+ static constexpr LRESULT topResizeBandHitCode(int x, int y, int client_width, int thickness, int corner) {
2819
+ if (y < 0 || y >= thickness || x < 0 || x >= client_width) return HTNOWHERE;
2820
+ if (x < corner) return HTTOPLEFT;
2821
+ if (x >= client_width - corner) return HTTOPRIGHT;
2822
+ return HTTOP;
2823
+ }
2824
+
2825
+ static_assert(
2826
+ topResizeBandHitCode(3, 2, 1040, 8, 8) == HTTOPLEFT &&
2827
+ topResizeBandHitCode(8, 2, 1040, 8, 8) == HTTOP &&
2828
+ topResizeBandHitCode(1031, 7, 1040, 8, 8) == HTTOP &&
2829
+ topResizeBandHitCode(1032, 7, 1040, 8, 8) == HTTOPRIGHT &&
2830
+ topResizeBandHitCode(520, 8, 1040, 8, 8) == HTNOWHERE &&
2831
+ topResizeBandHitCode(520, -1, 1040, 8, 8) == HTNOWHERE,
2832
+ "the top band is exactly `thickness` rows tall with `corner`-wide diagonal grips");
2833
+
2834
+ /* The top resize band of a window that reclaims its top frame, in the
2835
+ * window's client coordinates. Only a restored resizable window has one:
2836
+ * a maximized window does not resize and a fixed one never does. Every
2837
+ * surface that answers for the band — the window itself, canvas children
2838
+ * handing it back, and the top resize strip — asks here. */
2839
+ static LRESULT topResizeBandHit(const Window &window, POINT client) {
2840
+ if (!window.hwnd || !windowReclaimsTopFrame(window)) return HTNOWHERE;
2841
+ if (!window.resizable || IsZoomed(window.hwnd) || IsIconic(window.hwnd)) return HTNOWHERE;
2842
+ RECT rect = {};
2843
+ GetClientRect(window.hwnd, &rect);
2844
+ const UINT dpi = dpiForWindow(window.hwnd);
2845
+ const int corner = systemMetricForDpi(SM_CXSIZEFRAME, dpi) + systemMetricForDpi(SM_CXPADDEDBORDER, dpi);
2846
+ return topResizeBandHitCode(client.x, client.y, rect.right - rect.left, hiddenFrameTopThickness(window.hwnd), corner);
2847
+ }
2848
+
2849
+ /* ------------------------------------------------------------------------
2850
+ * Top resize strip.
2851
+ *
2852
+ * A child covering the top band must hand it back to the window. Canvas
2853
+ * surfaces in hidden-titlebar windows do (they answer HTTRANSPARENT
2854
+ * there), but a WebView2 child belongs to the browser process and never
2855
+ * consults its parent, so under WebView content the band was only
2856
+ * resizable where WebView2's own drag-region windows happened to answer
2857
+ * it. The strip is a same-thread, unpainted child kept above every
2858
+ * content child across the band: it answers the window's band hit codes
2859
+ * itself (the cursor follows from them) and forwards the press to the
2860
+ * window, whose DefWindowProc runs the system size loop. Tauri recovers
2861
+ * the top edge of its undecorated windows the same way. WS_EX_TRANSPARENT
2862
+ * keeps the strip out of its siblings' visible regions: the WebView
2863
+ * containers clip siblings, and without it the band shows the window
2864
+ * background instead of the content under it. */
2865
+
2866
+ static Window *windowForTopResizeStrip(Host *host, HWND strip) {
2867
+ if (!host || !strip) return nullptr;
2868
+ for (auto &entry : host->windows) {
2869
+ if (entry.second.top_resize_strip == strip) return &entry.second;
2870
+ }
2871
+ return nullptr;
2872
+ }
2873
+
2874
+ static LRESULT CALLBACK topResizeStripProc(HWND strip, UINT message, WPARAM wparam, LPARAM lparam) {
2875
+ Host *host = reinterpret_cast<Host *>(GetWindowLongPtrW(strip, GWLP_USERDATA));
2876
+ switch (message) {
2877
+ case WM_NCHITTEST: {
2878
+ Window *window = windowForTopResizeStrip(host, strip);
2879
+ if (!window || !window->hwnd) return HTTRANSPARENT;
2880
+ POINT point = { (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam) };
2881
+ ScreenToClient(window->hwnd, &point);
2882
+ const LRESULT hit = topResizeBandHit(*window, point);
2883
+ return hit != HTNOWHERE ? hit : HTTRANSPARENT;
2884
+ }
2885
+ case WM_NCLBUTTONDOWN:
2886
+ case WM_NCLBUTTONDBLCLK:
2887
+ /* Same thread, so the window's own non-client handling runs
2888
+ * synchronously with the original screen point: the size loop
2889
+ * for a press, the vertical maximize for a double-click. */
2890
+ if (wparam == HTTOP || wparam == HTTOPLEFT || wparam == HTTOPRIGHT) {
2891
+ Window *window = windowForTopResizeStrip(host, strip);
2892
+ if (window && window->hwnd) return SendMessageW(window->hwnd, message, wparam, lparam);
2893
+ }
2894
+ break;
2895
+ case WM_ERASEBKGND:
2896
+ return 1;
2897
+ case WM_PAINT: {
2898
+ PAINTSTRUCT paint = {};
2899
+ BeginPaint(strip, &paint);
2900
+ EndPaint(strip, &paint);
2901
+ return 0;
2902
+ }
2903
+ default:
2904
+ break;
2905
+ }
2906
+ return DefWindowProcW(strip, message, wparam, lparam);
2907
+ }
2908
+
2909
+ static const wchar_t *topResizeStripClassName(Host *host) {
2910
+ static bool registered = false;
2911
+ if (!registered) {
2912
+ WNDCLASSEXW wc = {};
2913
+ wc.cbSize = sizeof(wc);
2914
+ wc.lpfnWndProc = topResizeStripProc;
2915
+ wc.hInstance = host->instance;
2916
+ wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
2917
+ wc.lpszClassName = L"NativeSdkTopResizeStrip";
2918
+ registered = RegisterClassExW(&wc) != 0;
2919
+ }
2920
+ return L"NativeSdkTopResizeStrip";
2921
+ }
2922
+
2923
+ /* Whether any WebView container reaches into the window's top band. */
2924
+ static bool webViewCoversTopBand(Host *host, const Window &window, int band) {
2925
+ RECT client = {};
2926
+ GetClientRect(window.hwnd, &client);
2927
+ const RECT band_rect = { 0, 0, client.right - client.left, band };
2928
+ for (auto &entry : host->webviews) {
2929
+ const ChildWebView &webview = entry.second;
2930
+ if (webview.window_id != window.id || !webview.hwnd) continue;
2931
+ if (!(GetWindowLongPtrW(webview.hwnd, GWL_STYLE) & WS_VISIBLE)) continue;
2932
+ RECT bounds = {};
2933
+ GetWindowRect(webview.hwnd, &bounds);
2934
+ POINT top_left = { bounds.left, bounds.top };
2935
+ POINT bottom_right = { bounds.right, bounds.bottom };
2936
+ ScreenToClient(window.hwnd, &top_left);
2937
+ ScreenToClient(window.hwnd, &bottom_right);
2938
+ const RECT local = { top_left.x, top_left.y, bottom_right.x, bottom_right.y };
2939
+ RECT overlap = {};
2940
+ if (IntersectRect(&overlap, &local, &band_rect)) return true;
2941
+ }
2942
+ return false;
2943
+ }
2944
+
2945
+ /* Show, size and raise the strip over the band of a restored resizable
2946
+ * window that reclaims its top frame; hide it otherwise. Chromeless
2947
+ * windows always take it: their content children answer the band as
2948
+ * client. Hidden-titlebar windows take it only while a WebView covers the
2949
+ * band, so canvas windows keep their own band and the DWM keeps first
2950
+ * claim on its caption buttons. Runs after every geometry, state, or
2951
+ * child z-order change. */
2952
+ static void syncTopResizeStrip(Host *host, uint64_t window_id) {
2953
+ if (!host) return;
2954
+ auto found = host->windows.find(window_id);
2955
+ if (found == host->windows.end() || !found->second.hwnd) return;
2956
+ Window &window = found->second;
2957
+ const int band = hiddenFrameTopThickness(window.hwnd);
2958
+ const bool wanted = windowReclaimsTopFrame(window) && window.resizable && !window.click_through &&
2959
+ !IsZoomed(window.hwnd) && !IsIconic(window.hwnd) &&
2960
+ (windowIsChromeless(window) || webViewCoversTopBand(host, window, band));
2961
+ if (!wanted) {
2962
+ if (window.top_resize_strip) ShowWindow(window.top_resize_strip, SW_HIDE);
2963
+ return;
2964
+ }
2965
+ if (!window.top_resize_strip) {
2966
+ HWND strip = CreateWindowExW(WS_EX_TRANSPARENT, topResizeStripClassName(host), L"", WS_CHILD | WS_CLIPSIBLINGS,
2967
+ 0, 0, 0, 0, window.hwnd, nullptr, host->instance, nullptr);
2968
+ if (!strip) return;
2969
+ SetWindowLongPtrW(strip, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(host));
2970
+ window.top_resize_strip = strip;
2971
+ }
2972
+ RECT client = {};
2973
+ GetClientRect(window.hwnd, &client);
2974
+ SetWindowPos(window.top_resize_strip, HWND_TOP, 0, 0, client.right - client.left, band, SWP_NOACTIVATE | SWP_SHOWWINDOW);
2975
+ }
2976
+
2441
2977
  /* Height of the standard caption band (frame + caption) at the window's
2442
2978
  * dpi — the DWM frame extension depth, and the chrome-channel top inset
2443
2979
  * fallback when the live button rects are unavailable. */
@@ -3448,7 +3984,7 @@ static LRESULT CALLBACK gpuSurfaceProc(HWND hwnd, UINT message, WPARAM wparam, L
3448
3984
  if (!chrome_window) break;
3449
3985
  POINT point = { (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam) };
3450
3986
  ScreenToClient(chrome_window->hwnd, &point);
3451
- if (chrome_window->resizable && !IsZoomed(chrome_window->hwnd) && point.y >= 0 && point.y < hiddenFrameTopThickness(chrome_window->hwnd)) return HTTRANSPARENT;
3987
+ if (topResizeBandHit(*chrome_window, point) != HTNOWHERE) return HTTRANSPARENT;
3452
3988
  RECT cluster = {};
3453
3989
  if (captionButtonsClientRect(chrome_window->hwnd, &cluster) && PtInRect(&cluster, point)) return HTTRANSPARENT;
3454
3990
  if (windowDragRegionHit(host, *chrome_window, point)) return HTTRANSPARENT;
@@ -3715,8 +4251,7 @@ constexpr WPARAM kAudioNoteBufferingStarted = 6;
3715
4251
  constexpr WPARAM kAudioNoteBufferingStopped = 7;
3716
4252
  constexpr WPARAM kAudioNoteError = 8;
3717
4253
 
3718
- /* Position tick on the first top-level window, outside the app-timer id
3719
- * 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
3720
4255
  * platform tick the same), so frame-clock scrubber interpolation
3721
4256
  * behaves identically across hosts. */
3722
4257
  constexpr UINT_PTR kAudioPositionTimerId = 0x3000;
@@ -3839,20 +4374,20 @@ struct AudioSessionEventForwarder final : public IMFAsyncCallback {
3839
4374
  }
3840
4375
  };
3841
4376
 
3842
- /* Completes an asynchronous URL source resolution (worker thread) and
3843
- * hands the media source to the loop thread under the host lifetime
3844
- * mutex; a stale generation — the playback was replaced or stopped
3845
- * 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. */
3846
4382
  struct AudioSourceResolveForwarder final : public IMFAsyncCallback {
3847
4383
  LONG refs = 1;
3848
4384
  IMFSourceResolver *resolver = nullptr;
3849
- Host *host = nullptr;
3850
- std::shared_ptr<HostLifetime> lifetime;
4385
+ std::shared_ptr<AudioSourceMailbox> mailbox;
3851
4386
  HWND hwnd = nullptr;
3852
4387
  uint64_t generation = 0;
3853
4388
 
3854
- AudioSourceResolveForwarder(IMFSourceResolver *resolver_in, Host *host_in, std::shared_ptr<HostLifetime> lifetime_in, HWND hwnd_in, uint64_t generation_in)
3855
- : 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) {
3856
4391
  resolver->AddRef();
3857
4392
  }
3858
4393
  ~AudioSourceResolveForwarder() { resolver->Release(); }
@@ -3884,23 +4419,24 @@ struct AudioSourceResolveForwarder final : public IMFAsyncCallback {
3884
4419
  IMFMediaSource *source = nullptr;
3885
4420
  if (SUCCEEDED(hr) && object) object->QueryInterface(kNativeSdkIID_IMFMediaSource, reinterpret_cast<void **>(&source));
3886
4421
  if (object) object->Release();
3887
- std::lock_guard<std::recursive_mutex> guard(lifetime->mutex);
3888
- const bool current = lifetime->alive && host->audio.active && host->audio.generation == generation;
3889
- if (!current) {
3890
- if (source) {
3891
- source->Shutdown();
3892
- 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;
3893
4431
  }
3894
- return S_OK;
3895
4432
  }
3896
- if (!source) {
3897
- PostMessageW(hwnd, kAudioSessionMessage, kAudioNoteSourceFailed, (LPARAM)generation);
3898
- return S_OK;
4433
+ if (!kept && source) {
4434
+ source->Shutdown();
4435
+ source->Release();
3899
4436
  }
3900
- /* Reference transferred; the loop thread attaches or (if it
3901
- * bumps the generation first) releases it in teardown. */
3902
- host->audio.source = source;
3903
- 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);
3904
4440
  return S_OK;
3905
4441
  }
3906
4442
  };
@@ -4074,7 +4610,7 @@ static void audioStopPositionTimer(Host *host) {
4074
4610
  static void audioStartPositionTimer(Host *host) {
4075
4611
  AudioState &audio = host->audio;
4076
4612
  if (audio.position_timer_armed) return;
4077
- HWND hwnd = parentWindow(host);
4613
+ HWND hwnd = loopWindow(host);
4078
4614
  if (!hwnd) return;
4079
4615
  if (SetTimer(hwnd, kAudioPositionTimerId, kAudioPositionIntervalMs, nullptr)) {
4080
4616
  audio.timer_hwnd = hwnd;
@@ -4471,7 +5007,7 @@ static void audioSpectrumStartCapture(Host *host) {
4471
5007
  AudioSpectrumState &spectrum = host->spectrum;
4472
5008
  if (spectrum.shared) return;
4473
5009
  if (!audioSpectrumSupported()) return;
4474
- HWND hwnd = parentWindow(host);
5010
+ HWND hwnd = loopWindow(host);
4475
5011
  if (!hwnd) return;
4476
5012
  spectrum.generation += 1;
4477
5013
  spectrum.shared = std::make_shared<AudioSpectrumShared>();
@@ -4657,7 +5193,6 @@ static void audioCaptureStopAll(Host *host) {
4657
5193
  * letting a straggler finish installs the cache entry the completed
4658
5194
  * play earned. */
4659
5195
  static void audioReleaseSession(Host *host, bool cancel_download) {
4660
- std::lock_guard<std::recursive_mutex> guard(host->lifetime->mutex);
4661
5196
  AudioState &audio = host->audio;
4662
5197
  audioStopPositionTimer(host);
4663
5198
  audioSpectrumStopCapture(host);
@@ -4684,6 +5219,22 @@ static void audioReleaseSession(Host *host, bool cancel_download) {
4684
5219
  audio.source->Release();
4685
5220
  audio.source = nullptr;
4686
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
+ }
4687
5238
  if (cancel_download && audio.download_cancel) audio.download_cancel->cancelled.store(true);
4688
5239
  const uint64_t generation = audio.generation;
4689
5240
  audio = AudioState{};
@@ -4698,7 +5249,7 @@ static bool audioAttachSession(Host *host) {
4698
5249
  AudioState &audio = host->audio;
4699
5250
  IMFMediaSource *source = audio.source;
4700
5251
  if (!source) return false;
4701
- HWND hwnd = parentWindow(host);
5252
+ HWND hwnd = loopWindow(host);
4702
5253
  if (!hwnd) return false;
4703
5254
  IMFMediaSession *session = nullptr;
4704
5255
  if (FAILED(MFCreateMediaSession(nullptr, &session)) || !session) return false;
@@ -4863,7 +5414,7 @@ static int audioLoadUrlInternal(Host *host, const std::string &url, const std::s
4863
5414
  }
4864
5415
  }
4865
5416
  if (!audioEnsureMediaFoundation()) return 2;
4866
- HWND hwnd = parentWindow(host);
5417
+ HWND hwnd = loopWindow(host);
4867
5418
  if (!hwnd) return 2;
4868
5419
  IMFSourceResolver *resolver = nullptr;
4869
5420
  if (FAILED(MFCreateSourceResolver(&resolver)) || !resolver) return 2;
@@ -4873,7 +5424,8 @@ static int audioLoadUrlInternal(Host *host, const std::string &url, const std::s
4873
5424
  /* A fresh stream has no bytes yet: buffering starts true and drops
4874
5425
  * when the session actually starts rolling. */
4875
5426
  audio.buffering = true;
4876
- 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);
4877
5429
  const HRESULT hr = resolver->BeginCreateObjectFromURL(widen(url).c_str(), MF_RESOLUTION_MEDIASOURCE, nullptr, nullptr, forwarder, nullptr);
4878
5430
  forwarder->Release();
4879
5431
  resolver->Release();
@@ -4928,16 +5480,26 @@ static void audioFailed(Host *host) {
4928
5480
  audioEmitReport(host, kAudioEventFailed, 0, 0, 0, 0);
4929
5481
  }
4930
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
+
4931
5494
  /* Distilled session notes, marshalled from Media Foundation worker
4932
5495
  * threads via PostMessage; loop thread. Stale generations (a replaced
4933
5496
  * or stopped playback's stragglers) are dropped here. */
4934
5497
  static void audioHandleSessionMessage(Host *host, WPARAM note, LPARAM generation) {
4935
- std::lock_guard<std::recursive_mutex> guard(host->lifetime->mutex);
4936
5498
  AudioState &audio = host->audio;
4937
5499
  if (!audio.active || (uint64_t)generation != audio.generation) return;
4938
5500
  switch (note) {
4939
5501
  case kAudioNoteSourceResolved:
4940
- if (!audioAttachSession(host)) audioFailed(host);
5502
+ if (!audioCollectResolvedSource(host) || !audioAttachSession(host)) audioFailed(host);
4941
5503
  break;
4942
5504
  case kAudioNoteSourceFailed:
4943
5505
  case kAudioNoteError:
@@ -5010,6 +5572,7 @@ static void destroyAllWindows(Host *host) {
5010
5572
  if (entry.second.hwnd) {
5011
5573
  DestroyWindow(entry.second.hwnd);
5012
5574
  entry.second.hwnd = nullptr;
5575
+ entry.second.top_resize_strip = nullptr;
5013
5576
  }
5014
5577
  }
5015
5578
  }
@@ -5335,13 +5898,90 @@ static CreateEnvironmentFn webView2Factory() {
5335
5898
  return reinterpret_cast<CreateEnvironmentFn>(GetProcAddress(loader, "CreateCoreWebView2EnvironmentWithOptions"));
5336
5899
  }
5337
5900
 
5901
+ static bool webViewDirectoryExists(const std::wstring &path) {
5902
+ const DWORD attributes = GetFileAttributesW(path.c_str());
5903
+ return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
5904
+ }
5905
+
5906
+ /* The folder WebView2 picks when none is given: "<exe>.WebView2" beside
5907
+ * the executable. Earlier hosts left their data there. */
5908
+ static std::wstring webViewLegacyUserDataFolder() {
5909
+ std::wstring path(MAX_PATH, L'\0');
5910
+ for (;;) {
5911
+ const DWORD length = GetModuleFileNameW(nullptr, path.data(), (DWORD)path.size());
5912
+ if (length == 0) return std::wstring();
5913
+ if (length < path.size()) {
5914
+ path.resize(length);
5915
+ return path + L".WebView2";
5916
+ }
5917
+ if (path.size() >= 32768) return std::wstring();
5918
+ path.resize(path.size() * 2);
5919
+ }
5920
+ }
5921
+
5922
+ /* WebView2 keeps cookies, storage and caches in its user data folder.
5923
+ * Its default sits beside the executable, which is read-only under
5924
+ * Program Files and is replaced together with the program files when
5925
+ * an installed app updates. Keep it with the app's other per-user data
5926
+ * instead: %LOCALAPPDATA%\<bundle id>\WebView2, next to the Data, Cache,
5927
+ * State and Logs folders app_dirs resolves from the same variable and
5928
+ * bundle id. A folder left beside the executable by an earlier host
5929
+ * moves there once. A folder on another volume cannot be renamed there,
5930
+ * so that install keeps its old folder; when the move fails for another
5931
+ * reason (another process still has the folder open) this launch keeps
5932
+ * the old folder and the next launch tries again. Either way no data is
5933
+ * lost. Without LOCALAPPDATA or a usable bundle id, WebView2 keeps its
5934
+ * default. */
5935
+ static const wchar_t *webViewUserDataFolder(Host *host) {
5936
+ if (host->webview_user_data_folder_resolved) {
5937
+ return host->webview_user_data_folder.empty() ? nullptr : host->webview_user_data_folder.c_str();
5938
+ }
5939
+ host->webview_user_data_folder_resolved = true;
5940
+ const std::string &bundle_id = host->bundle_id;
5941
+ if (bundle_id.empty() || bundle_id == "." || bundle_id == ".." ||
5942
+ bundle_id.find_first_of("\\/:*?\"<>|") != std::string::npos) return nullptr;
5943
+ for (unsigned char ch : bundle_id) {
5944
+ if (ch < 0x20) return nullptr;
5945
+ }
5946
+ const DWORD needed = GetEnvironmentVariableW(L"LOCALAPPDATA", nullptr, 0);
5947
+ if (needed <= 1) return nullptr;
5948
+ std::wstring local_app_data((size_t)needed, L'\0');
5949
+ const DWORD written = GetEnvironmentVariableW(L"LOCALAPPDATA", local_app_data.data(), needed);
5950
+ if (written == 0 || written >= needed) return nullptr;
5951
+ local_app_data.resize(written);
5952
+ while (!local_app_data.empty() && (local_app_data.back() == L'\\' || local_app_data.back() == L'/')) local_app_data.pop_back();
5953
+ if (local_app_data.empty()) return nullptr;
5954
+
5955
+ const std::wstring app_root = local_app_data + L"\\" + widen(bundle_id);
5956
+ const std::wstring folder = app_root + L"\\WebView2";
5957
+ SHCreateDirectoryExW(nullptr, app_root.c_str(), nullptr);
5958
+ if (!webViewDirectoryExists(app_root)) return nullptr;
5959
+
5960
+ if (!webViewDirectoryExists(folder)) {
5961
+ const std::wstring legacy = webViewLegacyUserDataFolder();
5962
+ if (!legacy.empty() && webViewDirectoryExists(legacy) && !MoveFileExW(legacy.c_str(), folder.c_str(), 0)) {
5963
+ const DWORD error = GetLastError();
5964
+ if (error != ERROR_NOT_SAME_DEVICE) {
5965
+ fprintf(stderr, "native-sdk: keeping WebView2 data in %s for this launch; moving it to %s failed (error %lu)\n",
5966
+ narrow(legacy).c_str(), narrow(folder).c_str(), static_cast<unsigned long>(error));
5967
+ }
5968
+ host->webview_user_data_folder = legacy;
5969
+ return host->webview_user_data_folder.c_str();
5970
+ }
5971
+ }
5972
+ host->webview_user_data_folder = folder;
5973
+ return host->webview_user_data_folder.c_str();
5974
+ }
5975
+
5338
5976
  static void cleanupPendingChildWebView(Host *host, const std::string &key) {
5339
5977
  if (!host) return;
5340
5978
  auto found = host->webviews.find(key);
5341
5979
  if (found == host->webviews.end()) return;
5980
+ const uint64_t window_id = found->second.window_id;
5342
5981
  if (found->second.controller) found->second.controller->Close();
5343
5982
  if (found->second.hwnd) DestroyWindow(found->second.hwnd);
5344
5983
  host->webviews.erase(found);
5984
+ syncTopResizeStrip(host, window_id);
5345
5985
  }
5346
5986
 
5347
5987
  static void failWindowDragInitialization(Host *host, const std::string &key, HRESULT result) {
@@ -5361,12 +6001,12 @@ static bool createChildWebView(Host *host, const std::string &key) {
5361
6001
  HWND parent = found->second.hwnd;
5362
6002
  const uint64_t generation = found->second.window_drag_generation;
5363
6003
  std::weak_ptr<HostLifetime> lifetime = host->lifetime;
5364
- HRESULT hr = factory(nullptr, nullptr, nullptr, Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
6004
+ HRESULT hr = factory(nullptr, webViewUserDataFolder(host), nullptr, Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
5365
6005
  [host, key, parent, lifetime, generation](HRESULT result, ICoreWebView2Environment *environment) -> HRESULT {
5366
6006
  auto token = lifetime.lock();
5367
6007
  if (!token) return S_OK;
5368
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5369
6008
  if (!token->alive) return S_OK;
6009
+ WebViewCallbackScope scope(host);
5370
6010
  auto found = host->webviews.find(key);
5371
6011
  if (found == host->webviews.end() || found->second.hwnd != parent || found->second.window_drag_generation != generation || !IsWindow(parent)) return S_OK;
5372
6012
  if (FAILED(result) || !environment) {
@@ -5382,11 +6022,11 @@ static bool createChildWebView(Host *host, const std::string &key) {
5382
6022
  if (controller) controller->Close();
5383
6023
  return S_OK;
5384
6024
  }
5385
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5386
6025
  if (!token->alive) {
5387
6026
  if (controller) controller->Close();
5388
6027
  return S_OK;
5389
6028
  }
6029
+ WebViewCallbackScope scope(host);
5390
6030
  auto found = host->webviews.find(key);
5391
6031
  if (found == host->webviews.end() || found->second.window_drag_generation != generation) {
5392
6032
  if (controller) controller->Close();
@@ -5413,16 +6053,16 @@ static bool createChildWebView(Host *host, const std::string &key) {
5413
6053
  [host, key, lifetime](ICoreWebView2Controller *, IUnknown *) -> HRESULT {
5414
6054
  auto token = lifetime.lock();
5415
6055
  if (!token) return S_OK;
5416
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5417
6056
  if (!token->alive || !host->callback) return S_OK;
6057
+ WebViewCallbackScope scope(host);
5418
6058
  auto focused = host->webviews.find(key);
5419
6059
  if (focused == host->webviews.end()) return S_OK;
5420
- WindowsEvent event = {};
5421
- event.kind = kViewFocused;
6060
+ DeferredWebViewEvent event;
6061
+ event.kind = DeferredWebViewEvent::Kind::Focus;
5422
6062
  event.window_id = focused->second.window_id;
5423
- event.view_label = focused->second.label.c_str();
5424
- event.view_label_len = focused->second.label.size();
5425
- host->callback(host->callback_context, &event);
6063
+ event.view_key = key;
6064
+ event.label = focused->second.label;
6065
+ queueWebViewEvent(host, std::move(event));
5426
6066
  return S_OK;
5427
6067
  }).Get(), &focus_token);
5428
6068
  if (found->second.webview) {
@@ -5451,8 +6091,8 @@ static bool createChildWebView(Host *host, const std::string &key) {
5451
6091
  [host, key, lifetime, drag_generation](HRESULT result, LPCWSTR) -> HRESULT {
5452
6092
  auto token = lifetime.lock();
5453
6093
  if (!token) return S_OK;
5454
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5455
6094
  if (!token->alive) return S_OK;
6095
+ WebViewCallbackScope scope(host);
5456
6096
  auto current = host->webviews.find(key);
5457
6097
  if (current == host->webviews.end() || current->second.window_drag_generation != drag_generation) return S_OK;
5458
6098
  if (FAILED(result)) {
@@ -5480,8 +6120,8 @@ static bool createChildWebView(Host *host, const std::string &key) {
5480
6120
  [host, key, bridge_window_id, bridge_label, lifetime](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT {
5481
6121
  auto token = lifetime.lock();
5482
6122
  if (!token) return S_OK;
5483
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5484
6123
  if (!token->alive || !args || !host->bridge_callback) return S_OK;
6124
+ WebViewCallbackScope scope(host);
5485
6125
 
5486
6126
  LPWSTR message_bytes = nullptr;
5487
6127
  if (FAILED(args->TryGetWebMessageAsString(&message_bytes)) || !message_bytes) return S_OK;
@@ -5501,15 +6141,14 @@ static bool createChildWebView(Host *host, const std::string &key) {
5501
6141
  : bridgeOriginForWebViewUrl(source_webview->second, source_url);
5502
6142
  }
5503
6143
 
5504
- host->bridge_callback(
5505
- host->bridge_context,
5506
- bridge_window_id,
5507
- bridge_label.c_str(),
5508
- bridge_label.size(),
5509
- message.c_str(),
5510
- message.size(),
5511
- origin.c_str(),
5512
- 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));
5513
6152
  return S_OK;
5514
6153
  }).Get(), &bridge_token);
5515
6154
  }
@@ -5519,16 +6158,21 @@ static bool createChildWebView(Host *host, const std::string &key) {
5519
6158
  [host, accelerator_window_id, lifetime](ICoreWebView2Controller *, ICoreWebView2AcceleratorKeyPressedEventArgs *args) -> HRESULT {
5520
6159
  auto token = lifetime.lock();
5521
6160
  if (!token) return S_OK;
5522
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5523
6161
  if (!token->alive || !args) return S_OK;
6162
+ WebViewCallbackScope scope(host);
5524
6163
  COREWEBVIEW2_KEY_EVENT_KIND kind = COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN;
5525
6164
  if (FAILED(args->get_KeyEventKind(&kind))) return S_OK;
5526
6165
  if (kind != COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN && kind != COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN) return S_OK;
5527
6166
  UINT virtual_key = 0;
5528
6167
  if (FAILED(args->get_VirtualKey(&virtual_key))) return S_OK;
5529
- if (emitShortcutForWindowId(host, accelerator_window_id, virtual_key)) {
5530
- args->put_Handled(TRUE);
5531
- }
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));
5532
6176
  return S_OK;
5533
6177
  }).Get(), &accelerator_token);
5534
6178
 
@@ -5538,8 +6182,8 @@ static bool createChildWebView(Host *host, const std::string &key) {
5538
6182
  [host, key, environment_ref, lifetime](ICoreWebView2 *, ICoreWebView2WebResourceRequestedEventArgs *args) -> HRESULT {
5539
6183
  auto token = lifetime.lock();
5540
6184
  if (!token) return S_OK;
5541
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5542
6185
  if (!token->alive || !args) return S_OK;
6186
+ WebViewCallbackScope scope(host);
5543
6187
  auto found = host->webviews.find(key);
5544
6188
  if (found == host->webviews.end()) return S_OK;
5545
6189
  ComPtr<ICoreWebView2WebResourceRequest> request;
@@ -5560,8 +6204,8 @@ static bool createChildWebView(Host *host, const std::string &key) {
5560
6204
  [host, key, lifetime](ICoreWebView2 *, ICoreWebView2NavigationStartingEventArgs *args) -> HRESULT {
5561
6205
  auto token = lifetime.lock();
5562
6206
  if (!token) return S_OK;
5563
- std::lock_guard<std::recursive_mutex> guard(token->mutex);
5564
6207
  if (!token->alive) return S_OK;
6208
+ WebViewCallbackScope scope(host);
5565
6209
  LPWSTR uri_bytes = nullptr;
5566
6210
  if (!args || FAILED(args->get_Uri(&uri_bytes))) return S_OK;
5567
6211
  std::wstring uri_wide = uri_bytes ? std::wstring(uri_bytes) : std::wstring();
@@ -5571,7 +6215,10 @@ static bool createChildWebView(Host *host, const std::string &key) {
5571
6215
  if (found != host->webviews.end() && isInternalAssetUrl(found->second, uri)) return S_OK;
5572
6216
  if (uri.empty() || uri.rfind("about:", 0) == 0 || policyListMatches(host->allowed_origins, uri)) return S_OK;
5573
6217
  if (host->external_link_action == 1 && policyListMatches(host->allowed_external_urls, uri)) {
5574
- 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));
5575
6222
  }
5576
6223
  args->put_Cancel(TRUE);
5577
6224
  return S_OK;
@@ -5758,6 +6405,145 @@ static void clampChromelessMaximizedFrame(Host *host, HWND hwnd, WINDOWPOS *pos)
5758
6405
  pos->flags &= ~(SWP_NOMOVE | SWP_NOSIZE);
5759
6406
  }
5760
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
+
5761
6547
  static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
5762
6548
  if (message == WM_NCCREATE) {
5763
6549
  auto *create = reinterpret_cast<CREATESTRUCTW *>(lparam);
@@ -5818,21 +6604,11 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
5818
6604
  if (def_hit != HTCLIENT) return def_hit;
5819
6605
  POINT point = { (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam) };
5820
6606
  ScreenToClient(hwnd, &point);
5821
- RECT client = {};
5822
- GetClientRect(hwnd, &client);
5823
6607
  /* Top resize band: the caption removal moved the top
5824
6608
  * frame INSIDE the client area, so hand its band back to
5825
- * the system — restored and resizable windows only (a
5826
- * maximized window does not resize, a fixed one never
5827
- * does). Corner slivers widen to the side borders so the
5828
- * diagonal grips survive at the very top. */
5829
- if (chrome_window->resizable && !IsZoomed(hwnd) && point.y >= 0 && point.y < hiddenFrameTopThickness(hwnd)) {
5830
- const UINT dpi = dpiForWindow(hwnd);
5831
- const int corner = systemMetricForDpi(SM_CXSIZEFRAME, dpi) + systemMetricForDpi(SM_CXPADDEDBORDER, dpi);
5832
- if (point.x < corner) return HTTOPLEFT;
5833
- if (point.x >= client.right - corner) return HTTOPRIGHT;
5834
- return HTTOP;
5835
- }
6609
+ * the system. */
6610
+ const LRESULT band_hit = topResizeBandHit(*chrome_window, point);
6611
+ if (band_hit != HTNOWHERE) return band_hit;
5836
6612
  /* Caption buttons when DwmDefWindowProc stayed silent
5837
6613
  * (composition off, older cores): DefWindowProc still
5838
6614
  * performs minimize/maximize/close for these hit codes,
@@ -5865,6 +6641,22 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
5865
6641
  break;
5866
6642
  }
5867
6643
  }
6644
+ /* Chromeless windows hand the top frame to the client (see
6645
+ * windowReclaimsTopFrame) while restored. A maximized window keeps
6646
+ * DefWindowProc's inset: its frame parks the borders offscreen around
6647
+ * the work area, and the inset is what lands the client exactly on
6648
+ * it. */
6649
+ if (message == WM_NCCALCSIZE && wparam) {
6650
+ Window *chromeless_window = chromelessWindowForHwnd(host, hwnd);
6651
+ if (chromeless_window && windowReclaimsTopFrame(*chromeless_window) && !IsZoomed(hwnd)) {
6652
+ NCCALCSIZE_PARAMS *params = reinterpret_cast<NCCALCSIZE_PARAMS *>(lparam);
6653
+ const LONG original_top = params->rgrc[0].top;
6654
+ const LRESULT def_result = DefWindowProcW(hwnd, WM_NCCALCSIZE, wparam, lparam);
6655
+ if (def_result != 0) return def_result;
6656
+ params->rgrc[0].top = original_top;
6657
+ return 0;
6658
+ }
6659
+ }
5868
6660
  /* Chromeless windows (WS_POPUP, no caption): DefWindowProc owns the
5869
6661
  * resize frame when one exists; the app's window-drag regions are
5870
6662
  * the only caption there is, so a client-area hit inside one
@@ -5877,33 +6669,13 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
5877
6669
  if (def_hit != HTCLIENT) return def_hit;
5878
6670
  POINT point = { (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam) };
5879
6671
  ScreenToClient(hwnd, &point);
6672
+ const LRESULT band_hit = topResizeBandHit(*chromeless_window, point);
6673
+ if (band_hit != HTNOWHERE) return band_hit;
5880
6674
  if (windowDragRegionHit(host, *chromeless_window, point)) return HTCAPTION;
5881
6675
  return HTCLIENT;
5882
6676
  }
5883
6677
  }
5884
6678
  switch (message) {
5885
- case kWakeMessage:
5886
- if (host) {
5887
- WindowsEvent wake = {};
5888
- wake.kind = kWake;
5889
- wake.window_id = 1;
5890
- if (host->callback) host->callback(host->callback_context, &wake);
5891
- }
5892
- return 0;
5893
- case kRequestFrameMessage:
5894
- if (host) {
5895
- WindowsEvent frame = {};
5896
- frame.kind = kFrame;
5897
- frame.window_id = 1;
5898
- if (host->callback) host->callback(host->callback_context, &frame);
5899
- }
5900
- return 0;
5901
- case kAudioSessionMessage:
5902
- if (host) audioHandleSessionMessage(host, wparam, lparam);
5903
- return 0;
5904
- case kAudioSpectrumMessage:
5905
- if (host) audioHandleSpectrumMessage(host, wparam);
5906
- return 0;
5907
6679
  case kShowContextMenuMessage:
5908
6680
  if (host) showAppContextMenu(host, hwnd);
5909
6681
  return 0;
@@ -5990,6 +6762,9 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
5990
6762
  applyWebViewFrame(main->second);
5991
6763
  }
5992
6764
  #endif
6765
+ /* Size, maximize/restore, minimize, and dpi changes
6766
+ * all land here. */
6767
+ syncTopResizeStrip(host, entry.first);
5993
6768
  emit(host, entry.second, kResize);
5994
6769
  }
5995
6770
  }
@@ -6084,8 +6859,6 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6084
6859
  }
6085
6860
  break;
6086
6861
  case WM_TIMER:
6087
- if (host && handleAppTimerMessage(host, wparam)) return 0;
6088
- if (host && handleAudioTimerMessage(host, wparam)) return 0;
6089
6862
  if (host && wparam == kFrameTimerId) {
6090
6863
  for (auto &entry : host->windows) {
6091
6864
  if (entry.second.hwnd == hwnd) {
@@ -6109,9 +6882,9 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6109
6882
  /* The declared floor is a CONTENT size in LOGICAL
6110
6883
  * points; scale to physical pixels at this window's
6111
6884
  * DPI, then convert to the outer track size for its
6112
- * current style. Hidden titlebar styles carry no top
6113
- * chrome (the custom WM_NCCALCSIZE hands the caption
6114
- * band to the client), so their conversion skips it
6885
+ * current style. Styles that reclaim their top frame
6886
+ * carry no top chrome (the custom WM_NCCALCSIZE hands
6887
+ * it to the client), so their conversion skips it
6115
6888
  * too. */
6116
6889
  const UINT dpi = dpiForWindow(hwnd);
6117
6890
  const double scale = (double)dpi / 96.0;
@@ -6124,7 +6897,7 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6124
6897
  adjustWindowRectForDpi(&frame, style, has_menu, ex_style, dpi);
6125
6898
  LONG outer_width = frame.right - frame.left;
6126
6899
  LONG outer_height = frame.bottom - frame.top;
6127
- if (windowUsesHiddenTitlebar(window)) {
6900
+ if (windowReclaimsTopFrame(window, has_menu)) {
6128
6901
  const SIZE outer = hiddenOuterSizeForContent(style, ex_style, has_menu, min_content_width, min_content_height, dpi);
6129
6902
  outer_width = outer.cx;
6130
6903
  outer_height = outer.cy;
@@ -6141,7 +6914,21 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6141
6914
  case WM_SYSCOLORCHANGE:
6142
6915
  if (host) emitAppearanceIfChanged(host, false);
6143
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;
6144
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;
6145
6932
  /* close_policy .hide: the user's close (caption X, Alt+F4)
6146
6933
  * hides the window instead of destroying it — the hwnd and
6147
6934
  * every view stay live, the app keeps running behind its
@@ -6180,9 +6967,11 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6180
6967
  if (host) {
6181
6968
  for (auto &entry : host->windows) {
6182
6969
  if (entry.second.hwnd == hwnd) {
6970
+ cancelDialogsForWindow(host, entry.first);
6183
6971
  destroyNativeViewsForWindow(host, entry.first);
6184
6972
  destroyChildWebViewsForWindow(host, entry.first);
6185
6973
  entry.second.hwnd = nullptr;
6974
+ entry.second.top_resize_strip = nullptr;
6186
6975
  /* Destroyed means closed, never hidden. */
6187
6976
  entry.second.policy_hidden = false;
6188
6977
  emit(host, entry.second, kWindowFrame);
@@ -6252,9 +7041,9 @@ static bool createNativeWindow(Host *host, Window &window) {
6252
7041
  * re-derives the frame if it lands on another monitor), then grow
6253
7042
  * it to the outer size for this style so the client rect lands at
6254
7043
  * the request. The menu bar is attached after creation, so account
6255
- * for it here when menus are declared. Hidden styles use the
6256
- * custom-calc shape (no top chrome) — plain adjustment would land
6257
- * their client one caption band tall. */
7044
+ * for it here when menus are declared. Styles that reclaim their top
7045
+ * frame use the custom-calc shape (no top chrome) — plain adjustment
7046
+ * would land their client one caption band (or top border) tall. */
6258
7047
  const bool has_menu = !host->menus.empty();
6259
7048
  const UINT dpi = systemDpi();
6260
7049
  const double scale = (double)dpi / 96.0;
@@ -6264,7 +7053,7 @@ static bool createNativeWindow(Host *host, Window &window) {
6264
7053
  adjustWindowRectForDpi(&frame, style, has_menu ? TRUE : FALSE, 0, dpi);
6265
7054
  LONG outer_width = frame.right - frame.left;
6266
7055
  LONG outer_height = frame.bottom - frame.top;
6267
- if (windowUsesHiddenTitlebar(window)) {
7056
+ if (windowReclaimsTopFrame(window, has_menu)) {
6268
7057
  const SIZE outer = hiddenOuterSizeForContent(style, 0, has_menu, content_width, content_height, dpi);
6269
7058
  outer_width = outer.cx;
6270
7059
  outer_height = outer.cy;
@@ -6296,13 +7085,14 @@ static bool createNativeWindow(Host *host, Window &window) {
6296
7085
  /* Before the first show, so a dark-scheme launch never flashes a
6297
7086
  * light caption. */
6298
7087
  applyStandardTitlebarColorScheme(window);
6299
- if (windowUsesHiddenTitlebar(window)) {
6300
- applyHiddenTitlebarFrame(window);
7088
+ if (windowUsesHiddenTitlebar(window)) applyHiddenTitlebarFrame(window);
7089
+ if (windowReclaimsTopFrame(window)) {
6301
7090
  /* The create-time WM_NCCALCSIZE ran before this window was
6302
7091
  * registered under its HWND, so the custom calc did not apply;
6303
7092
  * force one now that the map entry resolves. The callers keep
6304
7093
  * `window` referencing the stored map entry for exactly this. */
6305
7094
  SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
7095
+ syncTopResizeStrip(host, window.id);
6306
7096
  }
6307
7097
  /* Initialize WS_EX_LAYERED through the same per-pixel path used by
6308
7098
  * canvas presents. A layered HWND is otherwise not displayable until
@@ -6319,6 +7109,327 @@ static bool createNativeWindow(Host *host, Window &window) {
6319
7109
  return true;
6320
7110
  }
6321
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
+
6322
7433
  } // namespace
6323
7434
 
6324
7435
  extern "C" {
@@ -6349,6 +7460,20 @@ Host *native_sdk_windows_create(const char *app_name, size_t app_name_len, const
6349
7460
  * RPC_E_CHANGED_MODE (an MTA got there first) leaves nothing to
6350
7461
  * balance. */
6351
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
+ }
6352
7477
  /* One hardware Direct2D/DirectWrite resource domain for every canvas
6353
7478
  * child. Creation also requires the in-memory font and constrained
6354
7479
  * fallback seams: without them packet text cannot match engine layout,
@@ -6357,6 +7482,14 @@ Host *native_sdk_windows_create(const char *app_name, size_t app_name_len, const
6357
7482
  host->app_name = slice(app_name, app_name_len);
6358
7483
  host->window_title = slice(window_title, window_title_len);
6359
7484
  host->bundle_id = slice(bundle_id, bundle_id_len);
7485
+ /* The taskbar groups and pins windows by AppUserModelID. Name the
7486
+ * process by its bundle id, the id the installer's Start menu
7487
+ * shortcut carries, so the taskbar follows the app instead of the
7488
+ * path of the executable that happened to start it. */
7489
+ if (!host->bundle_id.empty()) {
7490
+ const std::wstring app_user_model_id = widen(host->bundle_id);
7491
+ SetCurrentProcessExplicitAppUserModelID(app_user_model_id.c_str());
7492
+ }
6360
7493
  host->icon_path = slice(icon_path, icon_path_len);
6361
7494
  Window window;
6362
7495
  window.id = 1;
@@ -6384,9 +7517,14 @@ Host *native_sdk_windows_create(const char *app_name, size_t app_name_len, const
6384
7517
  void native_sdk_windows_destroy(Host *host) {
6385
7518
  if (!host) return;
6386
7519
  audioCaptureStopAll(host);
6387
- std::shared_ptr<HostLifetime> lifetime = host->lifetime;
6388
- std::lock_guard<std::recursive_mutex> guard(lifetime->mutex);
6389
- 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);
6390
7528
  /* DestroyWindow below dispatches WM_DESTROY/WM_ACTIVATEAPP
6391
7529
  * synchronously through windowProc; the run loop's handler state is
6392
7530
  * already gone by the time destroy is called, so those teardown
@@ -6406,6 +7544,7 @@ void native_sdk_windows_destroy(Host *host) {
6406
7544
  removeNotificationIcon(host);
6407
7545
  destroyAllWindows(host);
6408
7546
  gpuSurfaceDestroyFrameWakeTimer(host);
7547
+ if (loop_window) DestroyWindow(loop_window);
6409
7548
  const bool com_initialized = host->com_initialized;
6410
7549
  delete host;
6411
7550
  if (com_initialized) CoUninitialize();
@@ -6413,14 +7552,16 @@ void native_sdk_windows_destroy(Host *host) {
6413
7552
 
6414
7553
  void native_sdk_windows_run(Host *host, EventCallback callback, void *context) {
6415
7554
  if (!host) return;
6416
- host->callback = callback;
6417
- host->callback_context = context;
7555
+ host->runtime_callback = callback;
7556
+ host->runtime_context = context;
7557
+ host->callback = dispatchEventToRuntime;
7558
+ host->callback_context = host;
6418
7559
  host->running = true;
6419
7560
  if (!host->windows.empty()) createNativeWindow(host, host->windows.begin()->second);
6420
7561
  WindowsEvent start = {};
6421
7562
  start.kind = kStart;
6422
7563
  start.window_id = 1;
6423
- callback(context, &start);
7564
+ host->callback(host->callback_context, &start);
6424
7565
  emitAppearanceIfChanged(host, true);
6425
7566
  for (auto &entry : host->windows) {
6426
7567
  emit(host, entry.second, kResize);
@@ -6445,10 +7586,7 @@ void native_sdk_windows_run(Host *host, EventCallback callback, void *context) {
6445
7586
  DispatchMessageW(&message);
6446
7587
  if (host->running) gpuSurfaceDrainDueFrameEmissions(host);
6447
7588
  }
6448
- WindowsEvent shutdown = {};
6449
- shutdown.kind = kShutdown;
6450
- shutdown.window_id = 1;
6451
- callback(context, &shutdown);
7589
+ deliverShutdown(host);
6452
7590
  }
6453
7591
 
6454
7592
  void native_sdk_windows_stop(Host *host) {
@@ -6457,30 +7595,30 @@ void native_sdk_windows_stop(Host *host) {
6457
7595
  PostQuitMessage(0);
6458
7596
  }
6459
7597
 
6460
- /* Thread-safe wake: posts kWakeMessage into the message loop, which
6461
- * emits the kWake event on the loop thread. The lifetime mutex guards
6462
- * the window map against concurrent create/destroy. */
6463
- void native_sdk_windows_wake(Host *host) {
6464
- if (!host) return;
6465
- std::shared_ptr<HostLifetime> lifetime = host->lifetime;
6466
- std::lock_guard<std::recursive_mutex> guard(lifetime->mutex);
6467
- if (!lifetime->alive || !host->running) return;
6468
- HWND hwnd = parentWindow(host);
6469
- if (!hwnd) return;
6470
- 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;
6471
7609
  }
6472
7610
 
6473
- /* Thread-safe frame request: posts kRequestFrameMessage into the message
6474
- * loop, which emits one kFrame event on the loop thread — the automation
6475
- * arrival watcher's wake, guarded exactly like native_sdk_windows_wake. */
6476
- void native_sdk_windows_request_frame(Host *host) {
6477
- if (!host) return;
6478
- std::shared_ptr<HostLifetime> lifetime = host->lifetime;
6479
- std::lock_guard<std::recursive_mutex> guard(lifetime->mutex);
6480
- if (!lifetime->alive || !host->running) return;
6481
- HWND hwnd = parentWindow(host);
6482
- if (!hwnd) return;
6483
- 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;
6484
7622
  }
6485
7623
 
6486
7624
  /* Platform image decoder: WIC (Windows Imaging Component) handles PNG,
@@ -6638,6 +7776,7 @@ int native_sdk_windows_load_window_webview(Host *host, uint64_t window_id, const
6638
7776
  host->webviews.erase(key);
6639
7777
  return 0;
6640
7778
  }
7779
+ syncTopResizeStrip(host, window_id);
6641
7780
  }
6642
7781
 
6643
7782
  ChildWebView &webview = found->second;
@@ -6825,7 +7964,7 @@ int native_sdk_windows_create_window(Host *host, uint64_t window_id, const char
6825
7964
  void native_sdk_windows_start_timer(Host *host, uint64_t timer_id, uint64_t interval_ns, int repeats) {
6826
7965
  if (!host) return;
6827
7966
  native_sdk_windows_cancel_timer(host, timer_id);
6828
- HWND hwnd = parentWindow(host);
7967
+ HWND hwnd = loopWindow(host);
6829
7968
  if (!hwnd) return;
6830
7969
  AppTimer *slot = nullptr;
6831
7970
  UINT_PTR win32_id = 0;
@@ -7590,167 +8729,154 @@ int native_sdk_windows_close_view(Host *host, uint64_t window_id, const char *la
7590
8729
 
7591
8730
  WindowsOpenDialogResult native_sdk_windows_show_open_dialog(Host *host, const WindowsOpenDialogOpts *opts, char *buffer, size_t buffer_len) {
7592
8731
  WindowsOpenDialogResult result = {};
7593
- 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;
7594
8735
  bool uninitialize = false;
7595
8736
  if (!initializeCom(&uninitialize)) return result;
7596
8737
 
7597
- IFileOpenDialog *dialog = nullptr;
7598
- HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog));
7599
- if (FAILED(hr) || !dialog) {
7600
- finishCom(uninitialize);
7601
- return result;
7602
- }
7603
-
7604
- DWORD options = 0;
7605
- if (SUCCEEDED(dialog->GetOptions(&options))) {
7606
- options |= FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST;
7607
- if (opts->allow_directories) options |= FOS_PICKFOLDERS;
7608
- else options |= FOS_FILEMUSTEXIST;
7609
- if (opts->allow_multiple) options |= FOS_ALLOWMULTISELECT;
7610
- dialog->SetOptions(options);
7611
- }
7612
- setDialogTitle(dialog, opts->title, opts->title_len);
7613
- setDialogFolder(dialog, opts->default_path, opts->default_path_len);
7614
- if (!opts->allow_directories) setDialogFilters(dialog, opts->extensions, opts->extensions_len);
7615
-
7616
- if (SUCCEEDED(dialog->Show(parentWindow(host)))) {
7617
- IShellItemArray *items = nullptr;
7618
- if (SUCCEEDED(dialog->GetResults(&items)) && items) {
7619
- DWORD count = 0;
7620
- items->GetCount(&count);
7621
- size_t offset = 0;
7622
- size_t written_count = 0;
7623
- for (DWORD index = 0; index < count; ++index) {
7624
- IShellItem *item = nullptr;
7625
- if (SUCCEEDED(items->GetItemAt(index, &item)) && item) {
7626
- bool overflow = false;
7627
- PWSTR path = nullptr;
7628
- if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path)) && path) {
7629
- overflow = !appendPathToBuffer(buffer, buffer_len, &offset, &written_count, std::wstring(path));
7630
- CoTaskMemFree(path);
7631
- }
7632
- item->Release();
7633
- if (overflow) break;
7634
- }
7635
- }
7636
- result.count = written_count;
7637
- result.bytes_written = offset;
7638
- 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;
7639
8744
  }
8745
+ result.count = written_count;
8746
+ result.bytes_written = offset;
7640
8747
  }
7641
8748
 
7642
- dialog->Release();
7643
8749
  finishCom(uninitialize);
7644
8750
  return result;
7645
8751
  }
7646
8752
 
7647
8753
  size_t native_sdk_windows_show_save_dialog(Host *host, const WindowsSaveDialogOpts *opts, char *buffer, size_t buffer_len) {
7648
- 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;
7649
8756
  bool uninitialize = false;
7650
8757
  if (!initializeCom(&uninitialize)) return 0;
7651
8758
 
7652
- IFileSaveDialog *dialog = nullptr;
7653
- HRESULT hr = CoCreateInstance(CLSID_FileSaveDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog));
7654
- if (FAILED(hr) || !dialog) {
7655
- finishCom(uninitialize);
7656
- return 0;
7657
- }
7658
-
7659
- DWORD options = 0;
7660
- if (SUCCEEDED(dialog->GetOptions(&options))) dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
7661
- setDialogTitle(dialog, opts->title, opts->title_len);
7662
- setDialogFolder(dialog, opts->default_path, opts->default_path_len);
7663
- setDialogFilters(dialog, opts->extensions, opts->extensions_len);
7664
- std::wstring default_name = widen(slice(opts->default_name, opts->default_name_len));
7665
- if (!default_name.empty()) dialog->SetFileName(default_name.c_str());
7666
-
7667
8759
  size_t written = 0;
7668
- if (SUCCEEDED(dialog->Show(parentWindow(host)))) {
7669
- IShellItem *item = nullptr;
7670
- if (SUCCEEDED(dialog->GetResult(&item)) && item) {
7671
- PWSTR path = nullptr;
7672
- if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path)) && path) {
7673
- std::string utf8_path = narrow(std::wstring(path));
7674
- if (utf8_path.size() > buffer_len) {
7675
- written = overflowSize(buffer_len);
7676
- } else {
7677
- written = utf8_path.size();
7678
- if (written > 0) memcpy(buffer, utf8_path.data(), written);
7679
- }
7680
- CoTaskMemFree(path);
7681
- }
7682
- 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);
7683
8768
  }
7684
8769
  }
7685
8770
 
7686
- dialog->Release();
7687
8771
  finishCom(uninitialize);
7688
8772
  return written;
7689
8773
  }
7690
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. */
7691
8780
  int native_sdk_windows_show_message_dialog(Host *host, const WindowsMessageDialogOpts *opts) {
7692
8781
  if (!host || !opts) return 0;
7693
- std::wstring title = widen(slice(opts->title, opts->title_len));
7694
- std::wstring message = widen(slice(opts->message, opts->message_len));
7695
- std::wstring informative = widen(slice(opts->informative_text, opts->informative_text_len));
7696
- std::wstring primary = widen(slice(opts->primary_button, opts->primary_button_len));
7697
- std::wstring secondary = widen(slice(opts->secondary_button, opts->secondary_button_len));
7698
- std::wstring tertiary = widen(slice(opts->tertiary_button, opts->tertiary_button_len));
7699
- if (primary.empty()) primary = L"OK";
7700
-
7701
- TASKDIALOG_BUTTON buttons[3] = {};
7702
- int button_count = 0;
7703
- buttons[button_count++] = { 100, primary.c_str() };
7704
- if (!secondary.empty()) buttons[button_count++] = { 101, secondary.c_str() };
7705
- if (!tertiary.empty()) buttons[button_count++] = { 102, tertiary.c_str() };
7706
-
7707
- TASKDIALOGCONFIG config = {};
7708
- config.cbSize = sizeof(config);
7709
- config.hwndParent = parentWindow(host);
7710
- config.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION;
7711
- config.pszWindowTitle = title.empty() ? L"native-sdk" : title.c_str();
7712
- config.pszMainInstruction = message.empty() ? config.pszWindowTitle : message.c_str();
7713
- config.pszContent = informative.empty() ? nullptr : informative.c_str();
7714
- config.cButtons = static_cast<UINT>(button_count);
7715
- config.pButtons = buttons;
7716
- config.nDefaultButton = 100;
7717
- config.pszMainIcon = opts->style == 2 ? TD_ERROR_ICON : (opts->style == 1 ? TD_WARNING_ICON : TD_INFORMATION_ICON);
7718
-
7719
- /* TaskDialogIndirect is a comctl32 v6 export: it only resolves when
7720
- * the process activates the v6 side-by-side assembly (application
7721
- * manifest). A static import would abort the whole process at load
7722
- * time with STATUS_ENTRYPOINT_NOT_FOUND on the system default v5, so
7723
- * resolve it dynamically and fall back to MessageBoxW (fixed button
7724
- * captions, same 0/1/2 result contract) when only v5 is available. */
7725
- using TaskDialogIndirectFn = HRESULT(WINAPI *)(const TASKDIALOGCONFIG *, int *, int *, BOOL *);
7726
- static TaskDialogIndirectFn task_dialog = reinterpret_cast<TaskDialogIndirectFn>(
7727
- reinterpret_cast<void *>(GetProcAddress(GetModuleHandleW(L"comctl32.dll"), "TaskDialogIndirect")));
7728
- if (task_dialog) {
8782
+ if (host->session_ending) return -1;
8783
+ const DialogRequest spec = messageDialogSpec(opts);
8784
+ if (TaskDialogIndirectFn task_dialog = taskDialogIndirect()) {
7729
8785
  int pressed = 100;
7730
- 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;
7731
8788
  if (FAILED(hr)) return 0;
7732
8789
  if (pressed == 101) return 1;
7733
8790
  if (pressed == 102) return 2;
7734
8791
  return 0;
7735
8792
  }
7736
-
7737
- UINT type = MB_OK;
7738
- if (button_count == 2) type = MB_OKCANCEL;
7739
- if (button_count == 3) type = MB_YESNOCANCEL;
7740
- type |= opts->style == 2 ? MB_ICONERROR : (opts->style == 1 ? MB_ICONWARNING : MB_ICONINFORMATION);
7741
- std::wstring text = message;
7742
- if (!informative.empty()) {
7743
- if (!text.empty()) text += L"\n\n";
7744
- 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;
7745
8823
  }
7746
- const int result = MessageBoxW(parentWindow(host), text.c_str(), title.empty() ? L"native-sdk" : title.c_str(), type);
7747
- if (button_count == 3) {
7748
- if (result == IDNO) return 1;
7749
- 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 (...) {
7750
8833
  return 0;
7751
8834
  }
7752
- if (result == IDCANCEL) return 1;
7753
- 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;
7754
8880
  }
7755
8881
 
7756
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) {
@@ -8042,6 +9168,7 @@ int native_sdk_windows_create_webview(Host *host, uint64_t window_id, const char
8042
9168
  if (!createChildWebView(host, key)) {
8043
9169
  DestroyWindow(hwnd);
8044
9170
  host->webviews.erase(key);
9171
+ syncTopResizeStrip(host, window_id);
8045
9172
  return 0;
8046
9173
  }
8047
9174
  return 1;
@@ -8066,6 +9193,7 @@ int native_sdk_windows_set_webview_frame(Host *host, uint64_t window_id, const c
8066
9193
  found->second.controller->put_Bounds(bounds);
8067
9194
  }
8068
9195
  #endif
9196
+ syncTopResizeStrip(host, window_id);
8069
9197
  return 1;
8070
9198
  }
8071
9199
 
@@ -8145,6 +9273,7 @@ int native_sdk_windows_close_webview(Host *host, uint64_t window_id, const char
8145
9273
  #endif
8146
9274
  if (found->second.hwnd) DestroyWindow(found->second.hwnd);
8147
9275
  host->webviews.erase(found);
9276
+ syncTopResizeStrip(host, window_id);
8148
9277
  return 1;
8149
9278
  }
8150
9279