diff --git a/docs/src/app/web-engines/page.mdx b/docs/src/app/web-engines/page.mdx
index 1f987fa21..98a0b69d2 100644
--- a/docs/src/app/web-engines/page.mdx
+++ b/docs/src/app/web-engines/page.mdx
@@ -25,7 +25,7 @@ zero-native has one app/runtime API and selectable web engine backends. The defa
| Windows |
- In progress |
+ WebView2 |
In progress |
@@ -41,6 +41,8 @@ The intended parity contract is the same Zig app model, same runtime services, s
System mode has no bundled browser dependency. It uses the OS web engine, so rendering and web platform support follow the user's installed OS.
+On Windows the system engine uses the WebView2 Runtime (included with Windows 11, available on Windows 10 via the evergreen runtime). The build looks for `` at `third_party/webview2-sdk/build/native/include` by default; pass `-Dwebview2-sdk-dir` to use a different path.
+
## Chromium (CEF)
Set the app engine once:
@@ -108,6 +110,10 @@ Core maintainers can build CEF from source with `tools/cef/build-from-source.sh`
-Dcef-auto-install=true |
Temporarily opt into running zero-native cef install during Chromium builds when CEF is missing. |
+
+ -Dwebview2-sdk-dir=path |
+ Windows only. Path to the WebView2 SDK root directory containing build/native/include. When omitted, defaults to third_party/webview2-sdk. |
+
diff --git a/docs/src/app/windows-build/layout.tsx b/docs/src/app/windows-build/layout.tsx
new file mode 100644
index 000000000..9af0c9ce8
--- /dev/null
+++ b/docs/src/app/windows-build/layout.tsx
@@ -0,0 +1,7 @@
+import { pageMetadata } from "@/lib/page-metadata";
+
+export const metadata = pageMetadata("windows-build");
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return children;
+}
diff --git a/docs/src/app/windows-build/page.mdx b/docs/src/app/windows-build/page.mdx
new file mode 100644
index 000000000..46d5a695c
--- /dev/null
+++ b/docs/src/app/windows-build/page.mdx
@@ -0,0 +1,98 @@
+# Windows Build
+
+zero-native supports Windows via the WebView2 Runtime. The system engine uses WebView2.
+
+## Prerequisites
+
+- Windows 10 or later with the [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) (included with Windows 11)
+- [Zig](https://ziglang.org/download/) 0.16.0 or later
+
+## Building
+
+```sh
+zig build run -Dplatform=windows
+```
+
+This installs frontend dependencies, builds assets, compiles the binary, and runs the app.
+
+## WebView2 SDK
+
+The generated project looks for the WebView2 SDK at `third_party/webview2-sdk/` by default. You need to vendor the SDK there (one-time setup per project).
+
+### Acquiring the SDK
+
+The WebView2 SDK is distributed as a NuGet package. Download and extract it with PowerShell:
+
+```bash
+Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/1.0.3351.48 -OutFile webview2.zip
+New-Item -ItemType Directory -Path third_party/webview2-sdk -Force
+Expand-Archive -Path webview2.zip -DestinationPath third_party/webview2-sdk
+Remove-Item webview2.zip
+```
+
+`WebView2.h` includes `EventToken.h` (a standard Windows SDK header not shipped in the NuGet). Create it at `third_party/webview2-sdk/build/native/include/EventToken.h`:
+
+```c
+#ifndef _EVENTTOKEN_H_
+#define _EVENTTOKEN_H_
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct EventRegistrationToken {
+ __int64 value;
+} EventRegistrationToken;
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
+```
+
+> This file is normally provided by the Windows SDK. The workaround above is only needed when vendoring the NuGet without Visual Studio or the standalone Windows SDK installed.
+
+Once vendored, no extra flags are needed:
+
+```sh
+zig build run -Dplatform=windows
+```
+
+To use a different SDK path instead of the default, pass `-Dwebview2-sdk-dir`:
+
+```sh
+zig build run -Dplatform=windows -Dwebview2-sdk-dir=path/to/sdk
+```
+
+## App Manifest
+
+The generated `app.zon` includes `"windows"` in `.platforms` and a `.windows` window configuration:
+
+```zig
+.platforms = .{ "macos", "linux", "windows" },
+.windows = .{
+ .{ .label = "main", .title = "My App", .width = 720, .height = 480, .restore_state = true },
+},
+```
+
+## Packaging
+
+```sh
+zig build package -Dplatform=windows -Dpackage-target=windows
+```
+
+Creates a self-contained directory with the binary and assets under `zig-out/package/`. The binary resolves assets relative to its own location inside the package, so the package can be moved as a whole.
+
+To override the frontend directory at runtime, set `ZERO_NATIVE_FRONTEND_DIR`:
+
+```sh
+set ZERO_NATIVE_FRONTEND_DIR=C:\path\to\dist
+testing22.exe
+```
+
+## Diagnostics
+
+```sh
+zero-native doctor --manifest app.zon --web-engine system
+```
diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts
index 2d6d36689..c20e0c897 100644
--- a/docs/src/lib/docs-navigation.ts
+++ b/docs/src/lib/docs-navigation.ts
@@ -22,6 +22,7 @@ export const navSections: NavSection[] = [
title: "Core Concepts",
items: [
{ name: "Web Engines", href: "/web-engines" },
+ { name: "Windows Build", href: "/windows-build" },
{ name: "Windows", href: "/windows" },
{ name: "Bridge", href: "/bridge" },
{ name: "Builtin Commands", href: "/bridge/builtin-commands" },
diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts
index c76696697..08e4733ce 100644
--- a/docs/src/lib/page-titles.ts
+++ b/docs/src/lib/page-titles.ts
@@ -22,6 +22,7 @@ export const PAGE_TITLES: Record = {
extensions: "Extensions",
embed: "Embedded App",
"web-engines": "Web Engines",
+ "windows-build": "Windows Build",
packages: "Package Distribution",
};
diff --git a/examples/hello/app.zon b/examples/hello/app.zon
index fefc13d6f..b2fe2912e 100644
--- a/examples/hello/app.zon
+++ b/examples/hello/app.zon
@@ -4,7 +4,7 @@
.display_name = "Hello",
.version = "0.1.0",
.icons = .{ "assets/icon.icns" },
- .platforms = .{ "macos", "linux" },
+ .platforms = .{ "macos", "linux", "windows" },
.permissions = .{},
.capabilities = .{ "webview" },
.security = .{
diff --git a/examples/next/app.zon b/examples/next/app.zon
index 350802c19..19cee4ca8 100644
--- a/examples/next/app.zon
+++ b/examples/next/app.zon
@@ -4,7 +4,7 @@
.display_name = "Next Example",
.version = "0.1.0",
.icons = .{ "assets/icon.icns" },
- .platforms = .{ "macos", "linux" },
+ .platforms = .{ "macos", "linux", "windows" },
.permissions = .{},
.capabilities = .{ "webview" },
.frontend = .{
diff --git a/examples/react/app.zon b/examples/react/app.zon
index 2e8bb3125..e478ee587 100644
--- a/examples/react/app.zon
+++ b/examples/react/app.zon
@@ -4,7 +4,7 @@
.display_name = "React Example",
.version = "0.1.0",
.icons = .{ "assets/icon.icns" },
- .platforms = .{ "macos", "linux" },
+ .platforms = .{ "macos", "linux", "windows" },
.permissions = .{},
.capabilities = .{ "webview" },
.frontend = .{
diff --git a/examples/svelte/app.zon b/examples/svelte/app.zon
index 245b90128..25b1c42a8 100644
--- a/examples/svelte/app.zon
+++ b/examples/svelte/app.zon
@@ -4,7 +4,7 @@
.display_name = "Svelte Example",
.version = "0.1.0",
.icons = .{ "assets/icon.icns" },
- .platforms = .{ "macos", "linux" },
+ .platforms = .{ "macos", "linux", "windows" },
.permissions = .{},
.capabilities = .{ "webview" },
.frontend = .{
diff --git a/examples/vue/app.zon b/examples/vue/app.zon
index 0eb929bd5..1aed55a43 100644
--- a/examples/vue/app.zon
+++ b/examples/vue/app.zon
@@ -4,7 +4,7 @@
.display_name = "Vue Example",
.version = "0.1.0",
.icons = .{ "assets/icon.icns" },
- .platforms = .{ "macos", "linux" },
+ .platforms = .{ "macos", "linux", "windows" },
.permissions = .{},
.capabilities = .{ "webview" },
.frontend = .{
diff --git a/examples/webview/app.zon b/examples/webview/app.zon
index f816dbc2f..0212b2e4d 100644
--- a/examples/webview/app.zon
+++ b/examples/webview/app.zon
@@ -4,7 +4,7 @@
.display_name = "WebView Example",
.version = "0.1.0",
.icons = .{ "assets/icon.icns" },
- .platforms = .{ "macos", "linux" },
+ .platforms = .{ "macos", "linux", "windows" },
.permissions = .{ "window" },
.capabilities = .{ "webview", "js_bridge" },
.bridge = .{
diff --git a/src/frontend/root.zig b/src/frontend/root.zig
index bdd22a2aa..9ca4cc07a 100644
--- a/src/frontend/root.zig
+++ b/src/frontend/root.zig
@@ -13,6 +13,16 @@ pub fn sourceFromEnv(env_map: *std.process.Environ.Map, config: Config) platform
if (env_map.get(config.dev_url_env)) |url| {
if (url.len > 0) return platform.WebViewSource.url(url);
}
+ if (env_map.get("ZERO_NATIVE_FRONTEND_DIR")) |dir| {
+ if (dir.len > 0) {
+ return platform.WebViewSource.assets(.{
+ .root_path = dir,
+ .entry = config.entry,
+ .origin = config.origin,
+ .spa_fallback = config.spa_fallback,
+ });
+ }
+ }
return productionSource(config);
}
diff --git a/src/platform/root.zig b/src/platform/root.zig
index d9d2be087..f8bc4d0f7 100644
--- a/src/platform/root.zig
+++ b/src/platform/root.zig
@@ -399,7 +399,7 @@ pub const Platform = struct {
};
pub const Backend = enum {
- @"null",
+ null,
macos,
linux,
windows,
@@ -412,7 +412,7 @@ pub const NullPlatform = struct {
requested_frames: u32 = 1,
loaded_source: ?WebViewSource = null,
security_policy: security.Policy = .{},
- window_sources: [max_windows]?WebViewSource = [_]?WebViewSource{null} ** max_windows,
+ window_sources: [max_windows]?WebViewSource = @splat(null),
windows: [max_windows]WindowInfo = undefined,
window_count: usize = 0,
bridge_response: [16 * 1024]u8 = undefined,
diff --git a/src/platform/windows/cef_host.cpp b/src/platform/windows/cef_host.cpp
index 13f244b7e..190c14841 100644
--- a/src/platform/windows/cef_host.cpp
+++ b/src/platform/windows/cef_host.cpp
@@ -1,4 +1,6 @@
// Windows Chromium currently shares the Win32 host surface with the system backend.
// CEF-specific browser creation is isolated behind this translation unit so the
// build can link the CEF runtime and evolve without changing the Zig ABI.
+// When building CEF, we skip the WebView2-specific initialization code.
+#define ZERO_NATIVE_CEF_BUILD
#include "webview2_host.cpp"
diff --git a/src/platform/windows/root.zig b/src/platform/windows/root.zig
index b2df65d3e..44e57b13e 100644
--- a/src/platform/windows/root.zig
+++ b/src/platform/windows/root.zig
@@ -1,3 +1,4 @@
+const std = @import("std");
const geometry = @import("geometry");
const platform_mod = @import("../root.zig");
const policy_values = @import("../policy_values.zig");
@@ -38,6 +39,7 @@ const WindowsEvent = extern struct {
const WindowsCallback = *const fn (context: ?*anyopaque, event: *const WindowsEvent) callconv(.c) void;
const WindowsBridgeCallback = *const fn (context: ?*anyopaque, window_id: u64, message: [*]const u8, message_len: usize, origin: [*]const u8, origin_len: usize) callconv(.c) void;
+const WindowsTrayCallback = *const fn (context: ?*anyopaque, item_id: u32) callconv(.c) void;
extern fn zero_native_windows_create(app_name: [*]const u8, app_name_len: usize, window_title: [*]const u8, window_title_len: usize, bundle_id: [*]const u8, bundle_id_len: usize, icon_path: [*]const u8, icon_path_len: usize, window_label: [*]const u8, window_label_len: usize, x: f64, y: f64, width: f64, height: f64, restore_frame: c_int) ?*WindowsHost;
extern fn zero_native_windows_destroy(host: *WindowsHost) void;
@@ -56,6 +58,58 @@ extern fn zero_native_windows_close_window(host: *WindowsHost, window_id: u64) c
extern fn zero_native_windows_clipboard_read(host: *WindowsHost, buffer: [*]u8, buffer_len: usize) usize;
extern fn zero_native_windows_clipboard_write(host: *WindowsHost, text: [*]const u8, text_len: usize) void;
+const WindowsOpenDialogOpts = extern struct {
+ title: [*]const u8,
+ title_len: usize,
+ default_path: [*]const u8,
+ default_path_len: usize,
+ extensions: [*]const u8,
+ extensions_len: usize,
+ allow_directories: c_int,
+ allow_multiple: c_int,
+};
+
+const WindowsOpenDialogResult = extern struct {
+ count: usize,
+ bytes_written: usize,
+};
+
+const WindowsSaveDialogOpts = extern struct {
+ title: [*]const u8,
+ title_len: usize,
+ default_path: [*]const u8,
+ default_path_len: usize,
+ default_name: [*]const u8,
+ default_name_len: usize,
+ extensions: [*]const u8,
+ extensions_len: usize,
+};
+
+const WindowsMessageDialogOpts = extern struct {
+ style: c_int,
+ title: [*]const u8,
+ title_len: usize,
+ message: [*]const u8,
+ message_len: usize,
+ informative_text: [*]const u8,
+ informative_text_len: usize,
+ primary_button: [*]const u8,
+ primary_button_len: usize,
+ secondary_button: [*]const u8,
+ secondary_button_len: usize,
+ tertiary_button: [*]const u8,
+ tertiary_button_len: usize,
+};
+
+extern fn zero_native_windows_show_open_dialog(host: *WindowsHost, opts: *const WindowsOpenDialogOpts, buffer: [*]u8, buffer_len: usize) WindowsOpenDialogResult;
+extern fn zero_native_windows_show_save_dialog(host: *WindowsHost, opts: *const WindowsSaveDialogOpts, buffer: [*]u8, buffer_len: usize) usize;
+extern fn zero_native_windows_show_message_dialog(host: *WindowsHost, opts: *const WindowsMessageDialogOpts) c_int;
+
+extern fn zero_native_windows_create_tray(host: *WindowsHost, icon_path: [*]const u8, icon_path_len: usize, tooltip: [*]const u8, tooltip_len: usize) void;
+extern fn zero_native_windows_update_tray_menu(host: *WindowsHost, item_ids: [*]const u32, labels: [*]const [*]const u8, label_lens: [*]const usize, separators: [*]const c_int, enabled_flags: [*]const c_int, count: usize) void;
+extern fn zero_native_windows_remove_tray(host: *WindowsHost) void;
+extern fn zero_native_windows_set_tray_callback(host: *WindowsHost, callback: WindowsTrayCallback, context: ?*anyopaque) void;
+
pub const WindowsPlatform = struct {
host: *WindowsHost,
web_engine: platform_mod.WebEngine,
@@ -109,6 +163,12 @@ pub const WindowsPlatform = struct {
.create_window_fn = createWindow,
.focus_window_fn = focusWindow,
.close_window_fn = closeWindow,
+ .show_open_dialog_fn = showOpenDialog,
+ .show_save_dialog_fn = showSaveDialog,
+ .show_message_dialog_fn = showMessageDialog,
+ .create_tray_fn = createTray,
+ .update_tray_menu_fn = updateTrayMenu,
+ .remove_tray_fn = removeTray,
.configure_security_policy_fn = configureSecurityPolicy,
.emit_window_event_fn = emitWindowEvent,
},
@@ -124,6 +184,7 @@ pub const WindowsPlatform = struct {
.handler_context = handler_context,
};
zero_native_windows_set_bridge_callback(self.host, windowsBridgeCallback, &self.state);
+ zero_native_windows_set_tray_callback(self.host, windowsTrayCallback, &self.state);
zero_native_windows_run(self.host, windowsCallback, &self.state);
if (self.state.failed) return error.CallbackFailed;
}
@@ -198,6 +259,11 @@ fn windowsBridgeCallback(context: ?*anyopaque, window_id: u64, message: [*]const
} });
}
+fn windowsTrayCallback(context: ?*anyopaque, item_id: u32) callconv(.c) void {
+ const state: *RunState = @ptrCast(@alignCast(context.?));
+ state.emit(.{ .tray_action = item_id });
+}
+
fn readClipboard(context: ?*anyopaque, buffer: []u8) anyerror![]const u8 {
const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
return buffer[0..zero_native_windows_clipboard_read(self.host, buffer.ptr, buffer.len)];
@@ -292,6 +358,140 @@ fn configureSecurityPolicy(context: ?*anyopaque, policy: security.Policy) anyerr
);
}
+fn showOpenDialog(context: ?*anyopaque, options: platform_mod.OpenDialogOptions, buffer: []u8) anyerror!platform_mod.OpenDialogResult {
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
+ var ext_buf: [1024]u8 = undefined;
+ const ext_str = flattenFilters(options.filters, &ext_buf);
+ const opts = WindowsOpenDialogOpts{
+ .title = options.title.ptr,
+ .title_len = options.title.len,
+ .default_path = options.default_path.ptr,
+ .default_path_len = options.default_path.len,
+ .extensions = ext_str.ptr,
+ .extensions_len = ext_str.len,
+ .allow_directories = if (options.allow_directories) 1 else 0,
+ .allow_multiple = if (options.allow_multiple) 1 else 0,
+ };
+ const result = zero_native_windows_show_open_dialog(self.host, &opts, buffer.ptr, buffer.len);
+ return .{
+ .count = result.count,
+ .paths = buffer[0..result.bytes_written],
+ };
+}
+
+fn showSaveDialog(context: ?*anyopaque, options: platform_mod.SaveDialogOptions, buffer: []u8) anyerror!?[]const u8 {
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
+ var ext_buf: [1024]u8 = undefined;
+ const ext_str = flattenFilters(options.filters, &ext_buf);
+ const opts = WindowsSaveDialogOpts{
+ .title = options.title.ptr,
+ .title_len = options.title.len,
+ .default_path = options.default_path.ptr,
+ .default_path_len = options.default_path.len,
+ .default_name = options.default_name.ptr,
+ .default_name_len = options.default_name.len,
+ .extensions = ext_str.ptr,
+ .extensions_len = ext_str.len,
+ };
+ const written = zero_native_windows_show_save_dialog(self.host, &opts, buffer.ptr, buffer.len);
+ if (written == 0) return null;
+ return buffer[0..written];
+}
+
+fn showMessageDialog(context: ?*anyopaque, options: platform_mod.MessageDialogOptions) anyerror!platform_mod.MessageDialogResult {
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
+ // Build filter string from all filters (flattenFilters not used here; message dialog doesn't have file filters)
+ const opts = WindowsMessageDialogOpts{
+ .style = @intFromEnum(options.style),
+ .title = options.title.ptr,
+ .title_len = options.title.len,
+ .message = options.message.ptr,
+ .message_len = options.message.len,
+ .informative_text = options.informative_text.ptr,
+ .informative_text_len = options.informative_text.len,
+ .primary_button = options.primary_button.ptr,
+ .primary_button_len = options.primary_button.len,
+ .secondary_button = options.secondary_button.ptr,
+ .secondary_button_len = options.secondary_button.len,
+ .tertiary_button = options.tertiary_button.ptr,
+ .tertiary_button_len = options.tertiary_button.len,
+ };
+ const result = zero_native_windows_show_message_dialog(self.host, &opts);
+ return @enumFromInt(result);
+}
+
+fn createTray(context: ?*anyopaque, options: platform_mod.TrayOptions) anyerror!void {
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
+ zero_native_windows_create_tray(self.host, options.icon_path.ptr, options.icon_path.len, options.tooltip.ptr, options.tooltip.len);
+ if (options.items.len > 0) {
+ try updateTrayMenu(context, options.items);
+ }
+}
+
+fn updateTrayMenu(context: ?*anyopaque, items: []const platform_mod.TrayMenuItem) anyerror!void {
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
+ const max_items: usize = 32;
+ const count = @min(items.len, max_items);
+ var ids: [max_items]u32 = undefined;
+ var labels_ptr: [max_items][*]const u8 = undefined;
+ var label_lens: [max_items]usize = undefined;
+ var separators: [max_items]c_int = undefined;
+ var enabled_flags: [max_items]c_int = undefined;
+ for (items[0..count], 0..) |item, i| {
+ ids[i] = item.id;
+ labels_ptr[i] = item.label.ptr;
+ label_lens[i] = item.label.len;
+ separators[i] = if (item.separator) 1 else 0;
+ enabled_flags[i] = if (item.enabled) 1 else 0;
+ }
+ zero_native_windows_update_tray_menu(self.host, &ids, &labels_ptr, &label_lens, &separators, &enabled_flags, count);
+}
+
+fn removeTray(context: ?*anyopaque) anyerror!void {
+ const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
+ zero_native_windows_remove_tray(self.host);
+}
+
+fn flattenFilters(filters: []const platform_mod.FileFilter, buffer: []u8) []const u8 {
+ var offset: usize = 0;
+ for (filters) |filter| {
+ for (filter.extensions) |ext| {
+ if (offset > 0 and offset < buffer.len) {
+ buffer[offset] = ';';
+ offset += 1;
+ }
+ const end = @min(offset + ext.len, buffer.len);
+ if (end > offset) {
+ @memcpy(buffer[offset..end], ext[0..(end - offset)]);
+ offset = end;
+ }
+ }
+ }
+ return buffer[0..offset];
+}
+
test "windows platform module exports type" {
_ = WindowsPlatform;
}
+
+test "windows event kind count matches expectation" {
+ // Ensure we have the right number of event kinds
+ const kinds = [_]WindowsEventKind{ .start, .frame, .shutdown, .resize, .window_frame };
+ try std.testing.expectEqual(@as(usize, 5), kinds.len);
+}
+
+test "windows open dialog opts has correct layout" {
+ try std.testing.expectEqual(@as(usize, 8), @typeInfo(WindowsOpenDialogOpts).@"struct".fields.len);
+}
+
+test "windows save dialog opts has correct layout" {
+ try std.testing.expectEqual(@as(usize, 8), @typeInfo(WindowsSaveDialogOpts).@"struct".fields.len);
+}
+
+test "windows message dialog opts has correct layout" {
+ try std.testing.expectEqual(@as(usize, 13), @typeInfo(WindowsMessageDialogOpts).@"struct".fields.len);
+}
+
+test "refAllDecls" {
+ std.testing.refAllDecls(@This());
+}
\ No newline at end of file
diff --git a/src/platform/windows/webview2_host.cpp b/src/platform/windows/webview2_host.cpp
index ca2113e47..1f0e8e011 100644
--- a/src/platform/windows/webview2_host.cpp
+++ b/src/platform/windows/webview2_host.cpp
@@ -1,13 +1,37 @@
#include
#include
+#include
+#include
+#include
#include
#include
#include
+#include
#include