diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4556d6901..aa43ebd7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,7 @@ jobs: - run: zig build test-gpu-components-smoke env: NATIVE_SDK_SMOKE_BUDGET_MS: "1500" + NATIVE_SDK_INPUT_LATENCY_BUDGET_MS: "500" macos-gpu-perf: name: macOS GPU Perf diff --git a/packages/core/compile-surface/core.ts b/packages/core/compile-surface/core.ts index 838272fe6..a3c0f940f 100644 --- a/packages/core/compile-surface/core.ts +++ b/packages/core/compile-surface/core.ts @@ -518,6 +518,7 @@ export type CmdData = readonly value: number; } | { readonly op: "window_show"; readonly label: string } + | { readonly op: "webview_navigate"; readonly label: string; readonly url: Uint8Array } | { readonly op: "window_hide"; readonly label: string } | { readonly op: "dock_presence"; readonly visible: boolean } | { readonly op: "quit_app" } @@ -981,6 +982,10 @@ export const Cmd = { return { op: "window_show", label }; }, + navigateWebView(label: string, url: Uint8Array): CmdData { + return { op: "webview_navigate", label, url }; + }, + hideWindow(label: string): CmdData { return { op: "window_hide", label }; }, diff --git a/packages/core/sdk/core.d.ts b/packages/core/sdk/core.d.ts index 336ca90be..b1d6b8310 100644 --- a/packages/core/sdk/core.d.ts +++ b/packages/core/sdk/core.d.ts @@ -479,6 +479,10 @@ export type Cmd = { } | { readonly op: "window_show"; readonly label: string; +} | { + readonly op: "webview_navigate"; + readonly label: string; + readonly url: Uint8Array; } | { readonly op: "window_hide"; readonly label: string; @@ -615,6 +619,7 @@ export declare const Cmd: { videoSetMuted(key: string, muted: boolean): Cmd; videoSetLoop(key: string, loop: boolean): Cmd; showWindow(label: string): Cmd; + navigateWebView(label: string, url: Uint8Array): Cmd; hideWindow(label: string): Cmd; setDockPresence(visible: boolean): Cmd; launchAtLoginStatus(route: RequestRoute): Cmd; diff --git a/packages/core/sdk/core.ts b/packages/core/sdk/core.ts index 29e08e92c..a51b73add 100644 --- a/packages/core/sdk/core.ts +++ b/packages/core/sdk/core.ts @@ -249,6 +249,9 @@ // "Open" consequence; also restores a // minimized window. An unknown label is a // no-op. +// Cmd.navigateWebView(label, url) +// navigate a declared child WebView in the +// main window (fire-and-forget). // Cmd.hideWindow(label) order a live window out while retaining // its views; showWindow is the inverse. // Cmd.setDockPresence(visible) show or remove the app from the macOS @@ -1344,6 +1347,7 @@ export type Cmd = readonly value: number; } | { readonly op: "window_show"; readonly label: string } + | { readonly op: "webview_navigate"; readonly label: string; readonly url: Uint8Array } | { readonly op: "window_hide"; readonly label: string } | { readonly op: "dock_presence"; readonly visible: boolean } | { readonly op: "quit_app" } @@ -1999,6 +2003,15 @@ export const Cmd = { return { op: "window_show", label }; }, + /// Navigate a declared child WebView in the main window. The runtime + /// validates the label, rejects the main WebView, and applies the same + /// navigation origin policy used by declarative WebView updates. The + /// command is fire-and-forget; invalid or denied requests do not crash + /// the update loop. Passing the current URL again forces a reload. + navigateWebView(label: string, url: Uint8Array): Cmd { + return { op: "webview_navigate", label, url }; + }, + /// Hide a live window without closing it. Its views and native identity /// remain intact; `showWindow` brings it back. Unknown labels no-op. hideWindow(label: string): Cmd { diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index 5e689f7cd..5e2595efe 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -81,6 +81,8 @@ const credentials_store = @import("credentials_store.zig"); const file_access = @import("file_access.zig"); const pty_transport = @import("pty.zig"); +const effects_log = std.log.scoped(.zero_effects); + /// Maximum in-flight effects (spawn slots / worker threads). pub const max_effects: usize = 16; /// Record-store effects have their own capacity: a large batch or a busy @@ -378,6 +380,17 @@ pub const WindowActionBinding = struct { quit_fn: *const fn (context: *anyopaque) bool, }; +/// Type-erased handle for navigating a declared child WebView from a +/// TypeScript core command. The target window is supplied by `UiApp` and is +/// refreshed whenever the canvas window identity changes; the callback uses +/// the runtime's existing `updateView` path so label, target, URL-policy, and +/// platform validation stay centralized. +pub const WebViewActionBinding = struct { + context: *anyopaque, + window_id: platform.WindowId, + navigate_fn: *const fn (context: *anyopaque, window_id: platform.WindowId, label: []const u8, url: []const u8) bool, +}; + /// Runtime-owned platform-service entry points used by the intrinsic /// `native-sdk.*` named commands. Binding the Runtime methods—not the raw /// PlatformServices table—keeps validation and external-link policy in the @@ -470,6 +483,34 @@ pub const WindowActionState = struct { } }; +/// The WebView-navigation mirror records the last fire-and-forget request so +/// fake execution and session replay remain observable without a platform +/// WebView. The live callback is still invoked in real mode. +pub const WebViewActionState = struct { + navigate_count: u32 = 0, + label_buffer: [platform.max_webview_label_bytes]u8 = @splat(0), + label_len: usize = 0, + url_buffer: [platform.max_webview_url_bytes]u8 = @splat(0), + url_len: usize = 0, + + pub fn label(self: *const WebViewActionState) []const u8 { + return self.label_buffer[0..self.label_len]; + } + + pub fn url(self: *const WebViewActionState) []const u8 { + return self.url_buffer[0..self.url_len]; + } + + fn record(self: *WebViewActionState, requested_label: []const u8, requested_url: []const u8) void { + const label_len = @min(requested_label.len, self.label_buffer.len); + @memcpy(self.label_buffer[0..label_len], requested_label[0..label_len]); + self.label_len = label_len; + const url_len = @min(requested_url.len, self.url_buffer.len); + @memcpy(self.url_buffer[0..url_len], requested_url[0..url_len]); + self.url_len = url_len; + } +}; + /// How a spawn's stdout comes back. `.lines` streams each line as an /// `on_line` Msg as it arrives (the default; long-running streams). /// `.collect` accumulates whole stdout — single-line JSON far beyond the @@ -4854,6 +4895,10 @@ pub fn Effects(comptime Msg: type) type { /// by `UiApp` alongside the services — the seam behind /// app-drawn window controls (loop-thread only). window_actions: ?WindowActionBinding = null, + /// The runtime's declared child-WebView navigation seam. Unlike + /// window actions, the target window id is refreshed by UiApp when + /// the canvas window identity becomes known. + webview_actions: ?WebViewActionBinding = null, /// Runtime system services with their validation/policy layer intact. /// Intrinsic platform commands use this binding; generic host calls /// continue to use `host_calls` below. @@ -4881,6 +4926,8 @@ pub fn Effects(comptime Msg: type) type { /// Window-action mirror: counts and the last requested label, /// observable in tests (`windowActionState`). window_action_state: WindowActionState = .{}, + /// WebView-navigation mirror, observable in tests. + webview_action_state: WebViewActionState = .{}, /// The environment spawned children inherit and fetch honors /// (PATH for `spawnPath`-style lookups, proxy variables). /// Bound once from the loop thread before the first real @@ -6234,6 +6281,13 @@ pub fn Effects(comptime Msg: type) type { if (self.window_actions == null) self.window_actions = binding; } + /// Bind the runtime-owned WebView navigation seam. The runtime + /// context and callback are stable, while UiApp may refresh the + /// target canvas window id after the first frame event. + pub fn bindWebViewActions(self: *Self, binding: WebViewActionBinding) void { + self.webview_actions = binding; + } + /// Bind runtime-validated platform services for the SDK-reserved /// named command family. Loop-thread only; the first bind sticks. pub fn bindSystemServices(self: *Self, binding: SystemServiceBinding) void { @@ -10401,6 +10455,21 @@ pub fn Effects(comptime Msg: type) type { _ = binding.quit_fn(binding.context); } + /// Navigate a declared child WebView in the bound canvas window. + /// Fire-and-forget: fake/replay records the request, while real mode + /// invokes the runtime callback. Invalid labels, the main WebView, + /// missing views, and denied origins fail closed and are logged by + /// the callback owner without aborting the update loop. + pub fn navigateWebView(self: *Self, label: []const u8, url: []const u8) void { + self.webview_action_state.navigate_count += 1; + self.webview_action_state.record(label, url); + if (self.executor == .fake) return; + const binding = self.webview_actions orelse return; + if (!binding.navigate_fn(binding.context, binding.window_id, label, url)) { + effects_log.warn("WebView navigation rejected for label '{s}'", .{label}); + } + } + /// The window-action mirror, for tests: how many close/minimize/ /// show/quit requests rode the channel and the last label /// requested. @@ -10408,6 +10477,11 @@ pub fn Effects(comptime Msg: type) type { return self.window_action_state; } + /// The WebView-navigation mirror, for tests and replay diagnostics. + pub fn webViewActionState(self: *const Self) WebViewActionState { + return self.webview_action_state; + } + /// Set playback volume, clamped to 0.0—1.0. Remembered across /// tracks: the next `playAudio` re-applies it. pub fn setAudioVolume(self: *Self, volume: f32) void { diff --git a/src/runtime/ts_core_host.zig b/src/runtime/ts_core_host.zig index becd14fd0..9b0a174d3 100644 --- a/src/runtime/ts_core_host.zig +++ b/src/runtime/ts_core_host.zig @@ -297,6 +297,11 @@ //! activate (the tray "Open" consequence of the //! menu-bar-app loop). No result Msg; the window's own //! frame event carries the state. +//! webview_navigate -> `fx.navigateWebView(label, url)` — fire-and- +//! forget navigation of a declared child WebView in the +//! main window. The runtime applies the normal WebView +//! label, target, and origin-policy checks; invalid or +//! denied requests do not abort dispatch. //! window_hide -> `fx.hideWindow(label)` — retain the live window and //! its views while ordering it out; window_show is the //! inverse. @@ -1243,6 +1248,13 @@ pub fn TsCoreHost(comptime core: type) type { const label = takeShortBytes(cmd, &at); fx.showWindow(label); }, + // webview_navigate [op][label_len][label] + // [url_len u32 LE][url] + 0x33 => { + const label = takeShortBytes(cmd, &at); + const url = takeLongBytes(cmd, &at); + fx.navigateWebView(label, url); + }, // quit_app [op] 0x11 => fx.quitApp(), // image_load [op][id f64 LE][event_tag] diff --git a/src/runtime/ts_core_host_tests.zig b/src/runtime/ts_core_host_tests.zig index d2af0316c..26d8e3e33 100644 --- a/src/runtime/ts_core_host_tests.zig +++ b/src/runtime/ts_core_host_tests.zig @@ -12,6 +12,7 @@ const std = @import("std"); const effects_mod = @import("effects.zig"); const runtime_clock = @import("clock.zig"); const ts_core_host = @import("ts_core_host.zig"); +const platform = @import("../platform/root.zig"); // ------------------------------------------------------ the mini core // @@ -305,9 +306,10 @@ const mini_core = struct { uget, // 81: fetch "uget" -> ufetched/failed ufetched: struct { status: u64, body: []const u8 }, // 82: fetch ok // record with a u64-classed number field - start_capture, // 83: microphone capture key 91 -> capture_evt - stop_capture, // 84: stop capture key 91 - capture_evt: struct { // 85: ten-field capture event arm + navigate_webview, // 83: webview_navigate child URL + start_capture, // 84: microphone capture key 91 -> capture_evt + stop_capture, // 85: stop capture key 91 + capture_evt: struct { // 86: ten-field capture event arm key: f64, state: CaptureState, source: CaptureSource, @@ -687,6 +689,7 @@ const mini_core = struct { .hide_win => return .{ .model = model, .cmd = cmdWindowHide("player") }, .dock_off => return .{ .model = model, .cmd = cmdDockPresence(false) }, .quit_app => return .{ .model = model, .cmd = cmdQuitApp() }, + .navigate_webview => return .{ .model = model, .cmd = cmdWebViewNavigate("preview", "https://status.test/page") }, .open_chan => return .{ .model = model, .cmd = cmdChannelOpen(41, 47) }, .close_chan => return .{ .model = model, .cmd = cmdChannelClose(41) }, .chan_evt => |event| { @@ -1186,6 +1189,15 @@ const mini_core = struct { return out; } + fn cmdWebViewNavigate(label: []const u8, url: []const u8) []const u8 { + const out = rt.frameAlloc(u8, 2 + label.len + 4 + url.len); + out[0] = 0x33; + out[1] = @intCast(label.len); + @memcpy(out[2..][0..label.len], label); + _ = writeLongBytes(out, 2 + label.len, url); + return out; + } + fn cmdImageLoad(id: f64, event_tag: u8, image_path: []const u8, url: []const u8, cache_path: []const u8, expected: f64) []const u8 { const out = rt.frameAlloc(u8, 1 + 8 + 1 + 4 + image_path.len + 4 + url.len + 4 + cache_path.len + 8); out[0] = 0x12; @@ -2842,6 +2854,54 @@ test "window verbs bridge to the effects channel's label-addressed verbs" { try std.testing.expectEqual(boot_pending, fx.pendingHostCount()); } +test "webview navigation decodes onto the effects mirror" { + const fx = freshChannel(); + defer fx.deinit(); + Host.init(fx); + + Host.dispatch(fx, .navigate_webview); + const state = fx.webViewActionState(); + try std.testing.expectEqual(@as(u32, 1), state.navigate_count); + try std.testing.expectEqualStrings("preview", state.label()); + try std.testing.expectEqualStrings("https://status.test/page", state.url()); +} + +test "webview navigation invokes its bound runtime seam in real mode" { + const fx = freshChannel(); + defer fx.deinit(); + fx.executor = .real; + + const Stub = struct { + var calls: u32 = 0; + var window_id: platform.WindowId = 0; + var label: []const u8 = ""; + var url: []const u8 = ""; + + fn navigate(context: *anyopaque, target: platform.WindowId, requested_label: []const u8, requested_url: []const u8) bool { + _ = context; + calls += 1; + window_id = target; + label = requested_label; + url = requested_url; + return true; + } + }; + Stub.calls = 0; + var context: u8 = 0; + fx.bindWebViewActions(.{ + .context = &context, + .window_id = 7, + .navigate_fn = Stub.navigate, + }); + + Host.init(fx); + Host.dispatch(fx, .navigate_webview); + try std.testing.expectEqual(@as(u32, 1), Stub.calls); + try std.testing.expectEqual(@as(platform.WindowId, 7), Stub.window_id); + try std.testing.expectEqualStrings("preview", Stub.label); + try std.testing.expectEqualStrings("https://status.test/page", Stub.url); +} + test "a channel opens, posts route the five-field arm by name, and close retires the key" { const fx = freshChannel(); defer fx.deinit(); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 70b2e9fbf..80cd76f8a 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -1502,6 +1502,11 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe .dock_presence_fn = effectsSetDockPresence, .quit_fn = effectsQuitApp, }); + self.effects.bindWebViewActions(.{ + .context = runtime, + .window_id = self.canvas_window_id, + .navigate_fn = effectsNavigateWebView, + }); self.effects.bindSystemServices(.{ .context = runtime, .open_external_url_fn = effectsOpenExternalUrl, @@ -6429,6 +6434,18 @@ fn effectsQuitApp(context: *anyopaque) bool { return true; } +fn effectsNavigateWebView(context: *anyopaque, window_id: platform.WindowId, label: []const u8, url: []const u8) bool { + const runtime: *Runtime = @ptrCast(@alignCast(context)); + _ = runtime.updateView(window_id, label, .{ .url = url }) catch |err| { + ui_app_log.warn( + "WebView navigation for '{s}' rejected: {s} - the view must be a declared child WebView and the URL's origin must be in security.navigation.allowed_origins", + .{ label, @errorName(err) }, + ); + return false; + }; + return true; +} + fn effectsOpenExternalUrl(context: *anyopaque, url: []const u8) anyerror!void { const runtime: *Runtime = @ptrCast(@alignCast(context)); return runtime.openExternalUrl(url); diff --git a/tools/corewire/emit_facade.zig b/tools/corewire/emit_facade.zig index c7942645b..4b2ce98f9 100644 --- a/tools/corewire/emit_facade.zig +++ b/tools/corewire/emit_facade.zig @@ -2895,6 +2895,11 @@ const FacadeEmitter = struct { \\ nscfWU8(sink, 0x10); \\ nscfWShortText(sink, cmd.label); \\ return; + \\ case "webview_navigate": + \\ nscfWU8(sink, 0x33); + \\ nscfWShortText(sink, cmd.label); + \\ nscfWBytes(sink, cmd.url); + \\ return; \\ case "window_hide": \\ nscfWU8(sink, 0x21); \\ nscfWShortText(sink, cmd.label);