ReviewOS

also looking at this

craft-native/craft

feat(bridge): let the page ask what native actually serves

#60
Merged glennmichael123 wants to merge feat/capabilities into main
20 files +1546 -37
docs/BRIDGE_API.mdmodified+54-0
Changes to docs/BRIDGE_API.md
@@ -11,6 +11,7 @@ The Craft JavaScript Bridge provides a seamless interface for your web applicati
1111- [Global Shortcuts (macOS)](#global-shortcuts-macos)
1212- [Settings and Preferences (macOS)](#settings-and-preferences-macos)
1313- [Headless mode](#headless-mode)
14- [Capabilities](#capabilities)
1415- [Window API](#window-api)
1516- [App API](#app-api)
1617- [TypeScript Support](#typescript-support)
@@ -520,6 +521,58 @@ sockets at all — and `safaridriver` refuses to attach to anything that is not
520521Safari. Driving a craft page from another process needs a control channel craft
521522owns, which does not exist yet.
522523
524## Capabilities
525
526This script is one blob, `@embedFile`d into the binary and injected whole into
527every craft window — the same JavaScript whatever the native side behind it
528implements. So `typeof window.craft.tray.destroy === 'function'` has never been
529evidence that anything is behind it, and three shipped bugs came from assuming
530otherwise. Ask instead.
531
532```javascript
533const caps = await window.craft.capabilities()
534
535caps.namespaces.updater.status // 'unavailable'
536caps.namespaces.updater.reason // 'the Sparkle framework is not linked into this build'
537caps.channels['craft:fs:change'] // false — you could subscribe, it would never fire
538```
539
540Or, for one surface:
541
542```javascript
543if (window.craft.supports('tray.destroy')) { /* … */ }
544```
545
546### Namespace status
547
548| status | means |
549|---|---|
550| `declared` | Audited. The action list is complete, and a conformance test fails the build if it stops matching what the dispatcher serves. |
551| `undeclared` | Routed, but craft has not audited it and **will not claim anything either way**. Treat as "try it and handle failure", not as "missing". |
552| `unavailable` | Reachable and known not to work. `reason` says why. |
553| `unrouted` | Implemented natively but absent from the dispatcher, so no message can reach it. |
554
555Most namespaces are `undeclared` today. Declaring one means refactoring its
556dispatch chain so each action name exists in exactly one place — roughly 279
557names across 47 files — so it is being done in batches, and the conformance test
558holds a ceiling on how many may remain that only ever goes down.
559
560### `channels`
561
562Every `craft:*` event, and whether anything native emits on it. This cannot be
563derived from the action tables — an emitter is a separate piece of code that may
564or may not have been written — so a channel is reported live only because the
565code that emits on it took out a permit at startup. Sixteen channels the JS
566surface subscribes to currently have no emitter at all.
567
568### `supports()` fails open
569
570It returns `true` before capabilities have been fetched, and `true` for an
571`undeclared` namespace. A feature-detection mechanism that breaks working code
572when it cannot see itself would be worse than the gaps it describes. Only an
573`unavailable` or `unrouted` surface — or an unknown action inside a `declared`
574namespace — answers `false`.
575
523576## Window API
524577
525578Control the application window from JavaScript.
@@ -757,6 +810,7 @@ async function onComplete() {
757810| Global Shortcuts | ✅ | �� | �� |
758811| Settings… item | ✅ | �� | �� |
759812| Preferences | ✅ | �� | �� |
813| Capabilities | ✅ | �� | �� |
760814
761815✅ Implemented | �� In Progress | ➖ Not Applicable
762816
packages/typescript/src/index.tsmodified+4-0
Changes to packages/typescript/src/index.ts
@@ -563,6 +563,10 @@ export type {
563563 CraftDatabaseAPI,
564564 CraftHttpAPI,
565565 CraftCryptoAPI,
566 CraftCapabilities,
567 CapabilityNamespace,
568 CapabilityNamespaceStatus,
569 CapabilityAction,
566570 CraftPreferencesAPI,
567571 CraftPreferencesInfo,
568572 CraftSettingsAPI,
packages/typescript/src/types.tsmodified+68-0
Changes to packages/typescript/src/types.ts
@@ -1187,8 +1187,76 @@ export interface CraftBridgeAPI {
11871187 * The Cmd+, convention: where the App menu's Settings… item arrives.
11881188 */
11891189 settings?: CraftSettingsAPI
1190
1191 /**
1192 * What the native side actually serves.
1193 *
1194 * Always present: it is how you find out whether anything else here is.
1195 */
1196 capabilities?: () => Promise<CraftCapabilities>
1197
1198 /** The last capabilities answer, or null if nothing has asked yet. */
1199 capabilitiesSync?: () => CraftCapabilities | null
1200
1201 /**
1202 * Whether one surface is known to work — `craft.supports('tray.destroy')`.
1203 *
1204 * Fails **open**: returns true before capabilities have been fetched, and
1205 * true for a namespace craft has not audited. A feature-detection mechanism
1206 * that breaks working code when it cannot see itself would be worse than the
1207 * gaps it describes.
1208 */
1209 supports?: (path: string) => boolean
11901210}
11911211
1212/**
1213 * How much craft is willing to claim about a namespace.
1214 *
1215 * - `declared` — audited: the action list below is complete and enforced by a
1216 * conformance test against the dispatch chain.
1217 * - `undeclared` — routed, but not audited. Craft claims nothing either way;
1218 * treat it as "try it and handle failure", not as "missing".
1219 * - `unavailable` — reachable and known not to work. `reason` says why.
1220 * - `unrouted` — implemented natively but absent from the dispatcher, so no
1221 * message can reach it.
1222 */
1223export type CapabilityNamespaceStatus = 'declared' | 'undeclared' | 'unavailable' | 'unrouted'
1224
1225export interface CapabilityAction {
1226 status: 'live' | 'unavailable'
1227 /** Whether native sends a reply the caller is waiting on. */
1228 reply: 'none' | 'result'
1229 /** Present when the action is unavailable. */
1230 reason?: string
1231}
1232
1233export interface CapabilityNamespace {
1234 status: CapabilityNamespaceStatus
1235 reason?: string
1236 /** Present only for `declared` namespaces. */
1237 actions?: Record<string, CapabilityAction>
1238}
1239
1240/**
1241 * What the native binary behind this page actually serves.
1242 *
1243 * The injected bridge script is one blob, the same in every build, so the
1244 * presence of `window.craft.x` has never been evidence that anything is behind
1245 * it. This is the evidence.
1246 */
1247export interface CraftCapabilities {
1248 namespaces: Record<string, CapabilityNamespace>
1249 /**
1250 * Every `craft:*` event channel, and whether anything native emits on it.
1251 *
1252 * A `false` here means subscribing would work and never fire — which cannot
1253 * be derived from the action tables, so it is tracked separately by the
1254 * emitters themselves.
1255 */
1256 channels: Record<string, boolean>
1257}
1258
1259
11921260/**
11931261 * The only value types `craft.prefs` stores.
11941262 *
packages/zig/build.zigmodified+58-0
Changes to packages/zig/build.zig
@@ -630,6 +630,49 @@ pub fn build(b: *std.Build) void {
630630 prefs_macos_tests.root_module.link_libc = true;
631631 }
632632
633 // The capability registry: what native declares it serves, and which event
634 // channels have a live emitter. Pure data plus a JSON renderer.
635 const capabilities_tests = b.addTest(.{
636 .root_module = b.createModule(.{
637 .root_source_file = b.path("src/capabilities.zig"),
638 .target = target,
639 .optimize = optimize,
640 }),
641 });
642
643 // The conformance test: what craft declares, against what it dispatches.
644 const capability_conformance_tests = b.addTest(.{
645 .root_module = b.createModule(.{
646 .root_source_file = b.path("test/capabilities_test.zig"),
647 .target = target,
648 .optimize = optimize,
649 .imports = &.{
650 .{ .name = "capability_registry", .module = b.createModule(.{
651 .root_source_file = b.path("src/capability_registry.zig"),
652 }) },
653 },
654 }),
655 });
656 // The registry imports the declared bridges, which reach the rest of the
657 // native graph, so this needs the same libraries the binary links.
658 linkPlatformLibraries(b, capability_conformance_tests.root_module, target_os, macos_sdk);
659 // The sources it reads, embedded rather than opened at run time: the test
660 // is then hermetic and looking at the same bytes the compiler saw.
661 for ([_][]const u8{
662 "src/macos.zig",
663 "src/bridge_clipboard.zig",
664 "src/bridge_tray.zig",
665 "src/bridge_app.zig",
666 "src/bridge_screen.zig",
667 "src/bridge_capabilities.zig",
668 "src/bridge_capabilities_actions.zig",
669 }) |source_path| {
670 capability_conformance_tests.root_module.addAnonymousImport(source_path, .{
671 .root_source_file = b.path(source_path),
672 });
673 }
674 const run_capability_conformance = b.addRunArtifact(capability_conformance_tests);
675
633676 const config_tests = b.addTest(.{
634677 .root_module = b.createModule(.{
635678 .root_source_file = b.path("test/config_test.zig"),
@@ -966,6 +1009,7 @@ pub fn build(b: *std.Build) void {
9661009 const run_space_list_tests = b.addRunArtifact(space_list_tests);
9671010 const run_local_tls_tests = b.addRunArtifact(local_tls_tests);
9681011 const run_menu_roles_tests = b.addRunArtifact(menu_roles_tests);
1012 const run_capabilities_tests = b.addRunArtifact(capabilities_tests);
9691013 const run_prefs_tests = b.addRunArtifact(prefs_tests);
9701014 const run_prefs_macos_tests = b.addRunArtifact(prefs_macos_tests);
9711015 const run_key_codes_tests = b.addRunArtifact(key_codes_tests);
@@ -1078,6 +1122,11 @@ pub fn build(b: *std.Build) void {
10781122 test_step.dependOn(&run_space_list_tests.step);
10791123 test_step.dependOn(&run_local_tls_tests.step);
10801124 test_step.dependOn(&run_menu_roles_tests.step);
1125 test_step.dependOn(&run_capabilities_tests.step);
1126 // macOS only: the registry describes the dispatch chain in macos.zig, and
1127 // compiling it elsewhere drags the whole native graph into a test binary
1128 // for a platform it does not describe.
1129 if (target_os == .macos) test_step.dependOn(&run_capability_conformance.step);
10811130 test_step.dependOn(&run_prefs_tests.step);
10821131 test_step.dependOn(&run_prefs_macos_tests.step);
10831132 test_step.dependOn(&run_key_codes_tests.step);
@@ -1183,6 +1232,10 @@ pub fn build(b: *std.Build) void {
11831232 const test_menu_roles_step = b.step("test:menu-roles", "Run menu role table tests");
11841233 test_menu_roles_step.dependOn(&run_menu_roles_tests.step);
11851234
1235 const test_capabilities_step = b.step("test:capabilities", "Run capability registry tests");
1236 test_capabilities_step.dependOn(&run_capabilities_tests.step);
1237 test_capabilities_step.dependOn(&run_capability_conformance.step);
1238
11861239 const test_prefs_step = b.step("test:prefs", "Run preference store tests");
11871240 test_prefs_step.dependOn(&run_prefs_tests.step);
11881241 test_prefs_step.dependOn(&run_prefs_macos_tests.step);
@@ -1699,6 +1752,11 @@ fn linkPlatformLibraries(b: *std.Build, module: *std.Build.Module, target_os: st
16991752 // when the app dynamically loads them.
17001753 module.linkFramework("CoreMIDI", .{});
17011754 module.linkFramework("CoreSpotlight", .{});
1755 // CoreFoundation for `prefs_macos.zig`, which calls the
1756 // CFPreferences quad directly. The shipped binary resolves these
1757 // through Cocoa, but a test artifact that reaches the same code
1758 // does not always, so name it.
1759 module.linkFramework("CoreFoundation", .{});
17021760 // Carbon for `macos_hotkey.zig`: `RegisterEventHotKey` is the only
17031761 // route to a global hotkey that neither needs an Objective-C block
17041762 // (which Zig cannot write) nor Accessibility permission (which a
packages/zig/src/bridge_app.zigmodified+36-7
Changes to packages/zig/src/bridge_app.zig
@@ -1,6 +1,35 @@
11const std = ;
22const builtin = ;
33const bridge_error = ;
4const capabilities = ;
5
6/// The action names this bridge dispatches on.
7///
8/// Declared once, here, and referenced by both the dispatch chain below and the
9/// capability table beside it — so the two cannot disagree. That is enforced:
10/// `test/capabilities_test.zig` fails if a declared bridge compares `action`
11/// against a bare string literal anywhere, which makes it impossible to add an
12/// action without also declaring it.
13pub const A = struct {
14 pub const hide_dock_icon = "hideDockIcon";
15 pub const show_dock_icon = "showDockIcon";
16 pub const quit = "quit";
17 pub const get_info = "getInfo";
18 pub const notify = "notify";
19 pub const set_badge = "setBadge";
20 pub const bounce = "bounce";
21};
22
23/// What craft serves on the `app` namespace.
24pub const capability_actions = [_]capabilities.ActionDecl{
25 .{ .name = A.hide_dock_icon, .reply = .none },
26 .{ .name = A.show_dock_icon, .reply = .none },
27 .{ .name = A.quit, .reply = .none },
28 .{ .name = A.get_info, .reply = .result },
29 .{ .name = A.notify, .reply = .none },
30 .{ .name = A.set_badge, .reply = .none },
31 .{ .name = A.bounce, .reply = .none },
32};
433
534/// Bridge handler for app-level control messages from JavaScript
635pub const AppBridge = struct {
@@ -22,19 +51,19 @@ pub const AppBridge = struct {
2251 }
2352
2453 pub fn handleMessageWithData(self: *Self, action: []const u8, data: ?[]const u8) !void {
25 if (std.mem.eql(u8, action, "hideDockIcon")) {
54 if (std.mem.eql(u8, action, A.hide_dock_icon)) {
2655 try self.hideDockIcon();
27 } else if (std.mem.eql(u8, action, "showDockIcon")) {
56 } else if (std.mem.eql(u8, action, A.show_dock_icon)) {
2857 try self.showDockIcon();
29 } else if (std.mem.eql(u8, action, "quit")) {
58 } else if (std.mem.eql(u8, action, A.quit)) {
3059 try self.quit();
31 } else if (std.mem.eql(u8, action, "getInfo")) {
60 } else if (std.mem.eql(u8, action, A.get_info)) {
3261 try self.getInfo();
33 } else if (std.mem.eql(u8, action, "notify")) {
62 } else if (std.mem.eql(u8, action, A.notify)) {
3463 try self.notify(data);
35 } else if (std.mem.eql(u8, action, "setBadge")) {
64 } else if (std.mem.eql(u8, action, A.set_badge)) {
3665 try self.setBadge(data);
37 } else if (std.mem.eql(u8, action, "bounce")) {
66 } else if (std.mem.eql(u8, action, A.bounce)) {
3867 try self.bounce();
3968 } else {
4069 if (comptime builtin.mode == .debug)
packages/zig/src/bridge_capabilities.zigadded+68-0
Changes to packages/zig/src/bridge_capabilities.zig
@@ -0,0 +1,68 @@
1//! `t: "capabilities"` — the page asking what the native side actually serves.
2//!
3//! Answered from `capability_registry.zig`, which is built from the same action
4//! constants the dispatch chains compare against, and from the live-emitter set
5//! that only real emitters can mark. Nothing here is hand-maintained, which is
6//! the entire point: a hand-written capability list would be authoritative-
7//! looking and wrong within a release, and that is worse than no list at all.
8
9const std = ;
10const bridge_error = ;
11const registry = ;
12
13const BridgeError = bridge_error.BridgeError;
14
15/// Namespace-qualified, like the prefs bridge and for the same reason: the
16/// pending-reply queue is keyed by action name alone, so a bare `get` would be
17/// drained by whichever of keychain or tags replied first.
18pub const A = ;
19
20/// What craft serves on the `capabilities` namespace.
21pub const capability_actions = [_].ActionDecl{
22 .{ .name = A.get, .reply = .result },
23};
24
25pub const action_get = A.get;
26
27/// What an unrecognised action is reported under.
28///
29/// Never the bytes that arrived: the action is interpolated into a JS literal
30/// unescaped, and `craft.invoke` lets a page choose it.
31pub const action_unknown = A.unknown;
32
33pub const CapabilitiesBridge = struct {
34 allocator: std.mem.Allocator,
35
36 const Self = @This();
37
38 pub fn init(allocator: std.mem.Allocator) Self {
39 return .{ .allocator = allocator };
40 }
41
42 pub fn deinit(_: *Self) void {}
43
44 pub fn handleMessage(self: *Self, action: []const u8, _: []const u8) !void {
45 if (!std.mem.eql(u8, action, A.get)) {
46 bridge_error.sendErrorToJS(self.allocator, action_unknown, BridgeError.UnknownAction);
47 return;
48 }
49
50 // Rendered per call rather than cached: an emitter can register after
51 // the page loads — a subsystem that only initialises on first use — and
52 // answering from a snapshot would be the stale-declaration problem this
53 // whole mechanism exists to prevent.
54 const json = registry.manifestJson(self.allocator) catch {
55 bridge_error.sendErrorToJS(self.allocator, action_get, BridgeError.AllocationFailed);
56 return;
57 };
58 defer self.allocator.free(json);
59 bridge_error.sendResultToJS(self.allocator, action_get, json);
60 }
61};
62
63const testing = std.testing;
64
65test "the action names are namespaced" {
66 try testing.expect(std.mem.startsWith(u8, action_get, "capabilities:"));
67 try testing.expect(std.mem.startsWith(u8, action_unknown, "capabilities:"));
68}
packages/zig/src/bridge_capabilities_actions.zigadded+16-0
Changes to packages/zig/src/bridge_capabilities_actions.zig
@@ -0,0 +1,16 @@
1//! The `capabilities` bridge's action names, alone.
2//!
3//! Split out of `bridge_capabilities.zig` so the JS-contract test can reference
4//! the same constants the bridge dispatches on without importing the registry —
5//! which reaches every declared bridge and, through them, the whole native
6//! graph. A test about the shape of a JSON message should not need to compile
7//! the tray.
8
9/// Namespace-qualified, like the prefs bridge and for the same reason: the
10/// pending-reply queue is keyed by action name alone.
11pub const get = "capabilities:get";
12
13/// What an unrecognised action is reported under. Never the bytes that arrived:
14/// the action is interpolated into a JS literal unescaped, and `craft.invoke`
15/// lets a page choose it.
16pub const unknown = "capabilities:unknownAction";
packages/zig/src/bridge_clipboard.zigmodified+39-8
Changes to packages/zig/src/bridge_clipboard.zig
@@ -8,6 +8,37 @@ const log = logging.clipboard;
88
99// Import GTK clipboard API from linux.zig (works on both X11 and Wayland)
1010const linux = if (builtin.os.tag == .linux) else undefined;
11const capabilities = ;
12
13/// The action names this bridge dispatches on.
14///
15/// Declared once, here, and referenced by both the dispatch chain below and the
16/// capability table beside it — so the two cannot disagree. That is enforced:
17/// `test/capabilities_test.zig` fails if a declared bridge compares `action`
18/// against a bare string literal anywhere, which makes it impossible to add an
19/// action without also declaring it.
20pub const A = struct {
21 pub const write_text = "writeText";
22 pub const read_text = "readText";
23 pub const write_h_t_m_l = "writeHTML";
24 pub const read_h_t_m_l = "readHTML";
25 pub const clear = "clear";
26 pub const has_text = "hasText";
27 pub const has_h_t_m_l = "hasHTML";
28 pub const has_image = "hasImage";
29};
30
31/// What craft serves on the `clipboard` namespace.
32pub const capability_actions = [_]capabilities.ActionDecl{
33 .{ .name = A.write_text, .reply = .none },
34 .{ .name = A.read_text, .reply = .result },
35 .{ .name = A.write_h_t_m_l, .reply = .none },
36 .{ .name = A.read_h_t_m_l, .reply = .result },
37 .{ .name = A.clear, .reply = .none },
38 .{ .name = A.has_text, .reply = .result },
39 .{ .name = A.has_h_t_m_l, .reply = .result },
40 .{ .name = A.has_image, .reply = .result },
41};
1142
1243// Win32 symbols used by the Windows clipboard implementation. Declared at
1344// file scope as `extern` so the file parses on every platform — Zig 0.17
@@ -48,21 +79,21 @@ pub const ClipboardBridge = struct {
4879 }
4980
5081 pub fn handleMessageWithData(self: *Self, action: []const u8, data: ?[]const u8) !void {
51 if (std.mem.eql(u8, action, "writeText")) {
82 if (std.mem.eql(u8, action, A.write_text)) {
5283 try self.writeText(data);
53 } else if (std.mem.eql(u8, action, "readText")) {
84 } else if (std.mem.eql(u8, action, A.read_text)) {
5485 try self.readText();
55 } else if (std.mem.eql(u8, action, "writeHTML")) {
86 } else if (std.mem.eql(u8, action, A.write_h_t_m_l)) {
5687 try self.writeHTML(data);
57 } else if (std.mem.eql(u8, action, "readHTML")) {
88 } else if (std.mem.eql(u8, action, A.read_h_t_m_l)) {
5889 try self.readHTML();
59 } else if (std.mem.eql(u8, action, "clear")) {
90 } else if (std.mem.eql(u8, action, A.clear)) {
6091 try self.clear();
61 } else if (std.mem.eql(u8, action, "hasText")) {
92 } else if (std.mem.eql(u8, action, A.has_text)) {
6293 try self.hasText();
63 } else if (std.mem.eql(u8, action, "hasHTML")) {
94 } else if (std.mem.eql(u8, action, A.has_h_t_m_l)) {
6495 try self.hasHTML();
65 } else if (std.mem.eql(u8, action, "hasImage")) {
96 } else if (std.mem.eql(u8, action, A.has_image)) {
6697 try self.hasImage();
6798 } else {
6899 return BridgeError.UnknownAction;
packages/zig/src/bridge_contracts.zigmodified+13-2
Changes to packages/zig/src/bridge_contracts.zig
@@ -4,8 +4,8 @@
44//! decodes what it posted with the very code the bridge dispatches to — which
55//! is the only kind of test that can catch a JS side and a native side that
66//! are each individually correct and disagree with each other. #27 (menu action
7//! names), #47 (shortcut payload shape) and #51 (preference value tags) were
8//! all exactly that.
7//! names), #47 (shortcut payload shape), #51 (preference value tags) and #49
8//! (a capability manifest that describes nothing) were all exactly that.
99//!
1010//! They arrive through one aggregate rather than as separate named modules
1111//! because several of them reach `bridge_error.zig`, and a file may belong to
@@ -17,3 +17,14 @@ pub const menu = ;
1717pub const shortcuts = ;
1818pub const prefs = ;
1919pub const prefs_actions = ;
20/// Pure: the manifest types and renderer, with no bridge behind them.
21pub const capabilities = ;
22/// Pure: just the action-name constants the capabilities bridge dispatches on.
23///
24/// Deliberately not the registry. The registry reaches every declared bridge
25/// and through them the whole native graph, and a test about the shape of a
26/// JSON message has no business compiling the tray — on Linux it does not even
27/// compile, because of stale-API code in branches macOS never analyses.
28/// `test/capabilities_test.zig` is where the real registry is checked against
29/// the real dispatch chain.
30pub const capabilities_actions = ;
packages/zig/src/bridge_fs.zigmodified+23-8
Changes to packages/zig/src/bridge_fs.zig
@@ -544,6 +544,15 @@ pub const FSBridge = struct {
544544 }
545545 }
546546
547 /// An environment variable as a slice, or null when it is unset.
548 ///
549 /// libc rather than std: this build links libc everywhere, and `std.posix`
550 /// has no `getenv` in this compiler version.
551 fn envOrEmpty(name: [*:0]const u8) ?[]const u8 {
552 const raw = std.c.getenv(name) orelse return null;
553 return std.mem.span(raw);
554 }
555
547556 /// Get home directory
548557 fn getHomeDir(self: *Self) !void {
549558 _ = self;
@@ -580,9 +589,15 @@ pub const FSBridge = struct {
580589 }
581590 }
582591 } else {
583 // Cross-platform: use HOME env var (Linux/Windows)
584 const home = std.posix.getenv("HOME") orelse
585 if (comptime builtin.os.tag == .windows) std.posix.getenv("USERPROFILE") orelse "" else "";
592 // Cross-platform: use HOME env var (Linux/Windows).
593 //
594 // `std.c.getenv`, not `std.posix.getenv` — the latter does not
595 // exist in this Zig at all (`@hasDecl(std.posix, "getenv")` is
596 // false even on macOS). It compiled only because this branch is
597 // dead on macOS and Zig never analysed it; the moment anything
598 // compiled the file for another target it failed.
599 const home = envOrEmpty("HOME") orelse
600 (if (comptime builtin.os.tag == .windows) envOrEmpty("USERPROFILE") orelse "" else "");
586601
587602 if (home.len > 0) {
588603 // Same escape as above — env vars can contain any bytes.
renamed throughout · 10 hidden lines
Changes to packages/zig/src/bridge_fs.zig
@@ -635,8 +650,8 @@ pub const FSBridge = struct { renamed throughout
635650 }
636651 } else {
637652 // Cross-platform: use TMPDIR or /tmp
638 const tmp = std.posix.getenv("TMPDIR") orelse
639 if (comptime builtin.os.tag == .windows) std.posix.getenv("TEMP") orelse "C:\\Temp" else "/tmp";
653 const tmp = envOrEmpty("TMPDIR") orelse
654 if (comptime builtin.os.tag == .windows) envOrEmpty("TEMP") orelse "C:\\Temp" else "/tmp";
640655
641656 // Windows default uses a backslash which Zig reads as a JS escape
642657 // in the string literal — escape before embedding.
renamed throughout · 14 hidden lines
Changes to packages/zig/src/bridge_fs.zig
@@ -695,12 +710,12 @@ pub const FSBridge = struct { renamed throughout
695710 }
696711 } else if (comptime builtin.os.tag == .linux) {
697712 // XDG Base Directory: ~/.local/share or $XDG_DATA_HOME
698 const app_data = std.posix.getenv("XDG_DATA_HOME") orelse "";
713 const app_data = envOrEmpty("XDG_DATA_HOME") orelse "";
699714 var path_buf: [512]u8 = undefined;
700715 const app_data_path = if (app_data.len > 0)
701716 app_data
702717 else blk: {
703 const home = std.posix.getenv("HOME") orelse "";
718 const home = envOrEmpty("HOME") orelse "";
704719 if (home.len == 0) break :blk "";
705720 break :blk std.fmt.bufPrint(&path_buf, "{s}/.local/share", .{home}) catch "";
706721 };
Changes to packages/zig/src/bridge_fs.zig
@@ -721,7 +736,7 @@ pub const FSBridge = struct {
721736 } else if (comptime builtin.os.tag == .windows) {
722737 // Windows: %APPDATA% contains backslashes which are JS escapes —
723738 // must be escaped before embedding in the string literal.
724 const app_data = std.posix.getenv("APPDATA") orelse "";
739 const app_data = envOrEmpty("APPDATA") orelse "";
725740
726741 if (app_data.len > 0) {
727742 var esc_buf: [1024]u8 = undefined;
packages/zig/src/bridge_screen.zigmodified+35-2
Changes to packages/zig/src/bridge_screen.zig
@@ -1,6 +1,25 @@
11const std = ;
22const builtin = ;
33const bridge_error = ;
4const capabilities = ;
5
6/// The action names this bridge dispatches on.
7///
8/// Declared once, here, and referenced by both the dispatch chain below and the
9/// capability table beside it — so the two cannot disagree. That is enforced:
10/// `test/capabilities_test.zig` fails if a declared bridge compares `action`
11/// against a bare string literal anywhere, which makes it impossible to add an
12/// action without also declaring it.
13pub const A = struct {
14 pub const get_displays = "getDisplays";
15 pub const get_primary = "getPrimary";
16};
17
18/// What craft serves on the `screen` namespace.
19pub const capability_actions = [_]capabilities.ActionDecl{
20 .{ .name = A.get_displays, .reply = .result },
21 .{ .name = A.get_primary, .reply = .result },
22};
423
524const BridgeError = bridge_error.BridgeError;
625
@@ -25,9 +44,9 @@ pub const ScreenBridge = struct {
2544 }
2645
2746 pub fn handleMessage(self: *Self, action: []const u8, _: []const u8) !void {
28 if (std.mem.eql(u8, action, "getDisplays")) {
47 if (std.mem.eql(u8, action, A.get_displays)) {
2948 try self.getDisplays();
30 } else if (std.mem.eql(u8, action, "getPrimary")) {
49 } else if (std.mem.eql(u8, action, A.get_primary)) {
3150 try self.getPrimary();
3251 } else {
3352 return BridgeError.UnknownAction;
@@ -98,6 +117,16 @@ pub const ScreenBridge = struct {
98117var screen_observer_installed = false;
99118var screen_observer_instance: .objc.id = null;
100119
120/// The permit to dispatch `craft:screen:change`, taken out when the observer is
121/// actually installed.
122///
123/// Null until then, and that is the point: `craft.capabilities()` reports this
124/// channel live only because this line ran, not because a table said so. On a
125/// build where the observer never installs — a non-macOS target, or a failed
126/// class registration below — the page is told the channel is dead instead of
127/// subscribing to something that will never fire.
128var screen_change_emitter: ?capabilities.Emitter = null;
129
101130fn installScreenChangeObserver() void {
102131 if (screen_observer_installed) return;
103132 if (builtin.os.tag != .macos) return;
@@ -126,11 +155,15 @@ fn installScreenChangeObserver() void {
126155 f(center, macos.sel("addObserver:selector:name:object:"), screen_observer_instance, macos.sel("onScreenChange:"), note_name, @as(objc.id, null));
127156
128157 screen_observer_installed = true;
158 screen_change_emitter = capabilities.registerEmitter(.screen_change);
129159}
130160
131161export fn handleScreenChange(_: .objc.id, _: .objc.SEL, _: .objc.id) callconv(.c) void {
132162 const macos = ;
133163 const webview = macos.getGlobalWebView() orelse return;
164 // Named through the emitter rather than spelled again here, so the string
165 // the page listens for and the string capabilities reports are one string.
166 _ = screen_change_emitter orelse return;
134167 const script = "if (window.dispatchEvent) window.dispatchEvent(new CustomEvent('craft:screen:change'));";
135168 const NSString = macos.getClass("NSString");
136169 const js = macos.msgSend1(NSString, "stringWithUTF8String:", @as([*:0]const u8, script));
packages/zig/src/bridge_tray.zigmodified+50-8
Changes to packages/zig/src/bridge_tray.zig
@@ -4,6 +4,48 @@ const tray_menu = ;
44const bridge_error = ;
55const logging = ;
66const icons = ;
7const capabilities = ;
8
9/// The action names this bridge dispatches on.
10///
11/// Declared once, here, and referenced by both the dispatch chain below and the
12/// capability table beside it — so the two cannot disagree. That is enforced:
13/// `test/capabilities_test.zig` fails if a declared bridge compares `action`
14/// against a bare string literal anywhere, which makes it impossible to add an
15/// action without also declaring it.
16pub const A = struct {
17 pub const set_title = "setTitle";
18 pub const set_tooltip = "setTooltip";
19 pub const set_menu = "setMenu";
20 pub const poll_actions = "pollActions";
21 pub const hide = "hide";
22 pub const show = "show";
23 pub const set_icon = "setIcon";
24 pub const set_badge = "setBadge";
25};
26
27/// What craft serves on the `tray` namespace.
28pub const capability_actions = [_]capabilities.ActionDecl{
29 .{ .name = A.set_title, .reply = .none },
30 .{ .name = A.set_tooltip, .reply = .none },
31 .{ .name = A.set_menu, .reply = .none },
32 .{ .name = A.poll_actions, .reply = .none },
33 .{ .name = A.hide, .reply = .none },
34 .{ .name = A.show, .reply = .none },
35 .{ .name = A.set_icon, .reply = .none },
36 .{ .name = A.set_badge, .reply = .none },
37 // Reachable from JS, dispatched nowhere. `_send` resolves the moment the
38 // message is posted, so an app calling this gets a resolved promise and a
39 // tray icon that is still there. Declared rather than quietly fixed:
40 // whether "destroy" should remove the status item or is just `hide` under
41 // another name is an API question, not an introspection one.
42 .{
43 .name = "destroy",
44 .reply = .none,
45 .status = .unavailable,
46 .reason = "not implemented; call craft.tray.hide() instead",
47 },
48};
749
850const log = logging.tray;
951
@@ -115,21 +157,21 @@ pub const TrayBridge = struct {
115157 }
116158
117159 fn handleMessageInternal(self: *Self, action: []const u8, data: []const u8) !void {
118 if (std.mem.eql(u8, action, "setTitle")) {
160 if (std.mem.eql(u8, action, A.set_title)) {
119161 try self.setTitle(data);
120 } else if (std.mem.eql(u8, action, "setTooltip")) {
162 } else if (std.mem.eql(u8, action, A.set_tooltip)) {
121163 try self.setTooltip(data);
122 } else if (std.mem.eql(u8, action, "setMenu")) {
164 } else if (std.mem.eql(u8, action, A.set_menu)) {
123165 try self.setMenu(data);
124 } else if (std.mem.eql(u8, action, "pollActions")) {
166 } else if (std.mem.eql(u8, action, A.poll_actions)) {
125167 try self.pollActions();
126 } else if (std.mem.eql(u8, action, "hide")) {
168 } else if (std.mem.eql(u8, action, A.hide)) {
127169 try self.hide();
128 } else if (std.mem.eql(u8, action, "show")) {
170 } else if (std.mem.eql(u8, action, A.show)) {
129171 try self.showTray();
130 } else if (std.mem.eql(u8, action, "setIcon")) {
172 } else if (std.mem.eql(u8, action, A.set_icon)) {
131173 try self.setIcon(data);
132 } else if (std.mem.eql(u8, action, "setBadge")) {
174 } else if (std.mem.eql(u8, action, A.set_badge)) {
133175 try self.setBadge(data);
134176 } else {
135177 return BridgeError.UnknownAction;
packages/zig/src/capabilities.zigadded+418-0
Changes to packages/zig/src/capabilities.zig
@@ -0,0 +1,418 @@
1//! What the native side actually serves, as data the page can ask for.
2//!
3//! Three shipped bugs share one shape: the JS bridge declares a surface, native
4//! silently does not serve it, and the app finds out in production. Reachability
5//! of `window.craft.x` is not evidence that anything is behind it — the script is
6//! one `@embedFile`d blob, injected whole, whatever the binary implements.
7//!
8//! The point of this file is **not** to list the gaps. It is that there was no
9//! mechanism by which a shipped surface could be *known* to be wired, so the
10//! number of gaps was whatever the last manual audit said it was. This is the
11//! mechanism; the gaps are triage.
12//!
13//! ## The rule that makes it hold
14//!
15//! A declaration nobody dispatches on drifts on the first pull request that adds
16//! an action and forgets the table — and that is strictly worse than having no
17//! capabilities API at all, because it turns "the page cannot tell" into "the
18//! page is told something false". So:
19//!
20//! **An action name appears as a string literal exactly once, in the bridge's
21//! action table, and the dispatch chain compares against that same constant.**
22//!
23//! One literal, two readers. `test/capabilities_test.zig` enforces it three
24//! ways: every declared action must dispatch, no undeclared action may, and a
25//! declared bridge's source may not contain a raw literal comparison at all.
26//!
27//! ## Honesty about coverage
28//!
29//! A namespace craft has not audited is reported `undeclared` rather than
30//! guessed at. That is a real answer an app can act on — "craft will not claim
31//! anything about this" — and it is deliberately uncomfortable, because the
32//! ratchet in the conformance test only lets the number of them go down.
33
34const std = ;
35
36/// Whether an action sends a reply the caller is waiting on.
37///
38/// Worth recording because the two failure modes look nothing alike from the
39/// page: an action declared `.result` whose handler never replies leaves the
40/// caller parked until the request timeout, while a `.none` action that a
41/// caller awaits resolves immediately and means nothing.
42pub const Reply = enum {
43 /// Fire-and-forget: native acknowledges nothing.
44 none,
45 /// Native calls `sendResultToJS` under this action name.
46 result,
47};
48
49pub const ActionStatus = enum {
50 /// Dispatches, does the thing.
51 live,
52 /// Reachable, and known not to work. `reason` is required.
53 unavailable,
54};
55
56pub const ActionDecl = struct {
57 name: []const u8,
58 reply: Reply = .result,
59 status: ActionStatus = .live,
60 /// Required when `status == .unavailable`, and shown to the app.
61 reason: ?[]const u8 = null,
62};
63
64pub const NamespaceStatus = enum {
65 /// Has an action table, and the conformance test enforces it.
66 declared,
67 /// Routed, but craft has not audited it and will not claim anything.
68 undeclared,
69 /// Every action is reachable and none of them work. `reason` is required.
70 unavailable,
71 /// Implemented natively but absent from the dispatch chain, so nothing can
72 /// reach it. Distinct from `unavailable`: the code is there and works.
73 unrouted,
74};
75
76pub const NamespaceDecl = struct {
77 /// The `t` a message carries, e.g. "tray".
78 name: []const u8,
79 status: NamespaceStatus,
80 actions: []const ActionDecl = &.{},
81 reason: ?[]const u8 = null,
82};
83
84// =============================================================================
85// Event channels
86// =============================================================================
87
88/// Every `craft:*` event the JS surface subscribes to.
89///
90/// Enumerated rather than derived, because the names follow no convention worth
91/// deriving from: `craft:menu:action` is colon-separated and `craft:powerSleep`
92/// is camel-cased, and both are load-bearing spellings a page already listens
93/// for. Renaming them to be consistent would break every app.
94pub const Channel = enum {
95 menu_action,
96 theme,
97 settings_open,
98 window_focus,
99 window_blur,
100 window_resize,
101 window_move,
102 screen_change,
103 deep_link,
104 file_drop,
105 shortcut,
106 fs_change,
107 power_sleep,
108 power_wake,
109 network_change,
110 bluetooth_device_found,
111 bluetooth_device_connected,
112 bluetooth_device_disconnected,
113 touchbar_action,
114 serial_data,
115 speech_partial,
116 speech_final,
117
118 pub fn eventName(self: Channel) []const u8 {
119 return switch (self) {
120 .menu_action => "craft:menu:action",
121 .theme => "craft:theme",
122 .settings_open => "craft:settings:open",
123 .window_focus => "craft:window:focus",
124 .window_blur => "craft:window:blur",
125 .window_resize => "craft:window:resize",
126 .window_move => "craft:window:move",
127 .screen_change => "craft:screen:change",
128 .deep_link => "craft:deepLink",
129 .file_drop => "craft:fileDrop",
130 .shortcut => "craft:shortcut",
131 .fs_change => "craft:fs:change",
132 .power_sleep => "craft:powerSleep",
133 .power_wake => "craft:powerWake",
134 .network_change => "craft:networkChange",
135 .bluetooth_device_found => "craft:bluetooth:deviceFound",
136 .bluetooth_device_connected => "craft:bluetooth:deviceConnected",
137 .bluetooth_device_disconnected => "craft:bluetooth:deviceDisconnected",
138 .touchbar_action => "craft:touchbar:action",
139 .serial_data => "craft:serial:data",
140 .speech_partial => "craft:speechRecognition:partial",
141 .speech_final => "craft:speechRecognition:final",
142 };
143 }
144};
145
146const channel_count = std.enums.values(Channel).len;
147
148/// Which channels something has claimed it emits on.
149///
150/// Set only by `registerEmitter`, so "this channel has a live emitter" cannot
151/// be asserted by a table — it has to be earned by code that actually holds an
152/// `Emitter`. A subsystem that is compiled out, or never initialised, never
153/// marks its channel, and the manifest says so.
154var live_channels = std.mem.zeroes([channel_count]bool);
155
156/// A permit to dispatch on one channel.
157///
158/// Has no public initialiser other than `registerEmitter`: that is the whole
159/// design. Sixteen of craft's forty-two event subscriptions have no emitter
160/// anywhere, and no table derived from dispatch actions could ever have shown
161/// that.
162pub const Emitter = struct {
163 channel: Channel,
164
165 pub fn eventName(self: Emitter) []const u8 {
166 return self.channel.eventName();
167 }
168};
169
170/// Claim a channel and get the permit to emit on it.
171pub fn registerEmitter(channel: Channel) Emitter {
172 live_channels[@backingInt(channel)] = true;
173 return .{ .channel = channel };
174}
175
176pub fn isLive(channel: Channel) bool {
177 return live_channels[@backingInt(channel)];
178}
179
180/// Forget every registration. Tests only — the process otherwise only ever
181/// gains emitters.
182pub fn resetEmittersForTesting() void {
183 live_channels = std.mem.zeroes([channel_count]bool);
184}
185
186// =============================================================================
187// The manifest
188// =============================================================================
189
190fn appendEscaped(gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), s: []const u8) !void {
191 // Every string here is a compile-time constant from this file, so this is
192 // belt and braces rather than a security boundary — but a reason string
193 // with an apostrophe in it should not be able to produce broken JSON.
194 for (s) |c| switch (c) {
195 '"' => try out.appendSlice(gpa, "\\\""),
196 '\\' => try out.appendSlice(gpa, "\\\\"),
197 '\n' => try out.appendSlice(gpa, "\\n"),
198 '\r' => try out.appendSlice(gpa, "\\r"),
199 '\t' => try out.appendSlice(gpa, "\\t"),
200 0x00...0x08, 0x0B, 0x0C, 0x0E...0x1F => {
201 var hex: [6]u8 = undefined;
202 try out.appendSlice(gpa, try std.fmt.bufPrint(&hex, "\\u{x:0>4}", .{c}));
203 },
204 else => try out.append(gpa, c),
205 };
206}
207
208fn appendReason(gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), reason: ?[]const u8) !void {
209 if (reason) |r| {
210 try out.appendSlice(gpa, ",\"reason\":\"");
211 try appendEscaped(gpa, out, r);
212 try out.appendSlice(gpa, "\"");
213 }
214}
215
216/// Render the whole manifest as JSON.
217///
218/// Built at call time from the registry and the live-emitter set rather than
219/// cached: an emitter can register after the page loads (a subsystem that only
220/// initialises on first use), and a manifest that answered from a snapshot
221/// would be exactly the stale-declaration problem this file exists to prevent.
222pub fn buildManifest(gpa: std.mem.Allocator, registry: []const NamespaceDecl) ![]u8 {
223 var out: std.ArrayListUnmanaged(u8) = .empty;
224 errdefer out.deinit(gpa);
225
226 try out.appendSlice(gpa, "{\"namespaces\":{");
227 for (registry, 0..) |ns, index| {
228 if (index > 0) try out.append(gpa, ',');
229 try out.append(gpa, '"');
230 try appendEscaped(gpa, &out, ns.name);
231 try out.appendSlice(gpa, "\":{\"status\":\"");
232 try out.appendSlice(gpa, @tagName(ns.status));
233 try out.append(gpa, '"');
234 try appendReason(gpa, &out, ns.reason);
235
236 if (ns.actions.len > 0) {
237 try out.appendSlice(gpa, ",\"actions\":{");
238 for (ns.actions, 0..) |action, action_index| {
239 if (action_index > 0) try out.append(gpa, ',');
240 try out.append(gpa, '"');
241 try appendEscaped(gpa, &out, action.name);
242 try out.appendSlice(gpa, "\":{\"status\":\"");
243 try out.appendSlice(gpa, @tagName(action.status));
244 try out.appendSlice(gpa, "\",\"reply\":\"");
245 try out.appendSlice(gpa, @tagName(action.reply));
246 try out.append(gpa, '"');
247 try appendReason(gpa, &out, action.reason);
248 try out.append(gpa, '}');
249 }
250 try out.append(gpa, '}');
251 }
252 try out.append(gpa, '}');
253 }
254
255 try out.appendSlice(gpa, "},\"channels\":{");
256 inline for (std.enums.values(Channel), 0..) |channel, index| {
257 if (index > 0) try out.append(gpa, ',');
258 try out.append(gpa, '"');
259 try appendEscaped(gpa, &out, channel.eventName());
260 try out.appendSlice(gpa, "\":");
261 try out.appendSlice(gpa, if (isLive(channel)) "true" else "false");
262 }
263 try out.appendSlice(gpa, "}}");
264
265 return out.toOwnedSlice(gpa);
266}
267
268// =============================================================================
269// Tests
270// =============================================================================
271
272const testing = std.testing;
273
274test "a channel has no emitter until something registers one" {
275 resetEmittersForTesting();
276 defer resetEmittersForTesting();
277
278 try testing.expect(!isLive(.fs_change));
279 const emitter = registerEmitter(.fs_change);
280 try testing.expect(isLive(.fs_change));
281 try testing.expectEqualStrings("craft:fs:change", emitter.eventName());
282
283 // Registering one channel says nothing about any other.
284 try testing.expect(!isLive(.power_sleep));
285}
286
287test "every channel has a distinct event name" {
288 // A duplicate would make one of the two silently unreportable: the manifest
289 // is keyed by event name, so the second would overwrite the first.
290 const all = std.enums.values(Channel);
291 inline for (all, 0..) |left, i| {
292 inline for (all, 0..) |right, j| {
293 if (i < j) try testing.expect(!std.mem.eql(u8, left.eventName(), right.eventName()));
294 }
295 }
296}
297
298test "every channel name is a craft: event" {
299 inline for (std.enums.values(Channel)) |channel| {
300 try testing.expect(std.mem.startsWith(u8, channel.eventName(), "craft:"));
301 }
302}
303
304test "the manifest reports a declared namespace with its actions" {
305 resetEmittersForTesting();
306 defer resetEmittersForTesting();
307
308 const registry = [_]NamespaceDecl{.{
309 .name = "clipboard",
310 .status = .declared,
311 .actions = &.{
312 .{ .name = "readText", .reply = .result },
313 .{ .name = "writeText", .reply = .none },
314 },
315 }};
316
317 const json = try buildManifest(testing.allocator, &registry);
318 defer testing.allocator.free(json);
319
320 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
321 defer parsed.deinit();
322
323 const clipboard = parsed.value.object.get("namespaces").?.object.get("clipboard").?.object;
324 try testing.expectEqualStrings("declared", clipboard.get("status").?.string);
325 const actions = clipboard.get("actions").?.object;
326 try testing.expectEqualStrings("live", actions.get("readText").?.object.get("status").?.string);
327 try testing.expectEqualStrings("result", actions.get("readText").?.object.get("reply").?.string);
328 try testing.expectEqualStrings("none", actions.get("writeText").?.object.get("reply").?.string);
329}
330
331test "an unavailable surface carries the reason with it" {
332 // The reason is the whole value of declaring it: "updater is unavailable"
333 // sends an app looking for a bug in its own code, "Sparkle framework not
334 // linked" does not.
335 const registry = [_]NamespaceDecl{.{
336 .name = "updater",
337 .status = .unavailable,
338 .reason = "Sparkle framework is not linked into this build",
339 .actions = &.{
340 .{ .name = "checkForUpdates", .status = .unavailable, .reason = "Sparkle framework is not linked into this build" },
341 },
342 }};
343
344 const json = try buildManifest(testing.allocator, &registry);
345 defer testing.allocator.free(json);
346
347 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
348 defer parsed.deinit();
349
350 const updater = parsed.value.object.get("namespaces").?.object.get("updater").?.object;
351 try testing.expectEqualStrings("unavailable", updater.get("status").?.string);
352 try testing.expectEqualStrings("Sparkle framework is not linked into this build", updater.get("reason").?.string);
353 try testing.expectEqualStrings(
354 "Sparkle framework is not linked into this build",
355 updater.get("actions").?.object.get("checkForUpdates").?.object.get("reason").?.string,
356 );
357}
358
359test "an undeclared namespace claims nothing rather than claiming everything" {
360 // The honest answer for a namespace craft has not audited. It must not
361 // look like `declared` with an empty action list, which an app would read
362 // as "this namespace has no actions".
363 const registry = [_]NamespaceDecl{.{ .name = "midi", .status = .undeclared }};
364
365 const json = try buildManifest(testing.allocator, &registry);
366 defer testing.allocator.free(json);
367
368 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
369 defer parsed.deinit();
370
371 const midi = parsed.value.object.get("namespaces").?.object.get("midi").?.object;
372 try testing.expectEqualStrings("undeclared", midi.get("status").?.string);
373 try testing.expect(midi.get("actions") == null);
374}
375
376test "the manifest reports every channel, live or not" {
377 resetEmittersForTesting();
378 defer resetEmittersForTesting();
379 _ = registerEmitter(.menu_action);
380
381 const json = try buildManifest(testing.allocator, &.{});
382 defer testing.allocator.free(json);
383
384 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
385 defer parsed.deinit();
386 const channels = parsed.value.object.get("channels").?.object;
387
388 // Every channel is present — a dead one has to be reported as dead, not
389 // omitted, or an app cannot tell "no emitter" from "channel I misspelled".
390 try testing.expectEqual(@as(usize, channel_count), channels.count());
391 try testing.expect(channels.get("craft:menu:action").?.bool);
392 try testing.expect(!channels.get("craft:fs:change").?.bool);
393}
394
395test "a reason cannot break the manifest out of its JSON" {
396 const registry = [_]NamespaceDecl{.{
397 .name = "x",
398 .status = .unavailable,
399 .reason = "has a \" and a \\ and a \n newline",
400 }};
401 const json = try buildManifest(testing.allocator, &registry);
402 defer testing.allocator.free(json);
403
404 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
405 defer parsed.deinit();
406 try testing.expectEqualStrings(
407 "has a \" and a \\ and a \n newline",
408 parsed.value.object.get("namespaces").?.object.get("x").?.object.get("reason").?.string,
409 );
410}
411
412test "an empty registry is still valid JSON" {
413 const json = try buildManifest(testing.allocator, &.{});
414 defer testing.allocator.free(json);
415 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
416 defer parsed.deinit();
417 try testing.expectEqual(@as(usize, 0), parsed.value.object.get("namespaces").?.object.count());
418}
packages/zig/src/capability_registry.zigadded+99-0
Changes to packages/zig/src/capability_registry.zig
@@ -0,0 +1,99 @@
1//! Every namespace the bridge routes, and what craft is willing to claim about it.
2//!
3//! One row per `t` value in the live dispatch chain in `macos.zig`, which is
4//! checked: `test/capabilities_test.zig` fails if the chain gains an arm this
5//! file does not list, or lists one the chain does not have.
6//!
7//! ## Why most rows say `undeclared`
8//!
9//! Declaring a namespace means refactoring its dispatch chain so the action
10//! names live in exactly one place, and that is a mechanical change across
11//! every bridge — around 279 action names in 47 files. Landing all of it beside
12//! a new public API would produce a diff reviewable only by trusting the
13//! compiler.
14//!
15//! So a namespace craft has not audited answers `undeclared`, which is a real
16//! answer an app can act on: craft will not claim anything about it. It is
17//! deliberately uncomfortable, and the conformance test holds a ceiling on how
18//! many there may be that only ever goes down.
19
20pub const capabilities = ;
21const bridge_app = ;
22const bridge_capabilities = ;
23const bridge_clipboard = ;
24const bridge_screen = ;
25const bridge_tray = ;
26
27pub const registry = [_]capabilities.NamespaceDecl{
28 // Implemented and unreachable: bridge_marketplace.zig dispatches eleven
29 // actions, and no arm in the dispatch chain routes `marketplace` to it, so
30 // no message can arrive. Listed precisely because it is not routed — the
31 // alternative is that it stays invisible, which is how it got this way.
32 .{
33 .name = "marketplace",
34 .status = .unrouted,
35 .reason = "implemented but not routed: no dispatcher arm sends messages to it",
36 },
37 .{ .name = "app", .status = .declared, .actions = &bridge_app.capability_actions },
38 .{ .name = "appleScript", .status = .undeclared },
39 .{ .name = "audio", .status = .undeclared },
40 .{ .name = "autoLaunch", .status = .undeclared },
41 .{ .name = "biometric", .status = .undeclared },
42 .{ .name = "bluetooth", .status = .undeclared },
43 .{ .name = "bonjour", .status = .undeclared },
44 // The introspection namespace itself, so a page can tell "craft is too old
45 // to have capabilities" from "capabilities says this is missing".
46 .{ .name = "capabilities", .status = .declared, .actions = &bridge_capabilities.capability_actions },
47 .{ .name = "clipboard", .status = .declared, .actions = &bridge_clipboard.capability_actions },
48 .{ .name = "continuityCamera", .status = .undeclared },
49 .{ .name = "coreml", .status = .undeclared },
50 .{ .name = "crashReporter", .status = .undeclared },
51 .{ .name = "dialog", .status = .undeclared },
52 .{ .name = "dragOut", .status = .undeclared },
53 .{ .name = "fileAssociations", .status = .undeclared },
54 .{ .name = "focus", .status = .undeclared },
55 .{ .name = "fs", .status = .undeclared },
56 .{ .name = "handoff", .status = .undeclared },
57 .{ .name = "iap", .status = .undeclared },
58 .{ .name = "keychain", .status = .undeclared },
59 .{ .name = "localServer", .status = .undeclared },
60 .{ .name = "location", .status = .undeclared },
61 .{ .name = "log", .status = .undeclared },
62 .{ .name = "menu", .status = .undeclared },
63 .{ .name = "menubarCollapse", .status = .undeclared },
64 .{ .name = "midi", .status = .undeclared },
65 .{ .name = "nativeUI", .status = .undeclared },
66 .{ .name = "network", .status = .undeclared },
67 .{ .name = "notification", .status = .undeclared },
68 .{ .name = "pdf", .status = .undeclared },
69 .{ .name = "permissions", .status = .undeclared },
70 .{ .name = "power", .status = .undeclared },
71 // Landed after this registry was written, and the conformance test caught
72 // it — which is the mechanism doing its job on a real integration rather
73 // than on a contrived one. A good stage-1 candidate: its action names are
74 // already centralised in bridge_prefs_actions.zig.
75 .{ .name = "prefs", .status = .undeclared },
76 .{ .name = "printing", .status = .undeclared },
77 .{ .name = "screen", .status = .declared, .actions = &bridge_screen.capability_actions },
78 .{ .name = "screenCapture", .status = .undeclared },
79 .{ .name = "screenSharing", .status = .undeclared },
80 .{ .name = "serial", .status = .undeclared },
81 .{ .name = "serviceMenu", .status = .undeclared },
82 .{ .name = "shell", .status = .undeclared },
83 .{ .name = "shortcuts", .status = .undeclared },
84 .{ .name = "speech", .status = .undeclared },
85 .{ .name = "speechRecognition", .status = .undeclared },
86 .{ .name = "spotlight", .status = .undeclared },
87 .{ .name = "system", .status = .undeclared },
88 .{ .name = "tags", .status = .undeclared },
89 .{ .name = "touchbar", .status = .undeclared },
90 .{ .name = "tray", .status = .declared, .actions = &bridge_tray.capability_actions },
91 .{ .name = "updater", .status = .unavailable, .reason = "the Sparkle framework is not linked into this build, so every updater call reaches a null SUUpdater" },
92 .{ .name = "vision", .status = .undeclared },
93 .{ .name = "window", .status = .undeclared },
94};
95
96/// Rendered for `craft.capabilities()`. Caller frees.
97pub fn manifestJson(gpa: .mem.Allocator) ![]u8 {
98 return capabilities.buildManifest(gpa, &registry);
99}
packages/zig/src/js/craft-bridge.jsmodified+48-0
Changes to packages/zig/src/js/craft-bridge.js
@@ -737,6 +737,54 @@
737737 info: function () { return _req('prefs', 'prefs:info', '{}', PREFS_TIMEOUT_MS) },
738738 }
739739
740 // -------------------------------------------------------------------------
741 // capabilities — what the native side actually serves
742 // -------------------------------------------------------------------------
743 // This script is one blob, injected whole into every craft window, whatever
744 // the binary behind it implements. So `typeof craft.tray.destroy === 'function'`
745 // has never been evidence that anything is behind it — and three shipped bugs
746 // came from exactly that assumption. Ask instead.
747 //
748 // const caps = await craft.capabilities()
749 // caps.namespaces.updater.status // 'unavailable'
750 // caps.namespaces.updater.reason // 'the Sparkle framework is not linked…'
751 // caps.channels['craft:fs:change'] // false — subscribing would never fire
752 //
753 // A namespace craft has not audited reports `undeclared`, which means exactly
754 // that: craft will not claim anything either way. Treat it as "try it and
755 // handle failure", not as "missing".
756 window.craft.capabilities = function () {
757 return _req('capabilities', 'capabilities:get', '{}', 5000).then(function (manifest) {
758 window.__craftCapabilities = manifest
759 return manifest
760 })
761 }
762
763 // The last answer, or null if nothing has asked yet. For code that cannot
764 // await — a synchronous feature check during startup.
765 window.craft.capabilitiesSync = function () {
766 return window.__craftCapabilities || null
767 }
768
769 // Convenience: is this exact surface known to work?
770 //
771 // Answers false for an unavailable surface and true for an undeclared one.
772 // That asymmetry is deliberate — failing open is the only safe default for a
773 // mechanism that describes itself, and an older binary with no capabilities
774 // support at all must not make working code stop calling.
775 window.craft.supports = function (path) {
776 const caps = window.__craftCapabilities
777 if (!caps || !caps.namespaces) return true
778 const parts = String(path).split('.')
779 const ns = caps.namespaces[parts[0]]
780 if (!ns) return true
781 if (ns.status === 'unavailable' || ns.status === 'unrouted') return false
782 if (parts.length < 2 || !ns.actions) return ns.status !== 'unavailable'
783 const action = ns.actions[parts[1]]
784 if (!action) return ns.status !== 'declared'
785 return action.status !== 'unavailable'
786 }
787
740788 // -------------------------------------------------------------------------
741789 // theme — system appearance
742790 // -------------------------------------------------------------------------
packages/zig/src/localization.zigmodified+10-1
Changes to packages/zig/src/localization.zig
@@ -24,6 +24,15 @@
2424const std = ;
2525const builtin = ;
2626
27/// An environment variable as a slice, or null when it is unset.
28///
29/// libc rather than std: `std.posix` has no `getenv` in this compiler version,
30/// and this build links libc everywhere.
31fn envOrEmpty(name: [*:0]const u8) ?[]const u8 {
32 const raw = std.c.getenv(name) orelse return null;
33 return std.mem.span(raw);
34}
35
2736/// Supported locale identifiers
2837pub const Locale = struct {
2938 language: []const u8, // ISO 639-1 (e.g., "en", "es", "zh")
@@ -412,7 +421,7 @@ pub const Localization = struct {
412421 // and returned a Locale whose slices pointed into that dead buffer.
413422 const env_vars = [_][]const u8{ "LC_ALL", "LC_MESSAGES", "LANG" };
414423 for (env_vars) |env_var| {
415 if (std.posix.getenv(env_var)) |value| {
424 if (envOrEmpty(env_var)) |value| {
416425 // Strip `.UTF-8`, `.ISO-8859-1`, etc. — the codepage suffix
417426 // isn't part of the locale identifier.
418427 const dot_pos = std.mem.indexOf(u8, value, ".");
packages/zig/src/macos.zigmodified+8-0
Changes to packages/zig/src/macos.zig
@@ -4101,6 +4101,7 @@ var global_serial_bridge: ?*.SerialBridge = null;
41014101var global_focus_bridge: ?*.FocusBridge = null;
41024102var global_screen_sharing_bridge: ?*.ScreenSharingBridge = null;
41034103var global_prefs_bridge: ?*.PrefsBridge = null;
4104var global_capabilities_bridge: ?*.CapabilitiesBridge = null;
41044105
41054106pub fn setGlobalTrayHandle(handle: *anyopaque) void {
41064107 global_tray_handle_for_bridge = handle;
@@ -4394,6 +4395,11 @@ pub fn setupBridgeHandlers(allocator: std.mem.Allocator, tray_handle: ?*anyopaqu
43944395 global_prefs_bridge = try allocator.create(T);
43954396 global_prefs_bridge.?.* = T.init(allocator);
43964397 }
4398 if (global_capabilities_bridge == null) {
4399 const T = .CapabilitiesBridge;
4400 global_capabilities_bridge = try allocator.create(T);
4401 global_capabilities_bridge.?.* = T.init(allocator);
4402 }
43974403
43984404 // theme + dragOut + deepLink: native modules with their own state, no
43994405 // shared bridge struct — they install handlers directly via init().
@@ -4759,6 +4765,8 @@ pub fn handleBridgeMessageJSON(json_str: []const u8) !void {
47594765 if (global_screen_sharing_bridge) |bridge| try bridge.handleMessage(action, data_json_str);
47604766 } else if (std.mem.eql(u8, msg_type, "prefs")) {
47614767 if (global_prefs_bridge) |bridge| try bridge.handleMessage(action, data_json_str);
4768 } else if (std.mem.eql(u8, msg_type, "capabilities")) {
4769 if (global_capabilities_bridge) |bridge| try bridge.handleMessage(action, data_json_str);
47624770 } else if (std.mem.eql(u8, msg_type, "debug")) {
47634771 // Handle debug messages
47644772 if (comptime builtin.mode == .debug) {
packages/zig/src/system_enhancements.zigmodified+10-1
Changes to packages/zig/src/system_enhancements.zig
@@ -2,6 +2,15 @@ const std = ;
22const io_context = ;
33const builtin = ;
44
5/// An environment variable as a slice, or null when it is unset.
6///
7/// libc rather than std: `std.posix` has no `getenv` in this compiler version,
8/// and this build links libc everywhere.
9fn envOrEmpty(name: [*:0]const u8) ?[]const u8 {
10 const raw = std.c.getenv(name) orelse return null;
11 return std.mem.span(raw);
12}
13
514/// System-level enhancements for Craft framework
615/// Includes: keyboard shortcuts, system events, dock features, etc.
716
@@ -612,7 +621,7 @@ pub const LocalStorage = struct {
612621
613622 pub fn init(allocator: std.mem.Allocator, app_name: []const u8) !LocalStorage {
614623 // Create path: ~/Library/Application Support/<app_name>/storage.json (macOS)
615 const home = std.posix.getenv("HOME") orelse return error.NoHomeDir;
624 const home = envOrEmpty("HOME") orelse return error.NoHomeDir;
616625
617626 const path = try std.fmt.allocPrint(
618627 allocator,
packages/zig/test/capabilities_test.zigadded+372-0
Changes to packages/zig/test/capabilities_test.zig
@@ -0,0 +1,372 @@
1//! The conformance test: does what craft *declares* match what it *dispatches*?
2//!
3//! This is the point of issue #49. Three bugs shipped where the JS bridge
4//! declared a surface, native silently did not serve it, and the app found out
5//! in production — and none of them were catchable, because nothing compared
6//! the two halves. Every check here exists to make one specific way of drifting
7//! fail the build.
8//!
9//! The checks are deliberately in both directions. Only comparing declarations
10//! against dispatch catches a deleted arm; only the reverse catches an arm that
11//! was added and never declared, which is the drift that actually happens.
12
13const std = ;
14const testing = std.testing;
15
16const registry_mod = ;
17const capabilities = registry_mod.capabilities;
18const registry = registry_mod.registry;
19
20/// The bridges that have opted in, with the source the literal-ban is checked
21/// against. Adding a row here — plus the matching `addAnonymousImport` in
22/// build.zig — is what "declaring a namespace" means.
23///
24/// Embedded rather than read from disk: the test is then hermetic, independent
25/// of the working directory, and looking at exactly the bytes the compiler saw.
26const declared_sources = [_]struct {
27 namespace: []const u8,
28 path: []const u8,
29 /// The dispatch chain, which the literal ban and the dispatched-on check read.
30 source: []const u8,
31 /// Where the `pub const name = "action";` declarations live, when they are
32 /// not in the same file as the chain. Defaults to `source`.
33 names: ?[]const u8 = null,
34}{
35 .{ .namespace = "clipboard", .path = "src/bridge_clipboard.zig", .source = @embedFile("src/bridge_clipboard.zig") },
36 .{ .namespace = "tray", .path = "src/bridge_tray.zig", .source = @embedFile("src/bridge_tray.zig") },
37 .{ .namespace = "app", .path = "src/bridge_app.zig", .source = @embedFile("src/bridge_app.zig") },
38 .{ .namespace = "screen", .path = "src/bridge_screen.zig", .source = @embedFile("src/bridge_screen.zig") },
39 .{
40 .namespace = "capabilities",
41 .path = "src/bridge_capabilities.zig",
42 .source = @embedFile("src/bridge_capabilities.zig"),
43 // Its action names live in a separate file so the JS-contract test can
44 // reference them without importing the registry — which reaches every
45 // declared bridge and, through them, the whole native graph.
46 .names = @embedFile("src/bridge_capabilities_actions.zig"),
47 },
48};
49
50const dispatcher_source = @embedFile("src/macos.zig");
51
52/// How many namespaces may still answer `undeclared`.
53///
54/// A ratchet, not a target. It exists so `undeclared` cannot quietly become
55/// where surfaces go to avoid being audited — the number may only ever be
56/// lowered, and lowering it is what "declaring a namespace" costs.
57const max_undeclared: usize = 45;
58
59test "nothing is marked declared without being enforced" {
60 // Closes the obvious loophole: marking a namespace `.declared` in the
61 // registry buys the app's trust, and it must cost a `declared_sources` row,
62 // which is what subjects the bridge to the three checks below.
63 for (registry) |ns| {
64 if (ns.status != .declared) continue;
65 var enforced = false;
66 for (declared_sources) |declared| {
67 if (std.mem.eql(u8, declared.namespace, ns.name)) enforced = true;
68 }
69 if (!enforced) {
70 std.debug.print(
71 "namespace '{s}' is marked declared but has no entry in declared_sources,\n" ++
72 " so nothing checks its table against its dispatch chain.\n",
73 .{ns.name},
74 );
75 return error.DeclaredNamespaceNotEnforced;
76 }
77 }
78}
79
80test "every declared namespace is in the registry, and declared" {
81 for (declared_sources) |declared| {
82 var found = false;
83 for (registry) |ns| {
84 if (!std.mem.eql(u8, ns.name, declared.namespace)) continue;
85 found = true;
86 try testing.expectEqual(capabilities.NamespaceStatus.declared, ns.status);
87 // A declared namespace with no actions would report as "this
88 // namespace serves nothing", which is a different and wrong claim.
89 try testing.expect(ns.actions.len > 0);
90 }
91 if (!found) {
92 std.debug.print("namespace '{s}' has a source entry but no registry row\n", .{declared.namespace});
93 return error.NamespaceMissingFromRegistry;
94 }
95 }
96}
97
98test "a declared bridge compares against no action literals" {
99 // The check that makes the other two airtight. With literals banned, a new
100 // `else if (std.mem.eql(u8, action, "newThing"))` will not compile past
101 // review without a matching table entry, because there is nowhere else to
102 // put the name.
103 for (declared_sources) |declared| {
104 const source = declared.source;
105
106 if (std.mem.indexOf(u8, source, "std.mem.eql(u8, action, \"")) |offset| {
107 const line = std.mem.count(u8, source[0..offset], "\n") + 1;
108 std.debug.print(
109 "{s}:{d}: a declared bridge compares `action` against a string literal.\n" ++
110 " Add the name to its `A` block and compare against that instead, so the\n" ++
111 " capability table and the dispatch chain cannot disagree.\n",
112 .{ declared.path, line },
113 );
114 return error.ActionLiteralInDeclaredBridge;
115 }
116 }
117}
118
119/// Pull `pub const <ident> = "<name>";` pairs out of a source.
120///
121/// Indentation-agnostic: the names sit inside an `A` struct in most bridges and
122/// at the top level in the ones whose names are split into their own file, and
123/// which of those a bridge chose is not something this check should care about.
124fn constantFor(source: []const u8, action_name: []const u8) ?[]const u8 {
125 const decl = "pub const ";
126 var search: usize = 0;
127 while (std.mem.indexOfPos(u8, source, search, decl)) |at| {
128 const line_start = if (std.mem.lastIndexOfScalar(u8, source[0..at], '\n')) |nl| nl + 1 else 0;
129 const line_end = std.mem.indexOfScalarPos(u8, source, at, '\n') orelse source.len;
130 search = line_end;
131
132 // Only a declaration, not `pub const` appearing inside a comment.
133 if (std.mem.trim(u8, source[line_start..at], " \t").len != 0) continue;
134
135 const line = source[at..line_end];
136 // ` = "` is four bytes, so the value starts at eq + 4.
137 const eq = std.mem.indexOf(u8, line, " = \"") orelse continue;
138 const close = std.mem.lastIndexOfScalar(u8, line, '"') orelse continue;
139 if (close <= eq + 4) continue;
140 if (!std.mem.eql(u8, line[eq + 4 .. close], action_name)) continue;
141
142 return std.mem.trim(u8, line[decl.len..eq], " ");
143 }
144 return null;
145}
146
147test "every declared action is actually dispatched on" {
148 // Catches a table row whose dispatch arm was deleted.
149 //
150 // Checking for the *name* would not: `"setBadge"` still appears in the `A`
151 // block after its `else if` is removed, so the row and the string agree
152 // while nothing dispatches. The constant is the thing to look for, because
153 // the only places it can appear are the table and the chain. This test was
154 // written the wrong way first and a control check caught it.
155 for (declared_sources) |declared| {
156 const source = declared.source;
157
158 for (registry) |ns| {
159 if (!std.mem.eql(u8, ns.name, declared.namespace)) continue;
160 for (ns.actions) |action| {
161 // An action declared `unavailable` is one the page can reach
162 // and craft does not serve — `tray.destroy` has no dispatch arm
163 // at all, and saying so is the whole point of declaring it.
164 if (action.status == .unavailable) continue;
165
166 const constant = constantFor(declared.names orelse source, action.name) orelse {
167 std.debug.print("{s} declares '{s}' with no `A` entry naming it\n", .{ declared.path, action.name });
168 return error.DeclaredActionHasNoConstant;
169 };
170
171 var needle_buf: [160]u8 = undefined;
172 const needle = try std.fmt.bufPrint(&needle_buf, "action, A.{s})", .{constant});
173 if (std.mem.indexOf(u8, source, needle) == null) {
174 std.debug.print(
175 "{s} declares '{s}' (A.{s}) but nothing dispatches on it.\n" ++
176 " Either the arm was deleted, or the declaration should go.\n",
177 .{ declared.path, action.name, constant },
178 );
179 return error.DeclaredActionNotDispatched;
180 }
181 }
182 }
183 }
184}
185
186test "every dispatched action is declared" {
187 // The other direction, within a bridge: an `A` constant that the chain
188 // compares against but the table does not list.
189 for (declared_sources) |declared| {
190 const source = declared.source;
191
192 var search: usize = 0;
193 while (std.mem.indexOfPos(u8, source, search, "action, A.")) |at| {
194 const name_start = at + "action, A.".len;
195 const name_end = std.mem.indexOfScalarPos(u8, source, name_start, ')') orelse break;
196 const constant = source[name_start..name_end];
197 search = name_end;
198
199 var declared_here = false;
200 for (registry) |ns| {
201 if (!std.mem.eql(u8, ns.name, declared.namespace)) continue;
202 for (ns.actions) |action| {
203 const c = constantFor(declared.names orelse source, action.name) orelse continue;
204 if (std.mem.eql(u8, c, constant)) declared_here = true;
205 }
206 }
207 if (!declared_here) {
208 std.debug.print(
209 "{s} dispatches on A.{s} but the capability table does not list it.\n" ++
210 " Add it to `capability_actions`.\n",
211 .{ declared.path, constant },
212 );
213 return error.DispatchedActionNotDeclared;
214 }
215 }
216 }
217}
218
219test "the registry covers exactly the namespaces the dispatcher routes" {
220 // Both directions. A missing row means a routed namespace is invisible to
221 // capabilities; an extra row means capabilities advertises something no
222 // message can reach — which is how `marketplace` came to implement eleven
223 // actions nothing can call.
224 const source = dispatcher_source;
225
226 const start = std.mem.indexOf(u8, source, "pub fn handleBridgeMessageJSON") orelse
227 return error.DispatcherNotFound;
228 // The live chain only; `handleBridgeMessage` below it is a dead fallback
229 // that parses `type`/`action`/`data` while every injected script posts
230 // `t`/`a`/`d`.
231 const end = std.mem.indexOfPos(u8, source, start, "\npub fn handleBridgeMessage(") orelse source.len;
232 const chain = source[start..end];
233
234 for (registry) |ns| {
235 // `unrouted` is the row that says "no arm routes this", so requiring an
236 // arm for it would be a contradiction.
237 if (ns.status == .unrouted) continue;
238
239 var needle_buf: [128]u8 = undefined;
240 const needle = try std.fmt.bufPrint(&needle_buf, "msg_type, \"{s}\"", .{ns.name});
241 if (std.mem.indexOf(u8, chain, needle) == null) {
242 std.debug.print("registry lists '{s}' but the dispatcher does not route it\n", .{ns.name});
243 return error.RegistryNamespaceNotRouted;
244 }
245 }
246
247 // And the reverse: every arm in the chain must have a row.
248 var search: usize = 0;
249 while (std.mem.indexOfPos(u8, chain, search, "msg_type, \"")) |at| {
250 const name_start = at + "msg_type, \"".len;
251 const name_end = std.mem.indexOfScalarPos(u8, chain, name_start, '"') orelse break;
252 const name = chain[name_start..name_end];
253 search = name_end;
254
255 // `debug` is a dispatcher-internal arm, not a bridge namespace: it has
256 // no bridge struct and no JS facade.
257 if (std.mem.eql(u8, name, "debug")) continue;
258
259 var known = false;
260 for (registry) |ns| {
261 if (std.mem.eql(u8, ns.name, name)) known = true;
262 }
263 if (!known) {
264 std.debug.print(
265 "the dispatcher routes '{s}' but the registry has no row for it.\n" ++
266 " Add one to src/capability_registry.zig — `.undeclared` is a valid answer.\n",
267 .{name},
268 );
269 return error.RoutedNamespaceNotInRegistry;
270 }
271 }
272}
273
274test "an unrouted namespace really is unrouted" {
275 // Otherwise the status becomes a place to park something that was quietly
276 // wired up later, and the manifest starts lying in the safe direction.
277 const source = dispatcher_source;
278 const start = std.mem.indexOf(u8, source, "pub fn handleBridgeMessageJSON") orelse
279 return error.DispatcherNotFound;
280 const end = std.mem.indexOfPos(u8, source, start, "\npub fn handleBridgeMessage(") orelse source.len;
281 const chain = source[start..end];
282
283 for (registry) |ns| {
284 if (ns.status != .unrouted) continue;
285 var needle_buf: [128]u8 = undefined;
286 const needle = try std.fmt.bufPrint(&needle_buf, "msg_type, \"{s}\"", .{ns.name});
287 if (std.mem.indexOf(u8, chain, needle) != null) {
288 std.debug.print("'{s}' is marked unrouted but the dispatcher routes it\n", .{ns.name});
289 return error.UnroutedNamespaceIsRouted;
290 }
291 }
292}
293
294test "undeclared namespaces are under the ratchet" {
295 var undeclared: usize = 0;
296 for (registry) |ns| {
297 if (ns.status == .undeclared) undeclared += 1;
298 }
299 if (undeclared > max_undeclared) {
300 std.debug.print(
301 "{d} namespaces are undeclared; the ceiling is {d}.\n" ++
302 " Declare one, or say why the ceiling should rise — it is meant to fall.\n",
303 .{ undeclared, max_undeclared },
304 );
305 return error.TooManyUndeclaredNamespaces;
306 }
307 // And the ceiling must not be left slack: if it has been beaten, lower it.
308 if (undeclared + 2 < max_undeclared) {
309 std.debug.print(
310 "only {d} namespaces are undeclared but the ceiling is still {d} — lower it.\n",
311 .{ undeclared, max_undeclared },
312 );
313 return error.RatchetLeftSlack;
314 }
315}
316
317test "an unavailable surface always says why" {
318 // Without a reason, "unavailable" sends an app looking for a bug in its own
319 // code. With one, it is a fact they can act on.
320 for (registry) |ns| {
321 if (ns.status == .unavailable or ns.status == .unrouted) {
322 if (ns.reason == null or ns.reason.?.len == 0) {
323 std.debug.print("namespace '{s}' is {s} with no reason\n", .{ ns.name, @tagName(ns.status) });
324 return error.UnavailableWithoutReason;
325 }
326 }
327 for (ns.actions) |action| {
328 if (action.status == .unavailable and (action.reason == null or action.reason.?.len == 0)) {
329 std.debug.print("{s}.{s} is unavailable with no reason\n", .{ ns.name, action.name });
330 return error.UnavailableWithoutReason;
331 }
332 }
333 }
334}
335
336test "no namespace or action is declared twice" {
337 for (registry, 0..) |a, i| {
338 for (registry[i + 1 ..]) |b| {
339 if (std.mem.eql(u8, a.name, b.name)) {
340 std.debug.print("namespace '{s}' has two registry rows\n", .{a.name});
341 return error.DuplicateNamespace;
342 }
343 }
344 for (a.actions, 0..) |x, j| {
345 for (a.actions[j + 1 ..]) |y| {
346 if (std.mem.eql(u8, x.name, y.name)) {
347 std.debug.print("{s} declares action '{s}' twice\n", .{ a.name, x.name });
348 return error.DuplicateAction;
349 }
350 }
351 }
352 }
353}
354
355test "the manifest renders and parses" {
356 const json = try registry_mod.manifestJson(testing.allocator);
357 defer testing.allocator.free(json);
358
359 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
360 defer parsed.deinit();
361
362 const namespaces = parsed.value.object.get("namespaces").?.object;
363 try testing.expectEqual(registry.len, namespaces.count());
364 try testing.expectEqualStrings("declared", namespaces.get("clipboard").?.object.get("status").?.string);
365 try testing.expectEqualStrings("unavailable", namespaces.get("updater").?.object.get("status").?.string);
366
367 // Every channel is reported, including the dead ones — an app has to be
368 // able to tell "no emitter" from "a name I misspelled".
369 const channels = parsed.value.object.get("channels").?.object;
370 try testing.expect(channels.count() > 0);
371 try testing.expect(channels.get("craft:fs:change") != null);
372}
packages/zig/test/injected_js_test.zigmodified+117-0
Changes to packages/zig/test/injected_js_test.zig
@@ -18,6 +18,8 @@ const js = ;
1818const testing = std.testing;
1919const contracts = ;
2020const bridge_menu = contracts.menu;
21const capabilities = contracts.capabilities;
22const capability_actions = contracts.capabilities_actions;
2123const prefs = contracts.prefs;
2224const prefs_actions = contracts.prefs_actions;
2325const shortcut_registry = contracts.shortcuts;
@@ -815,3 +817,118 @@ test "unsubscribing from settings stops delivery, and does not double-count" {
815817 // an unsubscribe called twice must not push it negative and silence it.
816818 try testing.expectEqualStrings("0", try fx.text("String(window.__craftSettingsListeners)"));
817819}
820
821// =============================================================================
822// Capabilities (#49)
823// =============================================================================
824
825/// A manifest with one of each namespace status, rendered by the real
826/// renderer. The registry's actual contents are checked against the actual
827/// dispatch chain in `test/capabilities_test.zig`; what matters here is that
828/// the JSON native produces is the JSON the facade reads.
829fn fixtureManifest() ![]u8 {
830 const registry = [_]capabilities.NamespaceDecl{
831 .{ .name = "clipboard", .status = .declared, .actions = &.{
832 .{ .name = "readText", .reply = .result },
833 } },
834 .{ .name = "tray", .status = .declared, .actions = &.{
835 .{ .name = "setTitle", .reply = .none },
836 .{ .name = "destroy", .reply = .none, .status = .unavailable, .reason = "not implemented; call craft.tray.hide() instead" },
837 } },
838 .{ .name = "updater", .status = .unavailable, .reason = "the Sparkle framework is not linked into this build" },
839 .{ .name = "marketplace", .status = .unrouted, .reason = "implemented but not routed" },
840 .{ .name = "midi", .status = .undeclared },
841 };
842 return capabilities.buildManifest(testing.allocator, &registry);
843}
844
845test "craft.capabilities posts the action the native bridge dispatches on" {
846 var fx = try Fixture.init();
847 defer fx.deinit();
848 const ctx = fx.ctx;
849
850 _ = try ctx.evaluate(WEBVIEW_HOST);
851 _ = try ctx.evaluate(BRIDGE);
852 _ = try ctx.evaluate("window.craft.capabilities();");
853
854 try testing.expectEqualStrings("1", try fx.text("String(posted.length)"));
855 try testing.expectEqualStrings("capabilities", try fx.text("posted[0].t"));
856 try testing.expectEqualStrings(capability_actions.get, try fx.text("posted[0].a"));
857}
858
859test "the manifest native builds is the shape the facade reads" {
860 // The contract carried across the boundary: the real registry rendered by
861 // the real renderer, handed to the real facade.
862 var fx = try Fixture.init();
863 defer fx.deinit();
864 const ctx = fx.ctx;
865
866 _ = try ctx.evaluate(WEBVIEW_HOST);
867 _ = try ctx.evaluate(BRIDGE);
868 _ = try ctx.evaluate(
869 \\var caps = null;
870 \\window.craft.capabilities().then(function (c) { caps = c });
871 );
872
873 const manifest = try fixtureManifest();
874 defer testing.allocator.free(manifest);
875
876 const script = try std.fmt.allocPrint(
877 testing.allocator,
878 "window.__craftBridgeResult('{s}',{s});",
879 .{ capability_actions.get, manifest },
880 );
881 defer testing.allocator.free(script);
882 _ = try ctx.evaluate(script);
883
884 try testing.expectEqualStrings("declared", try fx.text("caps.namespaces.clipboard.status"));
885 try testing.expectEqualStrings("unavailable", try fx.text("caps.namespaces.updater.status"));
886 // The reason is the difference between an app hunting a bug in its own code
887 // and an app knowing the answer.
888 try testing.expect(std.mem.indexOf(u8, try fx.text("caps.namespaces.updater.reason"), "Sparkle") != null);
889 try testing.expectEqualStrings("false", try fx.text("String(caps.channels['craft:fs:change'])"));
890}
891
892test "craft.supports answers from the manifest, and fails open without one" {
893 var fx = try Fixture.init();
894 defer fx.deinit();
895 const ctx = fx.ctx;
896
897 _ = try ctx.evaluate(WEBVIEW_HOST);
898 _ = try ctx.evaluate(BRIDGE);
899
900 // Before anything has asked, everything is supported. An older binary with
901 // no capabilities support must not make working code stop calling.
902 try testing.expectEqualStrings("true", try fx.text("String(window.craft.supports('updater.checkForUpdates'))"));
903 try testing.expectEqualStrings("true", try fx.text("String(window.craft.capabilitiesSync() === null)"));
904
905 _ = try ctx.evaluate("window.craft.capabilities();");
906 const manifest = try fixtureManifest();
907 defer testing.allocator.free(manifest);
908 const script = try std.fmt.allocPrint(
909 testing.allocator,
910 "window.__craftBridgeResult('{s}',{s});",
911 .{ capability_actions.get, manifest },
912 );
913 defer testing.allocator.free(script);
914 _ = try ctx.evaluate(script);
915
916 // Now it answers from the manifest.
917 try testing.expectEqualStrings("false", try fx.text("String(window.craft.supports('updater'))"));
918 try testing.expectEqualStrings("false", try fx.text("String(window.craft.supports('tray.destroy'))"));
919 try testing.expectEqualStrings("true", try fx.text("String(window.craft.supports('tray.setTitle'))"));
920 try testing.expectEqualStrings("true", try fx.text("String(window.craft.supports('clipboard.readText'))"));
921 // An undeclared namespace is not a missing one: craft claims nothing, so
922 // the app should try it rather than skip it.
923 try testing.expectEqualStrings("true", try fx.text("String(window.craft.supports('midi'))"));
924 try testing.expectEqualStrings("true", try fx.text("String(window.craft.supports('midi.anything'))"));
925 // A declared namespace does know its own action list, so an unknown action
926 // in one really is absent.
927 try testing.expectEqualStrings("false", try fx.text("String(window.craft.supports('clipboard.noSuchAction'))"));
928 // `unrouted` is not `unavailable`, and both must answer false. This case is
929 // why the namespace-status check exists separately from the action one: a
930 // control experiment that deleted it left every other assertion here
931 // passing.
932 try testing.expectEqualStrings("false", try fx.text("String(window.craft.supports('marketplace'))"));
933 try testing.expectEqualStrings("false", try fx.text("String(window.craft.supports('marketplace.list'))"));
934}