Compare commits

...

3 Commits

Author SHA1 Message Date
Chris Tate edaa882337 fix(automation): escape menu snapshot catalogs 2026-08-16 22:46:59 -05:00
Chris Tate 6787f7adf4 test(app-runner): verify manifest menu registration 2026-08-16 22:14:59 -05:00
Chris Tate 65d4b028a3 fix(app-runner): load manifest menus and commands
- Resolve app.zon commands, menus, and shortcuts consistently across live and replay runners.
- Add zero-config TypeScript coverage, automation, and documentation.

Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 21:30:02 -05:00
16 changed files with 497 additions and 19 deletions
+3
View File
@@ -74,6 +74,9 @@ jobs:
- run: zig build test-example-mobile-canvas-lib-ios-store
- run: zig build test-webview-system-link
- run: zig build test-webview-smoke
# The zero-config TypeScript runner must load app.zon menus before
# automation can select their registered command ids.
- run: zig build test-menu-bar-smoke
# Signed-package seal pin: an ad-hoc signed package must pass
# codesign --verify --strict (macOS runners are the only tier with
# codesign; the step skips loudly anywhere else).
+65
View File
@@ -209,6 +209,24 @@ pub fn build(b: *std.Build) void {
const app_runner_window_placement_mod = module(b, target, optimize, "src/app_runner/window_placement.zig");
app_runner_window_placement_mod.addImport("native_sdk", desktop_mod);
const app_runner_window_placement_tests = testArtifact(b, app_runner_window_placement_mod);
const app_runner_options = b.addOptions();
app_runner_options.addOption([]const u8, "platform", "null");
app_runner_options.addOption([]const u8, "trace", "off");
app_runner_options.addOption([]const u8, "web_engine", "system");
app_runner_options.addOption(bool, "debug_overlay", false);
app_runner_options.addOption(bool, "automation", false);
app_runner_options.addOption(bool, "web_layer", false);
const app_runner_mod = module(b, target, optimize, "src/app_runner/root.zig");
app_runner_mod.addImport("native_sdk", desktop_mod);
app_runner_mod.addImport("build_options", app_runner_options.createModule());
app_runner_mod.addImport("app_manifest_zon", b.createModule(.{ .root_source_file = b.path("tests/app-runner/menu_commands_fixture.zon") }));
const app_runner_migrations_mod = module(b, target, optimize, "src/app_runner/no_migrations.zig");
app_runner_migrations_mod.addImport("native_sdk", desktop_mod);
app_runner_mod.addImport("relational_migrations", app_runner_migrations_mod);
const app_runner_tests = testArtifact(b, app_runner_mod);
const app_runner_test_run = b.addRunArtifact(app_runner_tests);
const app_runner_test_step = b.step("test-app-runner", "Run framework app-runner manifest fallback tests");
app_runner_test_step.dependOn(&app_runner_test_run.step);
desktop_mod.link_libc = true;
if (target.result.os.tag == .macos) {
const flags: []const []const u8 = if (b.sysroot) |sysroot|
@@ -607,6 +625,7 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&b.addRunArtifact(json_tests).step);
test_step.dependOn(&b.addRunArtifact(app_runner_assets_tests).step);
test_step.dependOn(&b.addRunArtifact(app_runner_window_placement_tests).step);
test_step.dependOn(&app_runner_test_run.step);
test_step.dependOn(&b.addRunArtifact(canvas_tests).step);
test_step.dependOn(&b.addRunArtifact(record_store_tests).step);
test_step.dependOn(&file_crash_run.step);
@@ -2158,6 +2177,52 @@ pub fn build(b: *std.Build) void {
native_shell_smoke_run.step.dependOn(&cli_exe.step);
native_shell_smoke_step.dependOn(&native_shell_smoke_run.step);
const menu_bar_smoke_step = b.step("test-menu-bar-smoke", "Run zero-config TypeScript app-menu automation smoke test");
const menu_bar_smoke_build = managedExampleRun(b, cli_exe, &.{ "build", "-Dplatform=macos", "-Dweb-engine=system", "-Dautomation=true", "-Doptimize=Debug" });
menu_bar_smoke_build.setCwd(b.path("examples/menu-bar"));
const menu_bar_smoke_run = b.addSystemCommand(&.{
"sh", "-c",
\\set -eu
\\cd examples/menu-bar
\\app="zig-out/bin/menu-bar"
\\cli="$1"
\\case "$cli" in /*) ;; *) cli="../../$cli" ;; esac
\\automation_dir=".zig-cache/native-sdk-automation"
\\mkdir -p "$automation_dir"
\\rm -f "$automation_dir/snapshot.txt" "$automation_dir/accessibility.txt" "$automation_dir/windows.txt" "$automation_dir"/command*.txt
\\"$app" > .zig-cache/native-sdk-menu-bar-smoke.log 2>&1 &
\\pid=$!
\\trap 'status=$?; kill "$pid" >/dev/null 2>&1 || true; wait "$pid" >/dev/null 2>&1 || true; if [ "$status" -ne 0 ]; then echo "---- app log (.zig-cache/native-sdk-menu-bar-smoke.log) ----" >&2; cat .zig-cache/native-sdk-menu-bar-smoke.log >&2 2>/dev/null || true; fi' EXIT
\\ready="$("$cli" automate wait 2>&1)"
\\case "$ready" in *"ready=true"*) ;; *) echo "menu-bar automation snapshot was not ready" >&2; exit 1 ;; esac
\\before="$(cat "$automation_dir/snapshot.txt" 2>/dev/null || true)"
\\case "$before" in *'command id="player.next" title="Next Track" enabled=true checked=false'*) ;; *) echo "app.zon command catalog was not loaded by the zero-config runner" >&2; exit 1 ;; esac
\\case "$before" in *'app-menu title="Player" items=6'*) ;; *) echo "app.zon Player menu was not loaded by the zero-config runner" >&2; exit 1 ;; esac
\\case "$before" in *'app-menu-item label="Next Track" command="player.next" enabled=true checked=false key="n" modifiers=(primary=true,command=false,control=false,option=false,shift=false)'*) ;; *) echo "app.zon player.next menu item was not loaded by the zero-config runner" >&2; exit 1 ;; esac
\\case "$before" in
\\ *'Ambient Coast'*) expected='Night Drive' ;;
\\ *'Night Drive'*) expected='Paper Planes' ;;
\\ *'Paper Planes'*) expected='Ambient Coast' ;;
\\ *) echo "menu-bar snapshot did not expose the current TypeScript model track" >&2; exit 1 ;;
\\esac
\\"$cli" automate menu-command player.next >/dev/null 2>&1
\\attempts=0
\\while [ "$attempts" -lt 50 ]; do
\\ snapshot="$(cat "$automation_dir/snapshot.txt" 2>/dev/null || true)"
\\ case "$snapshot" in *"$expected"*) break ;; esac
\\ attempts=$((attempts + 1))
\\ sleep 0.1
\\done
\\case "$snapshot" in *"$expected"*) ;; *) echo "app.zon menu command did not reach the zero-config TypeScript commandMsg mapper" >&2; exit 1 ;; esac
\\echo "menu-bar smoke ok"
,
"sh",
});
menu_bar_smoke_run.addFileArg(cli_exe.getEmittedBin());
menu_bar_smoke_run.step.dependOn(&menu_bar_smoke_build.step);
menu_bar_smoke_run.step.dependOn(&cli_exe.step);
menu_bar_smoke_step.dependOn(&menu_bar_smoke_run.step);
const gpu_surface_smoke_step = b.step("test-gpu-surface-smoke", "Run macOS GPU surface automation smoke test");
// The GPU smoke apps are managed examples (no build.zig of their own),
// so their binaries come from the CLI verb. -Doptimize=Debug keeps the
+3 -3
View File
@@ -311,11 +311,11 @@ The optional `commands` list declares shared command metadata. The runtime still
An app can define up to 256 commands. Command ids can be up to 128 bytes and titles can be up to 128 bytes.
Generated runners load manifest commands into `RuntimeOptions.commands`. Native code can read the active catalog with `runtime.listCommands(...)`, and trusted WebView code can read it with `window.zero.commands.list()` when the built-in command bridge allows it. Use the catalog to keep menus, shortcuts, toolbar controls, tray items, and bridge callers aligned with the same command ids.
Generated zero-config TypeScript and Zig-core runners load manifest commands into `RuntimeOptions.commands`; ejected runners use the same fallback. Native code can read the active catalog with `runtime.listCommands(...)`, and trusted WebView code can read it with `window.zero.commands.list()` when the built-in command bridge allows it. Use the catalog to keep menus, shortcuts, toolbar controls, tray items, and bridge callers aligned with the same command ids.
## `shortcuts`
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated runners load these automatically:
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
```zig
.shortcuts = .{
@@ -334,7 +334,7 @@ Chromium builds are currently macOS-only; use the Linux system WebView backend w
## `menus`
The optional `menus` list defines native app menus. Generated runners load these automatically:
The optional `menus` list defines native app menus. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
```zig
.menus = .{
+1 -1
View File
@@ -36,7 +36,7 @@ When the runtime publishes a snapshot, it writes these files to the automation d
<tbody>
<tr>
<td><code>snapshot.txt</code></td>
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, <code>ready=true/false</code>, and <code>markup_watch=armed|off</code> in the header — whether the markup hot-reload watch is armed (only in builds where the app wired <code>.markup</code> with a <code>watch_path</code> and <code>io</code>, or registered compiled fragments through <code>fragment_watch</code> — i.e. Debug dev builds)</td>
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, the configured command and app-menu catalogs, <code>ready=true/false</code>, and <code>markup_watch=armed|off</code> in the header — whether the markup hot-reload watch is armed (only in builds where the app wired <code>.markup</code> with a <code>watch_path</code> and <code>io</code>, or registered compiled fragments through <code>fragment_watch</code> — i.e. Debug dev builds)</td>
</tr>
<tr>
<td><code>accessibility.txt</code></td>
+1 -1
View File
@@ -14,7 +14,7 @@ The Native SDK can configure native app menus from `app.zon` or typed Zig data.
},
```
Generated runners load `app.zon` menus automatically. Pass `menus` to `runWithOptions` when an app needs to override the manifest at runtime:
Generated zero-config runners load `app.zon` menus automatically for both TypeScript cores and Zig cores. Ejected runners use the same fallback. Pass a non-null `menus` slice to `runWithOptions` when lower-level Zig wiring needs to override the manifest at runtime (an explicit empty slice disables manifest menus):
```zig
const view_items = [_]native_sdk.MenuItem{
+2 -1
View File
@@ -5,7 +5,8 @@ This app is the complete hide-to-tray lifecycle in the default TypeScript + Nati
- `app.zon` sets `dock_visible = false`, so macOS selects Accessory before creating a window and no Dock tile flashes.
- The main window starts with `initially_hidden = true` and uses `close_policy = "hide"`; the status item is the only open/re-show affordance.
- `src/core.ts` exports `statusItem(model)`, whose presentation and menu update from committed playback state.
- Tray commands pass through `commandMsg`; Open uses `Cmd.showWindow("main")`, and Quit uses `Cmd.quitApp()` for graceful termination.
- `app.zon` declares the app command catalog, native Player menu, and keyboard shortcuts; the zero-config runner loads all three automatically.
- App-menu, shortcut, and tray commands pass through `commandMsg`; Open uses `Cmd.showWindow("main")`, and Quit uses `Cmd.quitApp()` for graceful termination.
- Playback changes issue `Cmd.persist()`, so the engine restores the last playing/track state from its atomic app-data snapshot on the next launch.
- `src/app.native` is the ordinary player window. No app-owned Zig glue is involved.
+27 -1
View File
@@ -4,7 +4,7 @@
.display_name = "Menu Bar",
.version = "0.1.0",
.platforms = .{"macos"},
.capabilities = .{ "native_views", "gpu_surfaces", "tray", "persist" },
.capabilities = .{ "native_views", "gpu_surfaces", "tray", "persist", "menus", "shortcuts" },
// Launch as an Accessory app. The activation policy is selected before
// the startup window exists, so no Dock tile or cmd+Tab entry flashes.
.dock_visible = false,
@@ -16,6 +16,32 @@
.err = "restore_failed",
},
},
// The zero-config framework runner reads all three declarations and
// routes them through core.ts's commandMsg mapper. No runner.zig is
// needed in the app tree.
.commands = .{
.{ .id = "app.open", .title = "Open Player" },
.{ .id = "player.toggle", .title = "Toggle Playback" },
.{ .id = "player.next", .title = "Next Track" },
.{ .id = "app.quit", .title = "Quit" },
},
.shortcuts = .{
.{ .id = "player.toggle", .key = "p", .modifiers = .{ "primary" } },
.{ .id = "player.next", .key = "n", .modifiers = .{ "primary" } },
},
.menus = .{
.{
.title = "Player",
.items = .{
.{ .label = "Open Player", .command = "app.open" },
.{ .separator = true },
.{ .label = "Play or Pause", .command = "player.toggle", .key = "p", .modifiers = .{ "primary" } },
.{ .label = "Next Track", .command = "player.next", .key = "n", .modifiers = .{ "primary" } },
.{ .separator = true },
.{ .label = "Quit", .command = "app.quit", .key = "q", .modifiers = .{ "primary" } },
},
},
},
.shell = .{
.windows = .{
.{
+1 -1
View File
@@ -6,7 +6,7 @@ import fs from "node:fs";
// EffectFileOp appended `delete`; the reflected journal layout fingerprint
// moves so older recordings refuse cleanly instead of decoding op 8 wrongly.
export const journalFormatFingerprint = 0xb3bd2e83971de44dn;
export const automationProtocolFingerprint = 0x59d66f39803fd602n;
export const automationProtocolFingerprint = 0x51f7889bbe3305e7n;
const requestKeyBase = 0x5453525100000000n;
const textEncoder = new TextEncoder();
+3 -1
View File
@@ -56,7 +56,7 @@
# example suite (frontends, native incl.
# canvas-preview, mobile), the Chromium host link check (cef-host-link),
# the macOS automation smokes (gpu-surface,
# gpu-dashboard, gpu-components, canvas-preview, writeback; skipped off-macOS), a
# gpu-dashboard, gpu-components, menu-bar, canvas-preview, writeback; skipped off-macOS), a
# markup check over every example markup file, and the docs check if docs/ changed
# vs base-ref or --all was passed. --perf additionally runs the percentile
# GPU perf check (test-gpu-dashboard-perf; macOS only, slow, load-sensitive —
@@ -376,6 +376,7 @@ else # full
run_step "smoke-gpu-components" zig build test-gpu-components-smoke
run_step "smoke-webview" zig build test-webview-smoke
run_step "smoke-native-shell" zig build test-native-shell-smoke
run_step "smoke-menu-bar" zig build test-menu-bar-smoke
run_step "smoke-canvas-preview" zig build test-canvas-preview-smoke
run_step "smoke-writeback" zig build test-writeback-smoke
else
@@ -384,6 +385,7 @@ else # full
skip_step "smoke-gpu-components" "macOS only"
skip_step "smoke-webview" "macOS only"
skip_step "smoke-native-shell" "macOS only"
skip_step "smoke-menu-bar" "macOS only"
skip_step "smoke-canvas-preview" "macOS only"
skip_step "smoke-writeback" "macOS only"
fi
+4 -3
View File
@@ -117,9 +117,10 @@ Semantics:
10. Use `native automate widget-key <view-label> <key> [text]` for focused retained widget keyboard input. The key accepts modifier chords — `cmd+a`, `cmd+c`, `cmd+v`, `cmd+x`, `ctrl+shift+arrowleft` (`cmd` sets the primary shortcut modifier on every platform) — so select-all/copy/cut/paste and shift-extended selection are drivable; after a copy, widget lines in the snapshot show the live selection as `selection=a..b`, and the copied text lands on the real system clipboard (`pbpaste` on macOS).
11. Use `native automate widget-pinch <view-label> <scale> [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1`. `<scale>` is the FINAL multiplicative zoom for the gesture — the cumulative gesture scale (the product of `1 + delta`) lands exactly on it — `1.5` zooms in 50%, `0.5` zooms out to half. The optional anchor point is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`).
12. Use `native automate screenshot <view-label> [scale]` to capture the named `gpu_surface` view's canvas as `screenshot-<view-label>.png` (the CLI prints the artifact path and waits for the file).
13. Use `native automate tray-action <item-id>` for the primary status item, or `native automate tray-action <status-item-id> <item-id>` for an explicit item. Both select a dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). Live items appear in `snapshot.txt` as `tray #status-id title="..." visible=... items=N` followed by ` tray-item #item-id ...` rows — the menu bar is outside every window capture, so this is the automation evidence for every model-driven item. Unknown id pairs degrade into the dispatch-error ring as `automation.tray_action`.
14. Use `native automate reload` to request a WebView reload.
15. Use `native automate profile on` to enable per-stage frame timing: while on, `snapshot.txt` carries a `frame_profile` line with rolling p50/p90/max microseconds per pipeline stage (`rebuild`, `layout`, `reconcile`, `emit`, `a11y`, `plan`, `patch`, `encode`, `present`, `host_decode`, `host_draw`), each with a lifetime sample count (`<stage>_n=`). Drive some interactions, then `native automate snapshot | grep -o 'frame_profile.*'` to read where frame time goes; `profile off` stops recording and drops the line. Turning it on starts a fresh sample window.
13. Before driving an app-menu command, inspect the snapshot's `command id="..."` catalog and `app-menu` / `app-menu-item` rows to prove the running app loaded the expected `app.zon` or runner declarations. Then use `native automate menu-command <id>` to dispatch the same `.menu_command` platform event a real selection emits; the verb remains a raw event injector, so the snapshot receipt is what validates registration.
14. Use `native automate tray-action <item-id>` for the primary status item, or `native automate tray-action <status-item-id> <item-id>` for an explicit item. Both select a dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). Live items appear in `snapshot.txt` as `tray #status-id title="..." visible=... items=N` followed by ` tray-item #item-id ...` rows — the menu bar is outside every window capture, so this is the automation evidence for every model-driven item. Unknown id pairs degrade into the dispatch-error ring as `automation.tray_action`.
15. Use `native automate reload` to request a WebView reload.
16. Use `native automate profile on` to enable per-stage frame timing: while on, `snapshot.txt` carries a `frame_profile` line with rolling p50/p90/max microseconds per pipeline stage (`rebuild`, `layout`, `reconcile`, `emit`, `a11y`, `plan`, `patch`, `encode`, `present`, `host_decode`, `host_draw`), each with a lifetime sample count (`<stage>_n=`). Drive some interactions, then `native automate snapshot | grep -o 'frame_profile.*'` to read where frame time goes; `profile off` stops recording and drops the line. Turning it on starts a fresh sample window.
## Screenshots
+208 -6
View File
@@ -4,7 +4,9 @@ const native_sdk = @import("native_sdk");
const app_manifest = @import("app_manifest_zon");
const built_relational_migrations = @import("relational_migrations");
const window_placement = @import("window_placement.zig");
const manifest_commands = if (@hasField(@TypeOf(app_manifest), "commands")) app_manifest.commands else .{};
const manifest_shortcuts = if (@hasField(@TypeOf(app_manifest), "shortcuts")) app_manifest.shortcuts else .{};
const manifest_menus = if (@hasField(@TypeOf(app_manifest), "menus")) app_manifest.menus else .{};
const manifest_windows = if (@hasField(@TypeOf(app_manifest), "windows")) app_manifest.windows else .{};
fn manifestImagePixelBudget() usize {
@@ -62,7 +64,8 @@ pub const RunOptions = struct {
builtin_bridge: native_sdk.BridgePolicy = .{},
js_window_api: bool = false,
security: native_sdk.SecurityPolicy = .{},
menus: []const native_sdk.Menu = &.{},
commands: ?[]const native_sdk.Command = null,
menus: ?[]const native_sdk.Menu = null,
shortcuts: ?[]const native_sdk.Shortcut = null,
/// Filled by `runWithOptions` from the manifest capability. App entry
/// points do not set this themselves; the field only carries the owned
@@ -142,6 +145,72 @@ pub const RunOptions = struct {
fn resolvedShortcuts(self: RunOptions, storage: *ShortcutStorage) []const native_sdk.Shortcut {
return self.shortcuts orelse storage.fromManifest();
}
fn resolvedCommands(self: RunOptions, storage: *CommandStorage) []const native_sdk.Command {
return self.commands orelse storage.fromManifest();
}
fn resolvedMenus(self: RunOptions, storage: *MenuStorage) []const native_sdk.Menu {
return self.menus orelse storage.fromManifest();
}
};
const CommandStorage = struct {
commands: [native_sdk.app_manifest.max_commands]native_sdk.Command = undefined,
fn fromManifest(self: *CommandStorage) []const native_sdk.Command {
comptime {
if (manifest_commands.len > native_sdk.app_manifest.max_commands) {
@compileError("app.zon defines too many commands");
}
}
inline for (manifest_commands, 0..) |command, index| {
self.commands[index] = .{
.id = command.id,
.title = if (@hasField(@TypeOf(command), "title")) command.title else "",
.enabled = if (@hasField(@TypeOf(command), "enabled")) command.enabled else true,
.checked = if (@hasField(@TypeOf(command), "checked")) command.checked else false,
};
}
return self.commands[0..manifest_commands.len];
}
};
const MenuStorage = struct {
menus: [native_sdk.platform.max_menus]native_sdk.Menu = undefined,
items: [native_sdk.platform.max_menu_items]native_sdk.MenuItem = undefined,
fn fromManifest(self: *MenuStorage) []const native_sdk.Menu {
comptime {
if (manifest_menus.len > native_sdk.platform.max_menus) {
@compileError("app.zon defines too many menus");
}
var item_count: usize = 0;
for (manifest_menus) |menu| {
const items = if (@hasField(@TypeOf(menu), "items")) menu.items else .{};
item_count += items.len;
}
if (item_count > native_sdk.platform.max_menu_items) {
@compileError("app.zon defines too many menu items");
}
}
var item_index: usize = 0;
inline for (manifest_menus, 0..) |menu, menu_index| {
const items = if (@hasField(@TypeOf(menu), "items")) menu.items else .{};
const first_item = item_index;
inline for (items) |item| {
self.items[item_index] = menuItem(item);
item_index += 1;
}
self.menus[menu_index] = .{
.title = menu.title,
.items = self.items[first_item..item_index],
};
}
return self.menus[0..manifest_menus.len];
}
};
const ShortcutStorage = struct {
@@ -558,6 +627,18 @@ fn manifestDeclaresCredentials() bool {
return false;
}
fn menuItem(comptime item: anytype) native_sdk.MenuItem {
return .{
.label = if (@hasField(@TypeOf(item), "label")) item.label else "",
.command = if (@hasField(@TypeOf(item), "command")) item.command else "",
.key = if (@hasField(@TypeOf(item), "key")) item.key else "",
.modifiers = shortcutModifiers(item),
.separator = if (@hasField(@TypeOf(item), "separator")) item.separator else false,
.enabled = if (@hasField(@TypeOf(item), "enabled")) item.enabled else true,
.checked = if (@hasField(@TypeOf(item), "checked")) item.checked else false,
};
}
fn shortcutModifiers(comptime shortcut: anytype) native_sdk.ShortcutModifiers {
const values = if (@hasField(@TypeOf(shortcut), "modifiers")) shortcut.modifiers else .{};
var modifiers: native_sdk.ShortcutModifiers = .{};
@@ -697,6 +778,10 @@ fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !vo
runtime_trace_sink = filtered_trace_sink.sink();
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
// The Runtime is multi-megabyte; Linux's default 8 MB main-thread
// stack overflows on a stack instance, so construct it on the heap.
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
@@ -717,7 +802,8 @@ fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !vo
.web_layer = webLayerEnabled(),
.gpu_surface_frame_diagnostics = false,
.security = options.security,
.menus = options.menus,
.commands = commands,
.menus = menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
@@ -766,6 +852,10 @@ fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
runtime_trace_sink = filtered_trace_sink.sink();
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
// The Runtime is multi-megabyte; Linux's default 8 MB main-thread
// stack overflows on a stack instance, so construct it on the heap.
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
@@ -786,7 +876,8 @@ fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
.web_layer = webLayerEnabled(),
.gpu_surface_frame_diagnostics = false,
.security = options.security,
.menus = options.menus,
.commands = commands,
.menus = menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
@@ -832,6 +923,10 @@ fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
runtime_trace_sink = filtered_trace_sink.sink();
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
// The Runtime is multi-megabyte; Linux's default 8 MB main-thread
// stack overflows on a stack instance, so construct it on the heap.
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
@@ -852,7 +947,8 @@ fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
.web_layer = webLayerEnabled(),
.gpu_surface_frame_diagnostics = false,
.security = options.security,
.menus = options.menus,
.commands = commands,
.menus = menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
@@ -897,6 +993,10 @@ fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init)
runtime_trace_sink = filtered_trace_sink.sink();
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
// The Runtime is multi-megabyte; Linux's default 8 MB main-thread
// stack overflows on a stack instance, so construct it on the heap.
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
@@ -917,7 +1017,8 @@ fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init)
.web_layer = webLayerEnabled(),
.gpu_surface_frame_diagnostics = false,
.security = options.security,
.menus = options.menus,
.commands = commands,
.menus = menus,
.shortcuts = shortcuts,
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
.window_state_store = store,
@@ -1058,6 +1159,12 @@ fn runSessionReplay(app: native_sdk.App, options: RunOptions, init: std.process.
// (and disarm the producer wake bindings) before the runtime
// storage itself goes.
defer runtime.deinit();
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
// Bridge policy and security must match what the recording ran
// under (they gate replayed bridge_message dispatch); automation,
// window-state restore, and tracing stay off — replay consumes only
@@ -1070,7 +1177,9 @@ fn runSessionReplay(app: native_sdk.App, options: RunOptions, init: std.process.
.js_window_api = options.js_window_api,
.web_layer = webLayerEnabled(),
.security = options.security,
.menus = options.menus,
.commands = commands,
.menus = menus,
.shortcuts = shortcuts,
});
const verify = if (init.environ_map.get("NATIVE_SDK_SESSION_VERIFY")) |value|
@@ -1142,6 +1251,99 @@ fn webEngine() native_sdk.WebEngine {
return .system;
}
test "RunOptions resolves manifest commands menus and shortcuts" {
const options: RunOptions = .{
.app_name = "runner-fixture",
.bundle_id = "dev.native_sdk.runner_fixture",
};
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
try std.testing.expectEqual(@as(usize, 2), commands.len);
try std.testing.expectEqualStrings("app.refresh", commands[0].id);
try std.testing.expectEqualStrings("Refresh", commands[0].title);
try std.testing.expect(!commands[0].enabled);
try std.testing.expect(commands[0].checked);
try std.testing.expectEqualStrings("app.defaults", commands[1].id);
try std.testing.expectEqualStrings("", commands[1].title);
try std.testing.expect(commands[1].enabled);
try std.testing.expect(!commands[1].checked);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
try std.testing.expectEqual(@as(usize, 2), menus.len);
try std.testing.expectEqualStrings("View", menus[0].title);
try std.testing.expectEqual(@as(usize, 3), menus[0].items.len);
try std.testing.expectEqualStrings("Refresh", menus[0].items[0].label);
try std.testing.expectEqualStrings("app.refresh", menus[0].items[0].command);
try std.testing.expectEqualStrings("r", menus[0].items[0].key);
try std.testing.expect(menus[0].items[0].modifiers.option);
try std.testing.expect(menus[0].items[0].modifiers.shift);
try std.testing.expect(!menus[0].items[0].enabled);
try std.testing.expect(menus[0].items[0].checked);
try std.testing.expect(menus[0].items[1].separator);
try std.testing.expectEqualStrings("Defaults", menus[0].items[2].label);
try std.testing.expect(menus[0].items[2].enabled);
try std.testing.expect(!menus[0].items[2].checked);
try std.testing.expectEqualStrings("Help", menus[1].title);
try std.testing.expectEqual(@as(usize, 0), menus[1].items.len);
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
try std.testing.expectEqual(@as(usize, 1), shortcuts.len);
try std.testing.expectEqualStrings("app.refresh", shortcuts[0].id);
try std.testing.expectEqualStrings("r", shortcuts[0].key);
try std.testing.expect(shortcuts[0].modifiers.primary);
}
test "RunOptions explicit command menu and shortcut slices override manifest values" {
const override_commands = [_]native_sdk.Command{
.{ .id = "override.command", .title = "Override Command" },
};
const override_items = [_]native_sdk.MenuItem{
.{ .label = "Override Item", .command = "override.command" },
};
const override_menus = [_]native_sdk.Menu{
.{ .title = "Override Menu", .items = &override_items },
};
const override_shortcuts = [_]native_sdk.Shortcut{
.{ .id = "override.command", .key = "o", .modifiers = .{ .primary = true } },
};
const options: RunOptions = .{
.app_name = "runner-fixture",
.bundle_id = "dev.native_sdk.runner_fixture",
.commands = &override_commands,
.menus = &override_menus,
.shortcuts = &override_shortcuts,
};
var command_storage: CommandStorage = .{};
const commands = options.resolvedCommands(&command_storage);
try std.testing.expectEqual(@as(usize, 1), commands.len);
try std.testing.expectEqualStrings("override.command", commands[0].id);
var menu_storage: MenuStorage = .{};
const menus = options.resolvedMenus(&menu_storage);
try std.testing.expectEqual(@as(usize, 1), menus.len);
try std.testing.expectEqualStrings("Override Menu", menus[0].title);
var shortcut_storage: ShortcutStorage = .{};
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
try std.testing.expectEqual(@as(usize, 1), shortcuts.len);
try std.testing.expectEqualStrings("override.command", shortcuts[0].id);
const empty_options: RunOptions = .{
.app_name = "runner-fixture",
.bundle_id = "dev.native_sdk.runner_fixture",
.commands = &.{},
.menus = &.{},
.shortcuts = &.{},
};
try std.testing.expectEqual(@as(usize, 0), empty_options.resolvedCommands(&command_storage).len);
try std.testing.expectEqual(@as(usize, 0), empty_options.resolvedMenus(&menu_storage).len);
try std.testing.expectEqual(@as(usize, 0), empty_options.resolvedShortcuts(&shortcut_storage).len);
}
const StateBuffers = struct {
state_dir: [1024]u8 = undefined,
file_path: [1200]u8 = undefined,
+5 -1
View File
@@ -55,7 +55,11 @@ pub const fingerprint: u64 = layout_fingerprint.hash(layoutDescription(semantic_
/// visibility, and `tray-action` gained the explicit
/// `<status-item-id> <menu-item-id>` form (the old one-id primary-item
/// shorthand remains valid).
pub const semantic_epoch: u32 = 2;
/// Epoch 3: automation snapshots expose the app menus configured on the
/// runtime, including command ids and enabled/checked/key state.
/// Epoch 4: command and app-menu catalog strings in snapshots use
/// JSON-style escapes so every catalog entry remains exactly one line.
pub const semantic_epoch: u32 = 4;
/// The canonical description the protocol fingerprint hashes: the
/// command vocabulary (the `Action` enum, reflected — names and values,
+119
View File
@@ -1,5 +1,6 @@
const std = @import("std");
const geometry = @import("geometry");
const app_manifest = @import("app_manifest");
const platform = @import("../platform/root.zig");
const protocol = @import("protocol.zig");
@@ -290,6 +291,12 @@ pub const Input = struct {
windows: []const Window,
views: []const platform.ViewInfo = &.{},
widgets: []const Widget = &.{},
/// Static command and app-menu catalogs configured on the runtime.
/// These receipts let automation prove generated/ejected runners
/// loaded their manifest declarations before injecting command-source
/// events that cannot drive an OS menu tracking loop directly.
commands: []const app_manifest.Command = &.{},
menus: []const platform.Menu = &.{},
diagnostics: Diagnostics = .{},
/// Per-stage frame timing, non-null while `profile on` is active —
/// printed as the `frame_profile` line right after the header.
@@ -595,6 +602,40 @@ pub fn writeText(input: Input, writer: anytype) !void {
try writeWidgetContextMenu(widget, writer);
try writer.writeByte('\n');
}
for (input.commands) |command| {
try writer.writeAll("command id=");
try writeQuotedSnapshotText(command.id, writer);
try writer.writeAll(" title=");
try writeQuotedSnapshotText(command.title, writer);
try writer.print(" enabled={any} checked={any}\n", .{ command.enabled, command.checked });
}
for (input.menus) |menu| {
try writer.writeAll("app-menu title=");
try writeQuotedSnapshotText(menu.title, writer);
try writer.print(" items={d}\n", .{menu.items.len});
for (menu.items) |item| {
if (item.separator) {
try writer.writeAll(" app-menu-item separator\n");
continue;
}
try writer.writeAll(" app-menu-item label=");
try writeQuotedSnapshotText(item.label, writer);
try writer.writeAll(" command=");
try writeQuotedSnapshotText(item.command, writer);
try writer.print(" enabled={any} checked={any} key=", .{
item.enabled,
item.checked,
});
try writeQuotedSnapshotText(item.key, writer);
try writer.print(" modifiers=(primary={any},command={any},control={any},option={any},shift={any})\n", .{
item.modifiers.primary,
item.modifiers.command,
item.modifiers.control,
item.modifiers.option,
item.modifiers.shift,
});
}
}
for (input.trays) |tray| {
try writer.print("tray #{d} title=\"{s}\" visible={any} items={d}\n", .{ tray.id, tray.title, tray.visible, tray.items.len });
for (tray.items) |item| {
@@ -726,6 +767,31 @@ pub fn writeA11yText(input: Input, writer: anytype) !void {
}
}
/// Catalog values are user-authored but snapshots are line-oriented. Keep
/// each configured command/menu record on exactly one line and preserve its
/// byte identity with JSON-style escapes for delimiters and control bytes.
fn writeQuotedSnapshotText(value: []const u8, writer: anytype) !void {
const hex = "0123456789abcdef";
try writer.writeByte('"');
for (value) |byte| {
switch (byte) {
'"' => try writer.writeAll("\\\""),
'\\' => try writer.writeAll("\\\\"),
'\n' => try writer.writeAll("\\n"),
'\r' => try writer.writeAll("\\r"),
'\t' => try writer.writeAll("\\t"),
else => if (byte < 0x20 or byte == 0x7f) {
try writer.writeAll("\\u00");
try writer.writeByte(hex[byte >> 4]);
try writer.writeByte(hex[byte & 0x0f]);
} else {
try writer.writeByte(byte);
},
}
}
try writer.writeByte('"');
}
fn writeWidgetParent(widget: Widget, writer: anytype) !void {
if (widget.parent_id) |parent_id| try writer.print(" parent=#{d}", .{parent_id});
}
@@ -942,6 +1008,59 @@ test "snapshot emits tray title and dropdown items" {
try std.testing.expect(std.mem.indexOf(u8, empty_writer.buffered(), "tray") == null);
}
test "snapshot emits configured command and app-menu catalogs" {
var buffer: [2048]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
const windows = [_]Window{.{ .title = "Test", .bounds = geometry.RectF.init(0, 0, 100, 100) }};
const commands = [_]app_manifest.Command{
.{ .id = "app.refresh", .title = "Refresh" },
};
const items = [_]platform.MenuItem{
.{ .label = "Refresh", .command = "app.refresh", .key = "r", .modifiers = .{ .primary = true } },
.{ .separator = true },
};
const menus = [_]platform.Menu{
.{ .title = "View", .items = &items },
};
try writeText(.{
.windows = &windows,
.commands = &commands,
.menus = &menus,
}, &writer);
const text = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, text, "\ncommand id=\"app.refresh\" title=\"Refresh\" enabled=true checked=false\n") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "app-menu title=\"View\" items=2\n") != null);
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item label=\"Refresh\" command=\"app.refresh\" enabled=true checked=false key=\"r\" modifiers=(primary=true,command=false,control=false,option=false,shift=false)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item separator\n") != null);
}
test "snapshot escapes hostile command and app-menu catalog text" {
var buffer: [2048]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
const windows = [_]Window{.{ .title = "Test", .bounds = geometry.RectF.init(0, 0, 100, 100) }};
const commands = [_]app_manifest.Command{
.{ .id = "app.\"quoted", .title = "Line 1\nLine 2\\tail\t\x01\x7f Café" },
};
const items = [_]platform.MenuItem{
.{ .label = "Open \"now\"\rnext\\", .command = "app.\"run", .key = "r\t" },
};
const menus = [_]platform.Menu{
.{ .title = "Tools\"\nInjected", .items = &items },
};
try writeText(.{
.windows = &windows,
.commands = &commands,
.menus = &menus,
}, &writer);
const text = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, text, "\ncommand id=\"app.\\\"quoted\" title=\"Line 1\\nLine 2\\\\tail\\t\\u0001\\u007f Café\" enabled=true checked=false\n") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "app-menu title=\"Tools\\\"\\nInjected\" items=1\n") != null);
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item label=\"Open \\\"now\\\"\\rnext\\\\\" command=\"app.\\\"run\" enabled=true checked=false key=\"r\\t\" modifiers=(primary=false,command=false,control=false,option=false,shift=false)\n") != null);
// Header, window, command, menu, and item: hostile values inject no
// additional records into the line-oriented snapshot.
try std.testing.expectEqual(@as(usize, 5), std.mem.count(u8, text, "\n"));
}
test "accessibility snapshot uses visible view text as name" {
var buffer: [512]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
+4
View File
@@ -30,6 +30,8 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type {
.windows = self.automation_windows[0..1],
.views = &.{},
.widgets = &.{},
.commands = self.options.commands,
.menus = self.options.menus,
.diagnostics = automationDiagnostics(self),
.frame_profile = automationFrameProfile(self),
.trays = automationTrays(self),
@@ -64,6 +66,8 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type {
.windows = self.automation_windows[0..count],
.views = self.automation_views[0..view_count],
.widgets = self.automation_widgets[0..widget_count],
.commands = self.options.commands,
.menus = self.options.menus,
.diagnostics = automationDiagnostics(self),
.frame_profile = automationFrameProfile(self),
.trays = automationTrays(self),
@@ -253,6 +253,35 @@ test "runtime dispatches menu command events" {
try std.testing.expectEqual(@as(platform.WindowId, 1), app_state.last_window_id);
}
test "automation snapshot exposes configured app menus" {
const items = [_]platform.MenuItem{
.{ .label = "Refresh", .command = "app.refresh" },
.{ .label = "Disabled", .command = "app.disabled", .enabled = false },
.{ .separator = true },
};
const menus = [_]platform.Menu{
.{ .title = "View", .items = &items },
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.runtime.options.menus = &menus;
const snapshot = harness.runtime.automationSnapshot("Menus");
try std.testing.expectEqual(@as(usize, 1), snapshot.menus.len);
try std.testing.expectEqualStrings("View", snapshot.menus[0].title);
try std.testing.expectEqual(@as(usize, 3), snapshot.menus[0].items.len);
try std.testing.expectEqualStrings("app.refresh", snapshot.menus[0].items[0].command);
try std.testing.expect(!snapshot.menus[0].items[1].enabled);
try std.testing.expect(snapshot.menus[0].items[2].separator);
var buffer: [2048]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
try automation.snapshot.writeText(snapshot, &writer);
const text = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, text, "app-menu title=\"View\" items=3\n") != null);
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item label=\"Refresh\" command=\"app.refresh\" enabled=true checked=false key=\"\"") != null);
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item separator\n") != null);
}
test "runtime dispatches tray item commands" {
const TestApp = struct {
command_count: u32 = 0,
@@ -0,0 +1,22 @@
.{
.id = "dev.native_sdk.runner_fixture",
.name = "runner-fixture",
.commands = .{
.{ .id = "app.refresh", .title = "Refresh", .enabled = false, .checked = true },
.{ .id = "app.defaults" },
},
.shortcuts = .{
.{ .id = "app.refresh", .key = "r", .modifiers = .{ "primary" } },
},
.menus = .{
.{
.title = "View",
.items = .{
.{ .label = "Refresh", .command = "app.refresh", .key = "r", .modifiers = .{ "alt", "shift" }, .enabled = false, .checked = true },
.{ .separator = true },
.{ .label = "Defaults", .command = "app.defaults" },
},
},
.{ .title = "Help" },
},
}