| Open URL in system browser |
- Cmd.openExternalUrl(url) / runtime.openExternalUrl(url) |
+ Cmd.openExternalUrl(url) / fx.openUrl(url) / runtime.openExternalUrl(url) |
native-sdk.os.openUrl |
network |
macOS, Linux, and Windows model cores and system WebView; macOS Chromium |
diff --git a/docs/src/app/docs/native-ui/page.mdx b/docs/src/app/docs/native-ui/page.mdx
index e6428f2df..7d6932966 100644
--- a/docs/src/app/docs/native-ui/page.mdx
+++ b/docs/src/app/docs/native-ui/page.mdx
@@ -471,6 +471,12 @@ case "build_finished":
+Handing a URL to the user's default handler is the same fire-and-forget shape: `fx.openUrl(url)` from a Zig `update_fx` arm asks the OS to open it — the browser for `http`/`https`, the mail client for `mailto` — through the platform verb the [bridge](/docs/bridge/builtin-commands) exposes to web content as `native-sdk.os.openUrl`. The URL is treated as hostile, because cores build them from terminal output, fetch bodies, and pastes: schemes are an allowlist (`http`, `https`, `mailto`, matched case-insensitively), and an empty URL, one past the 4 KiB bound, one carrying a NUL or any other control byte, or one naming an unvetted scheme — `file:` and `javascript:` included — is refused whole rather than trimmed into something openable. Unlike `runtime.openExternalUrl`, it is not gated on the webview external-link policy: that policy governs links *web content* follows, while this call comes from the app's own `update`. Fake execution and session replay never open anything.
+
+```zig
+.open_docs => fx.openUrl("https://example.com/docs/start"),
+```
+
Failure and overflow are always visible: a spawn that cannot run delivers an exit Msg with reason `rejected`, a fetch that cannot run delivers a response Msg with outcome `rejected`, and a file effect that cannot run delivers a result Msg with outcome `rejected`; dropped or truncated lines carry counts and flags; `cancel` kills and reaps the process and always ends in exactly one `cancelled` exit Msg, with no further line Msgs after it. Tests use the fake executor (`effects.executor = .fake`) to assert on spawn, fetch, and file requests and feed synthetic lines, stderr (`feedStderr`, collect spawns), exits, responses, and file results back deterministically — set it before the first frame and `init_fx` boot spawns are recorded too. See `examples/effects-probe`.
For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std.Io`, which `update` never sees): `native_sdk.nowMs()` / `nowNanoseconds()` read the wall clock and `monotonicMs()` / `monotonicNanoseconds()` the duration clock. Time-dependent logic stores the `native_sdk.Clock` seam in the model (`.system` by default) so tests substitute a deterministic `native_sdk.TestClock` and advance it by hand.
diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig
index ce420b003..eaf80f791 100644
--- a/src/runtime/effects.zig
+++ b/src/runtime/effects.zig
@@ -17,7 +17,11 @@
//! fire-and-forget exception: the OS owns delivery after the one
//! loop-thread platform call, so there is no meaningful terminal Msg.
//! Invalid requests and unavailable services fail closed; fake execution
-//! and session replay never emit a real notification. Clipboard effects
+//! and session replay never emit a real notification. Opening a URL in
+//! the user's default handler (`openUrl`) is the same shape, with the
+//! same reasoning, over an ALLOWLIST of schemes (http, https, mailto) —
+//! app cores hand it untrusted bytes, so anything else is a whole
+//! no-op. Clipboard effects
//! (`writeClipboard`/`readClipboard`) keep
//! the same shape over the platform pasteboard — the seam the
//! runtime's cmd+C copy uses — executed synchronously on the loop
@@ -8597,6 +8601,44 @@ pub fn Effects(comptime Msg: type) type {
services.showNotification(options) catch {};
}
+ /// Ask the desktop host to open `url` in the user's default
+ /// handler — the browser for `http`/`https`, the mail client for
+ /// `mailto`. This is the `update`-side seam for the same
+ /// platform verb the webview bridge exposes as
+ /// `native-sdk.os.openUrl`; before it, a Zig core with no
+ /// webview had no way to reach it. Fire-and-forget for the same
+ /// reason `showNotification` is: the OS owns whether a handler
+ /// actually launched, so no success Msg would be truthful.
+ ///
+ /// The URL is treated as HOSTILE — apps hand this bytes that
+ /// came from terminal output, fetch bodies, and pastes — and is
+ /// validated against an ALLOWLIST of schemes
+ /// (`validation.open_url_schemes`: http, https, mailto) before
+ /// the platform sees it. Empty, over-bound
+ /// (`platform.max_external_url_bytes`), NUL- or control-byte-
+ /// bearing, and unrecognised-scheme URLs — `file:` and
+ /// `javascript:` among them — fail closed: a refused request is
+ /// a whole no-op, never a trimmed or coerced open. Nothing
+ /// reaches the platform, so a test's null platform records
+ /// nothing (`lastExternalUrl()`), which is how a rejection is
+ /// observed.
+ ///
+ /// Unlike the runtime's `openExternalUrl`, this is NOT gated on
+ /// the app's webview external-link policy: that policy governs
+ /// links WEB CONTENT follows, and this call comes from the app's
+ /// own `update`.
+ ///
+ /// The call runs synchronously on the loop thread, where the
+ /// platform launch services expect to be entered. Fake execution
+ /// and session replay suppress the platform call so tests stay
+ /// hermetic and a replay never reopens an external window.
+ pub fn openUrl(self: *Self, url: []const u8) void {
+ if (self.executor == .fake or self.replay) return;
+ validation.validateOpenUrl(url) catch return;
+ const services = self.services orelse return;
+ services.openExternalUrl(url) catch {};
+ }
+
/// Put text on the system clipboard through the platform
/// pasteboard — the same seam the runtime's cmd+C copy uses —
/// and deliver exactly one terminal Msg with an explicit
diff --git a/src/runtime/effects_open_url_tests.zig b/src/runtime/effects_open_url_tests.zig
new file mode 100644
index 000000000..474054a23
--- /dev/null
+++ b/src/runtime/effects_open_url_tests.zig
@@ -0,0 +1,121 @@
+//! Open-URL effect coverage: `fx.openUrl` reaches the platform's
+//! external-URL service on the loop thread, refuses hostile input
+//! (unvetted schemes, control bytes, over-bound URLs) whole before the
+//! platform sees any of it, and stays inert under fake execution and
+//! session replay so tests/replays never launch a browser.
+
+const std = @import("std");
+const platform = @import("../platform/root.zig");
+const effects_mod = @import("effects.zig");
+
+const Msg = enum { unused };
+const TestEffects = effects_mod.Effects(Msg);
+
+test "open-url effect hands an allowed URL to the platform service" {
+ var null_platform = platform.NullPlatform.init(.{});
+ var host = null_platform.platform();
+ var fx = TestEffects.init(std.testing.allocator);
+ defer fx.deinit();
+ fx.bindServices(&host.services);
+
+ fx.openUrl("https://example.com/docs/start");
+ try std.testing.expectEqualStrings("https://example.com/docs/start", null_platform.lastExternalUrl());
+
+ // The other vetted schemes ride the same path.
+ fx.openUrl("http://example.com/plain");
+ try std.testing.expectEqualStrings("http://example.com/plain", null_platform.lastExternalUrl());
+ fx.openUrl("mailto:hello@example.com");
+ try std.testing.expectEqualStrings("mailto:hello@example.com", null_platform.lastExternalUrl());
+
+ // Schemes are case-insensitive per RFC 3986, so the allowlist
+ // matches them that way rather than refusing a legitimate URL.
+ fx.openUrl("HTTPS://example.com/shouty");
+ try std.testing.expectEqualStrings("HTTPS://example.com/shouty", null_platform.lastExternalUrl());
+}
+
+test "open-url effect refuses a javascript: URL" {
+ var null_platform = platform.NullPlatform.init(.{});
+ var host = null_platform.platform();
+ var fx = TestEffects.init(std.testing.allocator);
+ defer fx.deinit();
+ fx.bindServices(&host.services);
+
+ fx.openUrl("javascript:alert(1)");
+ // Case games do not widen an allowlist.
+ fx.openUrl("JavaScript:alert(1)");
+
+ try std.testing.expectEqualStrings("", null_platform.lastExternalUrl());
+}
+
+test "open-url effect refuses a file: URL" {
+ var null_platform = platform.NullPlatform.init(.{});
+ var host = null_platform.platform();
+ var fx = TestEffects.init(std.testing.allocator);
+ defer fx.deinit();
+ fx.bindServices(&host.services);
+
+ fx.openUrl("file:///etc/passwd");
+ fx.openUrl("FILE:///etc/passwd");
+
+ try std.testing.expectEqualStrings("", null_platform.lastExternalUrl());
+}
+
+test "open-url effect refuses an over-long URL" {
+ var null_platform = platform.NullPlatform.init(.{});
+ var host = null_platform.platform();
+ var fx = TestEffects.init(std.testing.allocator);
+ defer fx.deinit();
+ fx.bindServices(&host.services);
+
+ var long_url: [platform.max_external_url_bytes + 1]u8 = undefined;
+ const prefix = "https://example.com/";
+ @memcpy(long_url[0..prefix.len], prefix);
+ @memset(long_url[prefix.len..], 'x');
+ fx.openUrl(&long_url);
+
+ // Rejected WHOLE: the bound never truncates a URL into a shorter
+ // one the OS would happily open.
+ try std.testing.expectEqualStrings("", null_platform.lastExternalUrl());
+
+ // One byte under the bound still rides through, so the rejection
+ // above is the bound and not a broken path.
+ fx.openUrl(long_url[0..platform.max_external_url_bytes]);
+ try std.testing.expectEqual(platform.max_external_url_bytes, null_platform.lastExternalUrl().len);
+}
+
+test "open-url effect refuses an embedded NUL and other control bytes" {
+ var null_platform = platform.NullPlatform.init(.{});
+ var host = null_platform.platform();
+ var fx = TestEffects.init(std.testing.allocator);
+ defer fx.deinit();
+ fx.bindServices(&host.services);
+
+ fx.openUrl("https://example.com/\x00javascript:alert(1)");
+ fx.openUrl("https://example.com/ ok");
+ fx.openUrl("https://example.com/\nfollow");
+ fx.openUrl("");
+ fx.openUrl("https://");
+ fx.openUrl("ftp://example.com/file.zip");
+
+ try std.testing.expectEqualStrings("", null_platform.lastExternalUrl());
+}
+
+test "open-url effect is inert without a service and during fake execution or replay" {
+ var unbound = TestEffects.init(std.testing.allocator);
+ defer unbound.deinit();
+ unbound.openUrl("https://example.com/no-host");
+
+ var null_platform = platform.NullPlatform.init(.{});
+ var host = null_platform.platform();
+ var fx = TestEffects.init(std.testing.allocator);
+ defer fx.deinit();
+ fx.bindServices(&host.services);
+
+ fx.executor = .fake;
+ fx.openUrl("https://example.com/fake");
+ try std.testing.expectEqualStrings("", null_platform.lastExternalUrl());
+
+ fx.armReplay();
+ fx.openUrl("https://example.com/replay");
+ try std.testing.expectEqualStrings("", null_platform.lastExternalUrl());
+}
diff --git a/src/runtime/tests.zig b/src/runtime/tests.zig
index dbc7411aa..4d429d42f 100644
--- a/src/runtime/tests.zig
+++ b/src/runtime/tests.zig
@@ -21,6 +21,7 @@ test {
_ = @import("effects_file_tests.zig");
_ = @import("effects_clipboard_tests.zig");
_ = @import("effects_notification_tests.zig");
+ _ = @import("effects_open_url_tests.zig");
_ = @import("effects_audio_tests.zig");
_ = @import("effects_audio_capture_tests.zig");
_ = @import("effects_video_tests.zig");
diff --git a/src/runtime/validation.zig b/src/runtime/validation.zig
index d5b7110e6..341431242 100644
--- a/src/runtime/validation.zig
+++ b/src/runtime/validation.zig
@@ -12,6 +12,43 @@ pub fn validateCommandName(name: []const u8) !void {
}
}
+/// The schemes an app-facing open-URL request may name, matched
+/// case-insensitively against the front of the URL (schemes are
+/// case-insensitive per RFC 3986). An ALLOWLIST, not a denylist:
+/// `file:` (hands the local disk to whatever handler claims it) and
+/// `javascript:` (runs code inside the receiving handler) are refused by
+/// simply not appearing, and so is every scheme nobody has vetted. A URL
+/// assembled from untrusted bytes — terminal output, a fetch body, a
+/// paste — can only reach the OS by matching an entry here.
+pub const open_url_schemes = [_][]const u8{ "http://", "https://", "mailto:" };
+
+/// Validate a URL the app asked the OS to open in the user's default
+/// handler (`Effects.openUrl`). Bounded by
+/// `platform.max_external_url_bytes`, the same bound the webview
+/// bridge's `native-sdk.os.openUrl` enforces. Every failure rejects the
+/// URL WHOLE — nothing here trims, escapes, or coerces a malformed URL
+/// into a valid one.
+pub fn validateOpenUrl(url: []const u8) !void {
+ if (url.len == 0) return error.InvalidOpenUrl;
+ if (url.len > platform.max_external_url_bytes) return error.OpenUrlTooLarge;
+ // A NUL truncates the URL at the C boundary every host crosses, and
+ // control bytes, whitespace, and DEL never appear in a well-formed
+ // URL — the whole class is refused rather than stripped, so a
+ // "https://ok\x00javascript:..." style splice cannot survive as its
+ // prefix.
+ for (url) |ch| {
+ if (ch <= 0x20 or ch == 0x7f) return error.InvalidOpenUrl;
+ }
+ for (open_url_schemes) |scheme| {
+ if (!std.ascii.startsWithIgnoreCase(url, scheme)) continue;
+ // A bare scheme names no target; only a scheme with something
+ // after it is worth handing to the OS.
+ if (url.len == scheme.len) return error.InvalidOpenUrl;
+ return;
+ }
+ return error.UnsupportedOpenUrlScheme;
+}
+
pub fn validateRevealPath(path: []const u8) !void {
if (path.len == 0) return error.InvalidRevealPath;
if (path.len > platform.max_reveal_path_bytes) return error.RevealPathTooLarge;