@zogien/native-sdk 0.11.0-fork.3 → 0.11.0-fork.4

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 (49) hide show
  1. package/README.md +1 -1
  2. package/build/app.zig +3 -0
  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/ts-core/SKILL.md +2 -0
  9. package/src/app_runner/app_assets.zig +85 -1
  10. package/src/app_runner/root.zig +63 -3
  11. package/src/app_runner/ts_core_main.zig +8 -0
  12. package/src/platform/macos/appkit_host.h +9 -0
  13. package/src/platform/macos/appkit_host.m +366 -0
  14. package/src/platform/macos/root.zig +29 -6
  15. package/src/platform/root.zig +1 -0
  16. package/src/platform/types.zig +9 -0
  17. package/src/platform/windows/root.zig +37 -0
  18. package/src/platform/windows/webview2_host.cpp +371 -26
  19. package/src/primitives/app_manifest/root.zig +1 -0
  20. package/src/primitives/app_manifest/tests.zig +35 -0
  21. package/src/primitives/app_manifest/types.zig +12 -0
  22. package/src/primitives/app_manifest/validation.zig +44 -6
  23. package/src/root.zig +1 -0
  24. package/src/runtime/channel_sources.zig +49 -1
  25. package/src/runtime/effects.zig +12 -1
  26. package/src/runtime/flow.zig +6 -0
  27. package/src/runtime/flow_reference.zig +6 -0
  28. package/src/runtime/root.zig +4 -0
  29. package/src/runtime/single_instance.zig +893 -0
  30. package/src/tooling/manifest.zig +111 -3
  31. package/src/tooling/package.zig +98 -8
  32. package/src/tooling/raw_manifest.zig +7 -0
  33. package/src/tooling/root.zig +2 -0
  34. package/src/tooling/templates.zig +44 -2
  35. package/src/tooling/update.zig +228 -10
  36. package/src/tooling/windows_installer.zig +245 -0
  37. package/src/tooling/zip_writer.zig +211 -0
  38. package/src/updater/engine.zig +936 -0
  39. package/src/updater/feed.zig +261 -12
  40. package/src/updater/macos.zig +187 -0
  41. package/src/updater/root.zig +41 -0
  42. package/src/updater/windows.zig +470 -0
  43. package/src/windows_install/config.zig +247 -0
  44. package/src/windows_install/main.zig +690 -0
  45. package/src/windows_install/payload.zig +146 -0
  46. package/src/windows_install/switch.zig +184 -0
  47. package/src/windows_install/tests.zig +11 -0
  48. package/src/windows_install/win32.zig +248 -0
  49. package/tools/native-sdk/main.zig +10 -9
@@ -421,6 +421,9 @@ struct Window {
421
421
  /* The maximized state last reported to the runtime, so WM_SIZE emits
422
422
  * a frame event only when it changes. */
423
423
  bool maximized = false;
424
+ /* Same-thread child kept above content children across the top
425
+ * resize band (see syncTopResizeStrip); created on first need. */
426
+ HWND top_resize_strip = nullptr;
424
427
  };
425
428
 
426
429
  /* One rectangle of a canvas view's window-drag mirror (runtime push,
@@ -743,6 +746,11 @@ struct Host {
743
746
  std::string window_title;
744
747
  std::string bundle_id;
745
748
  std::string icon_path;
749
+ /* The WebView2 user data folder every environment in this host
750
+ * shares, resolved once on first use (webViewUserDataFolder). Empty
751
+ * means WebView2's own default. */
752
+ bool webview_user_data_folder_resolved = false;
753
+ std::wstring webview_user_data_folder;
746
754
  EventCallback callback = nullptr;
747
755
  void *callback_context = nullptr;
748
756
  BridgeCallback bridge_callback = nullptr;
@@ -1171,8 +1179,57 @@ static std::string bridgeOriginForWebViewUrl(const ChildWebView &webview, const
1171
1179
  return isInternalAssetUrl(webview, url) ? assetOrigin(webview) : originForUrl(url);
1172
1180
  }
1173
1181
 
