fix(app-runner): load manifest menus and commands (#376)
* 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> * test(app-runner): verify manifest menu registration * fix(automation): escape menu snapshot catalogs --------- Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = .{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = .{
|
||||
.{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user