1182
+ /* The resources\ directory of a packaged Windows artifact: `native
1183
+ * package` stages the executable in bin\ and the app-relative asset tree
1184
+ * in resources\ beside it. Empty when the executable is not in a bin\
1185
+ * directory. */
1186
+ static const std::wstring &packagedResourcesDirectory() {
1187
+ static const std::wstring directory = [] {
1188
+ std::wstring path(MAX_PATH, L'\0');
1189
+ for (;;) {
1190
+ const DWORD length = GetModuleFileNameW(nullptr, path.data(), (DWORD)path.size());
1191
+ if (length == 0) return std::wstring();
1192
+ if (length < path.size()) {
1193
+ path.resize(length);
1194
+ break;
1195
+ }
1196
+ if (path.size() >= 32768) return std::wstring();
1197
+ path.resize(path.size() * 2);
1198
+ }
1199
+ const size_t exe_slash = path.find_last_of(L"\\/");
1200
+ if (exe_slash == std::wstring::npos) return std::wstring();
1201
+ const std::wstring bin = path.substr(0, exe_slash);
1202
+ const size_t bin_slash = bin.find_last_of(L"\\/");
1203
+ if (bin_slash == std::wstring::npos || CompareStringOrdinal(bin.c_str() + bin_slash + 1, -1, L"bin", -1, TRUE) != CSTR_EQUAL) return std::wstring();
1204
+ return bin.substr(0, bin_slash) + L"\\resources";
1205
+ }();
1206
+ return directory;
1207
+ }
1208
+
1209
+ /* A relative WebView asset root names a directory of the packaged asset
1210
+ * tree when the app runs from a package, the tree the macOS host finds
1211
+ * in its bundle's Resources. In development it stays relative to the
1212
+ * working directory. */
1213
+ static std::string assetRootPath(const std::string &root) {
1214
+ const bool absolute = !root.empty() && (root[0] == '\\' || root[0] == '/' || (root.size() > 1 && root[1] == ':'));
1215
+ if (!absolute) {
1216
+ const std::wstring &resources = packagedResourcesDirectory();
1217
+ if (!resources.empty()) {
1218
+ std::wstring candidate = resources;
1219
+ if (!root.empty() && root != ".") {
1220
+ std::wstring relative = widen(root);
1221
+ std::replace(relative.begin(), relative.end(), L'/', L'\\');
1222
+ candidate += L"\\" + relative;
1223
+ }
1224
+ const DWORD attributes = GetFileAttributesW(candidate.c_str());
1225
+ if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) return narrow(candidate);
1226
+ }
1227
+ }
1228
+ return root.empty() ? std::string(".") : root;
1229
+ }
1230
+
1174
1231
  static std::wstring assetFilePath(const ChildWebView &webview, const std::string &relative) {
1175
- std::string path = webview.asset_root.empty() ? std::string(".") : webview.asset_root;
1232
+ std::string path = assetRootPath(webview.asset_root);
1176
1233
  if (!path.empty() && path.back() != '/' && path.back() != '\\') path.push_back('\\');
1177
1234
  std::string native_relative = relative;
1178
1235
  std::replace(native_relative.begin(), native_relative.end(), '/', '\\');
@@ -2014,6 +2071,8 @@ static void applyNativeViewState(NativeView &view, bool update_text, const std::
2014
2071
  applyNativeViewAccessibility(view);
2015
2072
  }
2016
2073
 
2074
+ static void syncTopResizeStrip(Host *host, uint64_t window_id);
2075
+
2017
2076
  static void reorderWindowChildren(Host *host, uint64_t window_id) {
2018
2077
  if (!host) return;
2019
2078
  struct LayerItem {
@@ -2041,6 +2100,8 @@ static void reorderWindowChildren(Host *host, uint64_t window_id) {
2041
2100
  for (const LayerItem &item : items) {
2042
2101
  SetWindowPos(item.hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
2043
2102
  }
2103
+ /* Content layers never rise above the window's own resize band. */
2104
+ syncTopResizeStrip(host, window_id);
2044
2105
  }
2045
2106
 
2046
2107
  static bool emitNativeCommandForHwnd(Host *host, HWND hwnd, UINT notification_code) {
@@ -2328,6 +2389,25 @@ static bool windowIsChromeless(const Window &window) {
2328
2389
  return window.titlebar_style == 3;
2329
2390
  }
2330
2391
 
2392
+ /* Windows whose top resize frame lives INSIDE the client, answered by
2393
+ * the window's own WM_NCHITTEST and the top resize strip: the hidden
2394
+ * styles, and chromeless windows. On Windows 10 a caption-less frame
2395
+ * with any non-client top draws it as a blank white bar, so chromeless
2396
+ * keeps real left/right/bottom borders (resize and shadow) and hands
2397
+ * the client the top edge, the frame Tauri gives its undecorated
2398
+ * windows. Two chromeless shapes keep the plain popup frame: transparent
2399
+ * windows, whose per-pixel present seeds hit-testable resize bands
2400
+ * around the client, and windows carrying a menu bar, which needs the
2401
+ * non-client top. */
2402
+ static bool windowReclaimsTopFrame(const Window &window, bool has_menu) {
2403
+ if (windowUsesHiddenTitlebar(window)) return true;
2404
+ return windowIsChromeless(window) && !window.transparent && !has_menu;
2405
+ }
2406
+
2407
+ static bool windowReclaimsTopFrame(const Window &window) {
2408
+ return windowReclaimsTopFrame(window, window.hwnd && GetMenu(window.hwnd) != nullptr);
2409
+ }
2410
+
2331
2411
  static Window *hiddenTitlebarWindowForHwnd(Host *host, HWND hwnd) {
2332
2412
  if (!host || !hwnd) return nullptr;
2333
2413
  for (auto &entry : host->windows) {
@@ -2438,6 +2518,168 @@ static int hiddenFrameTopThickness(HWND hwnd) {
2438
2518
  return systemMetricForDpi(SM_CYSIZEFRAME, dpi) + systemMetricForDpi(SM_CXPADDEDBORDER, dpi);
2439
2519
  }
2440
2520
 
2521
+ /* The top resize band's hit code for a client point: `thickness` rows
2522
+ * tall, with corner slivers as wide as the side borders so the diagonal
2523
+ * grips survive at the very top; HTNOWHERE outside the band. */
2524
+ static constexpr LRESULT topResizeBandHitCode(int x, int y, int client_width, int thickness, int corner) {
2525
+ if (y < 0 || y >= thickness || x < 0 || x >= client_width) return HTNOWHERE;
2526
+ if (x < corner) return HTTOPLEFT;
2527
+ if (x >= client_width - corner) return HTTOPRIGHT;
2528
+ return HTTOP;
2529
+ }
2530
+
2531
+ static_assert(
2532
+ topResizeBandHitCode(3, 2, 1040, 8, 8) == HTTOPLEFT &&
2533
+ topResizeBandHitCode(8, 2, 1040, 8, 8) == HTTOP &&
2534
+ topResizeBandHitCode(1031, 7, 1040, 8, 8) == HTTOP &&
2535
+ topResizeBandHitCode(1032, 7, 1040, 8, 8) == HTTOPRIGHT &&
2536
+ topResizeBandHitCode(520, 8, 1040, 8, 8) == HTNOWHERE &&
2537
+ topResizeBandHitCode(520, -1, 1040, 8, 8) == HTNOWHERE,
2538
+ "the top band is exactly `thickness` rows tall with `corner`-wide diagonal grips");
2539
+
2540
+ /* The top resize band of a window that reclaims its top frame, in the
2541
+ * window's client coordinates. Only a restored resizable window has one:
2542
+ * a maximized window does not resize and a fixed one never does. Every
2543
+ * surface that answers for the band — the window itself, canvas children
2544
+ * handing it back, and the top resize strip — asks here. */
2545
+ static LRESULT topResizeBandHit(const Window &window, POINT client) {
2546
+ if (!window.hwnd || !windowReclaimsTopFrame(window)) return HTNOWHERE;
2547
+ if (!window.resizable || IsZoomed(window.hwnd) || IsIconic(window.hwnd)) return HTNOWHERE;
2548
+ RECT rect = {};
2549
+ GetClientRect(window.hwnd, &rect);
2550
+ const UINT dpi = dpiForWindow(window.hwnd);
2551
+ const int corner = systemMetricForDpi(SM_CXSIZEFRAME, dpi) + systemMetricForDpi(SM_CXPADDEDBORDER, dpi);
2552
+ return topResizeBandHitCode(client.x, client.y, rect.right - rect.left, hiddenFrameTopThickness(window.hwnd), corner);
2553
+ }
2554
+
2555
+ /* ------------------------------------------------------------------------
2556
+ * Top resize strip.
2557
+ *
2558
+ * A child covering the top band must hand it back to the window. Canvas
2559
+ * surfaces in hidden-titlebar windows do (they answer HTTRANSPARENT
2560
+ * there), but a WebView2 child belongs to the browser process and never
2561
+ * consults its parent, so under WebView content the band was only
2562
+ * resizable where WebView2's own drag-region windows happened to answer
2563
+ * it. The strip is a same-thread, unpainted child kept above every
2564
+ * content child across the band: it answers the window's band hit codes
2565
+ * itself (the cursor follows from them) and forwards the press to the
2566
+ * window, whose DefWindowProc runs the system size loop. Tauri recovers
2567
+ * the top edge of its undecorated windows the same way. WS_EX_TRANSPARENT
2568
+ * keeps the strip out of its siblings' visible regions: the WebView
2569
+ * containers clip siblings, and without it the band shows the window
2570
+ * background instead of the content under it. */
2571
+
2572
+ static Window *windowForTopResizeStrip(Host *host, HWND strip) {
2573
+ if (!host || !strip) return nullptr;
2574
+ for (auto &entry : host->windows) {
2575
+ if (entry.second.top_resize_strip == strip) return &entry.second;
2576
+ }
2577
+ return nullptr;
2578
+ }
2579
+
2580
+ static LRESULT CALLBACK topResizeStripProc(HWND strip, UINT message, WPARAM wparam, LPARAM lparam) {
2581
+ Host *host = reinterpret_cast<Host *>(GetWindowLongPtrW(strip, GWLP_USERDATA));
2582
+ switch (message) {
2583
+ case WM_NCHITTEST: {
2584
+ Window *window = windowForTopResizeStrip(host, strip);
2585
+ if (!window || !window->hwnd) return HTTRANSPARENT;
2586
+ POINT point = { (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam) };
2587
+ ScreenToClient(window->hwnd, &point);
2588
+ const LRESULT hit = topResizeBandHit(*window, point);
2589
+ return hit != HTNOWHERE ? hit : HTTRANSPARENT;
2590
+ }
2591
+ case WM_NCLBUTTONDOWN:
2592
+ case WM_NCLBUTTONDBLCLK:
2593
+ /* Same thread, so the window's own non-client handling runs
2594
+ * synchronously with the original screen point: the size loop
2595
+ * for a press, the vertical maximize for a double-click. */
2596
+ if (wparam == HTTOP || wparam == HTTOPLEFT || wparam == HTTOPRIGHT) {
2597
+ Window *window = windowForTopResizeStrip(host, strip);
2598
+ if (window && window->hwnd) return SendMessageW(window->hwnd, message, wparam, lparam);
2599
+ }
2600
+ break;
2601
+ case WM_ERASEBKGND:
2602
+ return 1;
2603
+ case WM_PAINT: {
2604
+ PAINTSTRUCT paint = {};
2605
+ BeginPaint(strip, &paint);
2606
+ EndPaint(strip, &paint);
2607
+ return 0;
2608
+ }
2609
+ default:
2610
+ break;
2611
+ }
2612
+ return DefWindowProcW(strip, message, wparam, lparam);
2613
+ }
2614
+
2615
+ static const wchar_t *topResizeStripClassName(Host *host) {
2616
+ static bool registered = false;
2617
+ if (!registered) {
2618
+ WNDCLASSEXW wc = {};
2619
+ wc.cbSize = sizeof(wc);
2620
+ wc.lpfnWndProc = topResizeStripProc;
2621
+ wc.hInstance = host->instance;
2622
+ wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
2623
+ wc.lpszClassName = L"NativeSdkTopResizeStrip";
2624
+ registered = RegisterClassExW(&wc) != 0;
2625
+ }
2626
+ return L"NativeSdkTopResizeStrip";
2627
+ }
2628
+
2629
+ /* Whether any WebView container reaches into the window's top band. */
2630
+ static bool webViewCoversTopBand(Host *host, const Window &window, int band) {
2631
+ RECT client = {};
2632
+ GetClientRect(window.hwnd, &client);
2633
+ const RECT band_rect = { 0, 0, client.right - client.left, band };
2634
+ for (auto &entry : host->webviews) {
2635
+ const ChildWebView &webview = entry.second;
2636
+ if (webview.window_id != window.id || !webview.hwnd) continue;
2637
+ if (!(GetWindowLongPtrW(webview.hwnd, GWL_STYLE) & WS_VISIBLE)) continue;
2638
+ RECT bounds = {};
2639
+ GetWindowRect(webview.hwnd, &bounds);
2640
+ POINT top_left = { bounds.left, bounds.top };
2641
+ POINT bottom_right = { bounds.right, bounds.bottom };
2642
+ ScreenToClient(window.hwnd, &top_left);
2643
+ ScreenToClient(window.hwnd, &bottom_right);
2644
+ const RECT local = { top_left.x, top_left.y, bottom_right.x, bottom_right.y };
2645
+ RECT overlap = {};
2646
+ if (IntersectRect(&overlap, &local, &band_rect)) return true;
2647
+ }
2648
+ return false;
2649
+ }
2650
+
2651
+ /* Show, size and raise the strip over the band of a restored resizable
2652
+ * window that reclaims its top frame; hide it otherwise. Chromeless
2653
+ * windows always take it: their content children answer the band as
2654
+ * client. Hidden-titlebar windows take it only while a WebView covers the
2655
+ * band, so canvas windows keep their own band and the DWM keeps first
2656
+ * claim on its caption buttons. Runs after every geometry, state, or
2657
+ * child z-order change. */
2658
+ static void syncTopResizeStrip(Host *host, uint64_t window_id) {
2659
+ if (!host) return;
2660
+ auto found = host->windows.find(window_id);
2661
+ if (found == host->windows.end() || !found->second.hwnd) return;
2662
+ Window &window = found->second;
2663
+ const int band = hiddenFrameTopThickness(window.hwnd);
2664
+ const bool wanted = windowReclaimsTopFrame(window) && window.resizable && !window.click_through &&
2665
+ !IsZoomed(window.hwnd) && !IsIconic(window.hwnd) &&
2666
+ (windowIsChromeless(window) || webViewCoversTopBand(host, window, band));
2667
+ if (!wanted) {
2668
+ if (window.top_resize_strip) ShowWindow(window.top_resize_strip, SW_HIDE);
2669
+ return;
2670
+ }
2671
+ if (!window.top_resize_strip) {
2672
+ HWND strip = CreateWindowExW(WS_EX_TRANSPARENT, topResizeStripClassName(host), L"", WS_CHILD | WS_CLIPSIBLINGS,
2673
+ 0, 0, 0, 0, window.hwnd, nullptr, host->instance, nullptr);
2674
+ if (!strip) return;
2675
+ SetWindowLongPtrW(strip, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(host));
2676
+ window.top_resize_strip = strip;
2677
+ }
2678
+ RECT client = {};
2679
+ GetClientRect(window.hwnd, &client);
2680
+ SetWindowPos(window.top_resize_strip, HWND_TOP, 0, 0, client.right - client.left, band, SWP_NOACTIVATE | SWP_SHOWWINDOW);
2681
+ }
2682
+
2441
2683
  /* Height of the standard caption band (frame + caption) at the window's
2442
2684
  * dpi — the DWM frame extension depth, and the chrome-channel top inset
2443
2685
  * fallback when the live button rects are unavailable. */
@@ -3448,7 +3690,7 @@ static LRESULT CALLBACK gpuSurfaceProc(HWND hwnd, UINT message, WPARAM wparam, L
3448
3690
  if (!chrome_window) break;
3449
3691
  POINT point = { (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam) };
3450
3692
  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;
3693
+ if (topResizeBandHit(*chrome_window, point) != HTNOWHERE) return HTTRANSPARENT;
3452
3694
  RECT cluster = {};
3453
3695
  if (captionButtonsClientRect(chrome_window->hwnd, &cluster) && PtInRect(&cluster, point)) return HTTRANSPARENT;
3454
3696
  if (windowDragRegionHit(host, *chrome_window, point)) return HTTRANSPARENT;
@@ -5010,6 +5252,7 @@ static void destroyAllWindows(Host *host) {
5010
5252
  if (entry.second.hwnd) {
5011
5253
  DestroyWindow(entry.second.hwnd);
5012
5254
  entry.second.hwnd = nullptr;
5255
+ entry.second.top_resize_strip = nullptr;
5013
5256
  }
5014
5257
  }
5015
5258
  }
@@ -5335,13 +5578,90 @@ static CreateEnvironmentFn webView2Factory() {
5335
5578
  return reinterpret_cast<CreateEnvironmentFn>(GetProcAddress(loader, "CreateCoreWebView2EnvironmentWithOptions"));
5336
5579
  }
5337
5580
 
5581
+ static bool webViewDirectoryExists(const std::wstring &path) {
5582
+ const DWORD attributes = GetFileAttributesW(path.c_str());
5583
+ return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
5584
+ }
5585
+
5586
+ /* The folder WebView2 picks when none is given: "<exe>.WebView2" beside
5587
+ * the executable. Earlier hosts left their data there. */
5588
+ static std::wstring webViewLegacyUserDataFolder() {
5589
+ std::wstring path(MAX_PATH, L'\0');
5590
+ for (;;) {
5591
+ const DWORD length = GetModuleFileNameW(nullptr, path.data(), (DWORD)path.size());
5592
+ if (length == 0) return std::wstring();
5593
+ if (length < path.size()) {
5594
+ path.resize(length);
5595
+ return path + L".WebView2";
5596
+ }
5597
+ if (path.size() >= 32768) return std::wstring();
5598
+ path.resize(path.size() * 2);
5599
+ }
5600
+ }
5601
+
5602
+ /* WebView2 keeps cookies, storage and caches in its user data folder.
5603
+ * Its default sits beside the executable, which is read-only under
5604
+ * Program Files and is replaced together with the program files when
5605
+ * an installed app updates. Keep it with the app's other per-user data
5606
+ * instead: %LOCALAPPDATA%\<bundle id>\WebView2, next to the Data, Cache,
5607
+ * State and Logs folders app_dirs resolves from the same variable and
5608
+ * bundle id. A folder left beside the executable by an earlier host
5609
+ * moves there once. A folder on another volume cannot be renamed there,
5610
+ * so that install keeps its old folder; when the move fails for another
5611
+ * reason (another process still has the folder open) this launch keeps
5612
+ * the old folder and the next launch tries again. Either way no data is
5613
+ * lost. Without LOCALAPPDATA or a usable bundle id, WebView2 keeps its
5614
+ * default. */
5615
+ static const wchar_t *webViewUserDataFolder(Host *host) {
5616
+ if (host->webview_user_data_folder_resolved) {
5617
+ return host->webview_user_data_folder.empty() ? nullptr : host->webview_user_data_folder.c_str();
5618
+ }
5619
+ host->webview_user_data_folder_resolved = true;
5620
+ const std::string &bundle_id = host->bundle_id;
5621
+ if (bundle_id.empty() || bundle_id == "." || bundle_id == ".." ||
5622
+ bundle_id.find_first_of("\\/:*?\"<>|") != std::string::npos) return nullptr;
5623
+ for (unsigned char ch : bundle_id) {
5624
+ if (ch < 0x20) return nullptr;
5625
+ }
5626
+ const DWORD needed = GetEnvironmentVariableW(L"LOCALAPPDATA", nullptr, 0);
5627
+ if (needed <= 1) return nullptr;
5628
+ std::wstring local_app_data((size_t)needed, L'\0');
5629
+ const DWORD written = GetEnvironmentVariableW(L"LOCALAPPDATA", local_app_data.data(), needed);
5630
+ if (written == 0 || written >= needed) return nullptr;
5631
+ local_app_data.resize(written);
5632
+ while (!local_app_data.empty() && (local_app_data.back() == L'\\' || local_app_data.back() == L'/')) local_app_data.pop_back();
5633
+ if (local_app_data.empty()) return nullptr;
5634
+
5635
+ const std::wstring app_root = local_app_data + L"\\" + widen(bundle_id);
5636
+ const std::wstring folder = app_root + L"\\WebView2";
5637
+ SHCreateDirectoryExW(nullptr, app_root.c_str(), nullptr);
5638
+ if (!webViewDirectoryExists(app_root)) return nullptr;
5639
+
5640
+ if (!webViewDirectoryExists(folder)) {
5641
+ const std::wstring legacy = webViewLegacyUserDataFolder();
5642
+ if (!legacy.empty() && webViewDirectoryExists(legacy) && !MoveFileExW(legacy.c_str(), folder.c_str(), 0)) {
5643
+ const DWORD error = GetLastError();
5644
+ if (error != ERROR_NOT_SAME_DEVICE) {
5645
+ fprintf(stderr, "native-sdk: keeping WebView2 data in %s for this launch; moving it to %s failed (error %lu)\n",
5646
+ narrow(legacy).c_str(), narrow(folder).c_str(), static_cast<unsigned long>(error));
5647
+ }
5648
+ host->webview_user_data_folder = legacy;
5649
+ return host->webview_user_data_folder.c_str();
5650
+ }
5651
+ }
5652
+ host->webview_user_data_folder = folder;
5653
+ return host->webview_user_data_folder.c_str();
5654
+ }
5655
+
5338
5656
  static void cleanupPendingChildWebView(Host *host, const std::string &key) {
5339
5657
  if (!host) return;
5340
5658
  auto found = host->webviews.find(key);
5341
5659
  if (found == host->webviews.end()) return;
5660
+ const uint64_t window_id = found->second.window_id;
5342
5661
  if (found->second.controller) found->second.controller->Close();
5343
5662
  if (found->second.hwnd) DestroyWindow(found->second.hwnd);
5344
5663
  host->webviews.erase(found);
5664
+ syncTopResizeStrip(host, window_id);
5345
5665
  }
5346
5666
 
5347
5667
  static void failWindowDragInitialization(Host *host, const std::string &key, HRESULT result) {
@@ -5361,7 +5681,7 @@ static bool createChildWebView(Host *host, const std::string &key) {
5361
5681
  HWND parent = found->second.hwnd;
5362
5682
  const uint64_t generation = found->second.window_drag_generation;
5363
5683
  std::weak_ptr<HostLifetime> lifetime = host->lifetime;
5364
- HRESULT hr = factory(nullptr, nullptr, nullptr, Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
5684
+ HRESULT hr = factory(nullptr, webViewUserDataFolder(host), nullptr, Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
5365
5685
  [host, key, parent, lifetime, generation](HRESULT result, ICoreWebView2Environment *environment) -> HRESULT {
5366
5686
  auto token = lifetime.lock();
5367
5687
  if (!token) return S_OK;
@@ -5818,21 +6138,11 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
5818
6138
  if (def_hit != HTCLIENT) return def_hit;
5819
6139
  POINT point = { (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam) };
5820
6140
  ScreenToClient(hwnd, &point);
5821
- RECT client = {};
5822
- GetClientRect(hwnd, &client);
5823
6141
  /* Top resize band: the caption removal moved the top
5824
6142
  * 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
- }
6143
+ * the system. */
6144
+ const LRESULT band_hit = topResizeBandHit(*chrome_window, point);
6145
+ if (band_hit != HTNOWHERE) return band_hit;
5836
6146
  /* Caption buttons when DwmDefWindowProc stayed silent
5837
6147
  * (composition off, older cores): DefWindowProc still
5838
6148
  * performs minimize/maximize/close for these hit codes,
@@ -5865,6 +6175,22 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
5865
6175
  break;
5866
6176
  }
5867
6177
  }
6178
+ /* Chromeless windows hand the top frame to the client (see
6179
+ * windowReclaimsTopFrame) while restored. A maximized window keeps
6180
+ * DefWindowProc's inset: its frame parks the borders offscreen around
6181
+ * the work area, and the inset is what lands the client exactly on
6182
+ * it. */
6183
+ if (message == WM_NCCALCSIZE && wparam) {
6184
+ Window *chromeless_window = chromelessWindowForHwnd(host, hwnd);
6185
+ if (chromeless_window && windowReclaimsTopFrame(*chromeless_window) && !IsZoomed(hwnd)) {
6186
+ NCCALCSIZE_PARAMS *params = reinterpret_cast<NCCALCSIZE_PARAMS *>(lparam);
6187
+ const LONG original_top = params->rgrc[0].top;
6188
+ const LRESULT def_result = DefWindowProcW(hwnd, WM_NCCALCSIZE, wparam, lparam);
6189
+ if (def_result != 0) return def_result;
6190
+ params->rgrc[0].top = original_top;
6191
+ return 0;
6192
+ }
6193
+ }
5868
6194
  /* Chromeless windows (WS_POPUP, no caption): DefWindowProc owns the
5869
6195
  * resize frame when one exists; the app's window-drag regions are
5870
6196
  * the only caption there is, so a client-area hit inside one
@@ -5877,6 +6203,8 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
5877
6203
  if (def_hit != HTCLIENT) return def_hit;
5878
6204
  POINT point = { (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam) };
5879
6205
  ScreenToClient(hwnd, &point);
6206
+ const LRESULT band_hit = topResizeBandHit(*chromeless_window, point);
6207
+ if (band_hit != HTNOWHERE) return band_hit;
5880
6208
  if (windowDragRegionHit(host, *chromeless_window, point)) return HTCAPTION;
5881
6209
  return HTCLIENT;
5882
6210
  }
@@ -5990,6 +6318,9 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
5990
6318
  applyWebViewFrame(main->second);
5991
6319
  }
5992
6320
  #endif
6321
+ /* Size, maximize/restore, minimize, and dpi changes
6322
+ * all land here. */
6323
+ syncTopResizeStrip(host, entry.first);
5993
6324
  emit(host, entry.second, kResize);
5994
6325
  }
5995
6326
  }
@@ -6109,9 +6440,9 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6109
6440
  /* The declared floor is a CONTENT size in LOGICAL
6110
6441
  * points; scale to physical pixels at this window's
6111
6442
  * 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
6443
+ * current style. Styles that reclaim their top frame
6444
+ * carry no top chrome (the custom WM_NCCALCSIZE hands
6445
+ * it to the client), so their conversion skips it
6115
6446
  * too. */
6116
6447
  const UINT dpi = dpiForWindow(hwnd);
6117
6448
  const double scale = (double)dpi / 96.0;
@@ -6124,7 +6455,7 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6124
6455
  adjustWindowRectForDpi(&frame, style, has_menu, ex_style, dpi);
6125
6456
  LONG outer_width = frame.right - frame.left;
6126
6457
  LONG outer_height = frame.bottom - frame.top;
6127
- if (windowUsesHiddenTitlebar(window)) {
6458
+ if (windowReclaimsTopFrame(window, has_menu)) {
6128
6459
  const SIZE outer = hiddenOuterSizeForContent(style, ex_style, has_menu, min_content_width, min_content_height, dpi);
6129
6460
  outer_width = outer.cx;
6130
6461
  outer_height = outer.cy;
@@ -6183,6 +6514,7 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
6183
6514
  destroyNativeViewsForWindow(host, entry.first);
6184
6515
  destroyChildWebViewsForWindow(host, entry.first);
6185
6516
  entry.second.hwnd = nullptr;
6517
+ entry.second.top_resize_strip = nullptr;
6186
6518
  /* Destroyed means closed, never hidden. */
6187
6519
  entry.second.policy_hidden = false;
6188
6520
  emit(host, entry.second, kWindowFrame);
@@ -6252,9 +6584,9 @@ static bool createNativeWindow(Host *host, Window &window) {
6252
6584
  * re-derives the frame if it lands on another monitor), then grow
6253
6585
  * it to the outer size for this style so the client rect lands at
6254
6586
  * 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. */
6587
+ * for it here when menus are declared. Styles that reclaim their top
6588
+ * frame use the custom-calc shape (no top chrome) — plain adjustment
6589
+ * would land their client one caption band (or top border) tall. */
6258
6590
  const bool has_menu = !host->menus.empty();
6259
6591
  const UINT dpi = systemDpi();
6260
6592
  const double scale = (double)dpi / 96.0;
@@ -6264,7 +6596,7 @@ static bool createNativeWindow(Host *host, Window &window) {
6264
6596
  adjustWindowRectForDpi(&frame, style, has_menu ? TRUE : FALSE, 0, dpi);
6265
6597
  LONG outer_width = frame.right - frame.left;
6266
6598
  LONG outer_height = frame.bottom - frame.top;
6267
- if (windowUsesHiddenTitlebar(window)) {
6599
+ if (windowReclaimsTopFrame(window, has_menu)) {
6268
6600
  const SIZE outer = hiddenOuterSizeForContent(style, 0, has_menu, content_width, content_height, dpi);
6269
6601
  outer_width = outer.cx;
6270
6602
  outer_height = outer.cy;
@@ -6296,13 +6628,14 @@ static bool createNativeWindow(Host *host, Window &window) {
6296
6628
  /* Before the first show, so a dark-scheme launch never flashes a
6297
6629
  * light caption. */
6298
6630
  applyStandardTitlebarColorScheme(window);
6299
- if (windowUsesHiddenTitlebar(window)) {
6300
- applyHiddenTitlebarFrame(window);
6631
+ if (windowUsesHiddenTitlebar(window)) applyHiddenTitlebarFrame(window);
6632
+ if (windowReclaimsTopFrame(window)) {
6301
6633
  /* The create-time WM_NCCALCSIZE ran before this window was
6302
6634
  * registered under its HWND, so the custom calc did not apply;
6303
6635
  * force one now that the map entry resolves. The callers keep
6304
6636
  * `window` referencing the stored map entry for exactly this. */
6305
6637
  SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
6638
+ syncTopResizeStrip(host, window.id);
6306
6639
  }
6307
6640
  /* Initialize WS_EX_LAYERED through the same per-pixel path used by
6308
6641
  * canvas presents. A layered HWND is otherwise not displayable until
@@ -6357,6 +6690,14 @@ Host *native_sdk_windows_create(const char *app_name, size_t app_name_len, const
6357
6690
  host->app_name = slice(app_name, app_name_len);
6358
6691
  host->window_title = slice(window_title, window_title_len);
6359
6692
  host->bundle_id = slice(bundle_id, bundle_id_len);
6693
+ /* The taskbar groups and pins windows by AppUserModelID. Name the
6694
+ * process by its bundle id, the id the installer's Start menu
6695
+ * shortcut carries, so the taskbar follows the app instead of the
6696
+ * path of the executable that happened to start it. */
6697
+ if (!host->bundle_id.empty()) {
6698
+ const std::wstring app_user_model_id = widen(host->bundle_id);
6699
+ SetCurrentProcessExplicitAppUserModelID(app_user_model_id.c_str());
6700
+ }
6360
6701
  host->icon_path = slice(icon_path, icon_path_len);
6361
6702
  Window window;
6362
6703
  window.id = 1;
@@ -6638,6 +6979,7 @@ int native_sdk_windows_load_window_webview(Host *host, uint64_t window_id, const
6638
6979
  host->webviews.erase(key);
6639
6980
  return 0;
6640
6981
  }
6982
+ syncTopResizeStrip(host, window_id);
6641
6983
  }
6642
6984
 
6643
6985
  ChildWebView &webview = found->second;
@@ -8042,6 +8384,7 @@ int native_sdk_windows_create_webview(Host *host, uint64_t window_id, const char
8042
8384
  if (!createChildWebView(host, key)) {
8043
8385
  DestroyWindow(hwnd);
8044
8386
  host->webviews.erase(key);
8387
+ syncTopResizeStrip(host, window_id);
8045
8388
  return 0;
8046
8389
  }
8047
8390
  return 1;
@@ -8066,6 +8409,7 @@ int native_sdk_windows_set_webview_frame(Host *host, uint64_t window_id, const c
8066
8409
  found->second.controller->put_Bounds(bounds);
8067
8410
  }
8068
8411
  #endif
8412
+ syncTopResizeStrip(host, window_id);
8069
8413
  return 1;
8070
8414
  }
8071
8415
 
@@ -8145,6 +8489,7 @@ int native_sdk_windows_close_webview(Host *host, uint64_t window_id, const char
8145
8489
  #endif
8146
8490
  if (found->second.hwnd) DestroyWindow(found->second.hwnd);
8147
8491
  host->webviews.erase(found);
8492
+ syncTopResizeStrip(host, window_id);
8148
8493
  return 1;
8149
8494
  }
8150
8495
 
@@ -83,6 +83,7 @@ pub const FileAssociation = types.FileAssociation;
83
83
  pub const UrlScheme = types.UrlScheme;
84
84
  pub const PackageMetadata = types.PackageMetadata;
85
85
  pub const UpdateConfig = types.UpdateConfig;
86
+ pub const UpdateMode = types.UpdateMode;
86
87
  pub const Manifest = types.Manifest;
87
88
  pub const ImagesConfig = types.ImagesConfig;
88
89
  pub const validateManifest = validation.validateManifest;
@@ -125,6 +125,41 @@ test "updates require paired HTTPS feed and Ed25519 public key" {
125
125
  }));
126
126
  }
127
127
 
128
+ test "app-driven updates need a feed to drive" {
129
+ try validateUpdates(.{
130
+ .feed_url = "https://example.com/native-update.json",
131
+ .public_key = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
132
+ .mode = .app,
133
+ });
134
+ try std.testing.expectError(error.MissingRequiredField, validateUpdates(.{ .mode = .app }));
135
+ }
136
+
137
+ test "update feed URLs may name the target and installed version" {
138
+ const key = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=";
139
+ try validateUpdates(.{ .feed_url = "https://example.com/releases/{target}/native-update.json", .public_key = key });
140
+ try validateUpdates(.{ .feed_url = "https://example.com/feed?target={target}&from={current_version}", .public_key = key });
141
+ try std.testing.expectError(error.InvalidUrl, validateUpdates(.{ .feed_url = "https://example.com/{arch}/native-update.json", .public_key = key }));
142
+ try std.testing.expectError(error.InvalidUrl, validateUpdates(.{ .feed_url = "https://example.com/{target/native-update.json", .public_key = key }));
143
+ try std.testing.expectError(error.InvalidUrl, validateUpdates(.{ .feed_url = "https://example.com/target}/native-update.json", .public_key = key }));
144
+ try std.testing.expectError(error.InvalidUrl, validateUpdates(.{ .feed_url = "https://{target}.example.com/native-update.json", .public_key = key }));
145
+ }
146
+
147
+ test "update public_keys add distinct trusted keys beside public_key" {
148
+ const key = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=";
149
+ const other = "wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo=";
150
+ const rotated = [_][]const u8{other};
151
+ try validateUpdates(.{ .feed_url = "https://example.com/native-update.json", .public_key = key, .public_keys = &rotated });
152
+ try std.testing.expectError(error.MissingRequiredField, validateUpdates(.{ .public_keys = &rotated }));
153
+ const repeated = [_][]const u8{key};
154
+ try std.testing.expectError(error.MissingRequiredField, validateUpdates(.{ .feed_url = "https://example.com/native-update.json", .public_key = key, .public_keys = &repeated }));
155
+ const duplicated = [_][]const u8{ other, other };
156
+ try std.testing.expectError(error.MissingRequiredField, validateUpdates(.{ .feed_url = "https://example.com/native-update.json", .public_key = key, .public_keys = &duplicated }));
157
+ const malformed = [_][]const u8{"not-a-public-key"};
158
+ try std.testing.expectError(error.MissingRequiredField, validateUpdates(.{ .feed_url = "https://example.com/native-update.json", .public_key = key, .public_keys = &malformed }));
159
+ const too_many = [_][]const u8{ other, "IBLLkMpg6OXY2vZuInLSIz4EhtVX6MZhQe2JIBd9frc=", "+AzM3OSuHAeuIIoq35mjEK5CB+Awb6AjYRCwaCe7uNA=", "n0lDnp2wlbxBEe0l01eV2DG8VaBH9LHX9q7jd3u0EiA=" };
160
+ try std.testing.expectError(error.MissingRequiredField, validateUpdates(.{ .feed_url = "https://example.com/native-update.json", .public_key = key, .public_keys = &too_many }));
161
+ }
162
+
128
163
  test "accessory startup requires the tray capability" {
129
164
  const tray_capabilities = [_]Capability{.tray};
130
165
  try validateManifest(.{
@@ -647,9 +647,21 @@ pub const PackageMetadata = struct {
647
647
  pub const UpdateConfig = struct {
648
648
  feed_url: ?[]const u8 = null,
649
649
  public_key: ?[]const u8 = null,
650
+ /// Further trusted keys beside `public_key`, so a release signed with
651
+ /// a key being rotated out still verifies on installs that know it.
652
+ public_keys: []const []const u8 = &.{},
650
653
  check_on_start: bool = false,
654
+ /// Who drives updates on macOS: the host's own prompts, or the app
655
+ /// through the `native-sdk.updates` requests and channel. Windows apps
656
+ /// always drive updates themselves.
657
+ mode: UpdateMode = .host,
651
658
  };
652
659
 
660
+ pub const UpdateMode = enum { host, app };
661
+
662
+ /// `public_key` plus at most this many `public_keys`.
663
+ pub const max_update_public_keys: usize = 3;
664
+
653
665
  pub const PersistRestore = struct {
654
666
  ok: []const u8,
655
667
  none: []const u8,