also looking at this
feat(bridge): let the page ask what native actually serves
#60
20 files
+1546
-37
| @@ -11,6 +11,7 @@ The Craft JavaScript Bridge provides a seamless interface for your web applicati | ||
| 11 | 11 | - [Global Shortcuts (macOS)](#global-shortcuts-macos) |
| 12 | 12 | - [Settings and Preferences (macOS)](#settings-and-preferences-macos) |
| 13 | 13 | - [Headless mode](#headless-mode) |
| 14 | - [Capabilities](#capabilities) | |
| 14 | 15 | - [Window API](#window-api) |
| 15 | 16 | - [App API](#app-api) |
| 16 | 17 | - [TypeScript Support](#typescript-support) |
| @@ -520,6 +521,58 @@ sockets at all — and `safaridriver` refuses to attach to anything that is not | ||
| 520 | 521 | Safari. Driving a craft page from another process needs a control channel craft |
| 521 | 522 | owns, which does not exist yet. |
| 522 | 523 | |
| 524 | ## Capabilities | |
| 525 | ||
| 526 | This script is one blob, `@embedFile`d into the binary and injected whole into | |
| 527 | every craft window — the same JavaScript whatever the native side behind it | |
| 528 | implements. So `typeof window.craft.tray.destroy === 'function'` has never been | |
| 529 | evidence that anything is behind it, and three shipped bugs came from assuming | |
| 530 | otherwise. Ask instead. | |
| 531 | ||
| 532 | ```javascript | |
| 533 | const caps = await window.craft.capabilities() | |
| 534 | ||
| 535 | caps.namespaces.updater.status // 'unavailable' | |
| 536 | caps.namespaces.updater.reason // 'the Sparkle framework is not linked into this build' | |
| 537 | caps.channels['craft:fs:change'] // false — you could subscribe, it would never fire | |
| 538 | ``` | |
| 539 | ||
| 540 | Or, for one surface: | |
| 541 | ||
| 542 | ```javascript | |
| 543 | if (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 | ||
| 555 | Most namespaces are `undeclared` today. Declaring one means refactoring its | |
| 556 | dispatch chain so each action name exists in exactly one place — roughly 279 | |
| 557 | names across 47 files — so it is being done in batches, and the conformance test | |
| 558 | holds a ceiling on how many may remain that only ever goes down. | |
| 559 | ||
| 560 | ### `channels` | |
| 561 | ||
| 562 | Every `craft:*` event, and whether anything native emits on it. This cannot be | |
| 563 | derived from the action tables — an emitter is a separate piece of code that may | |
| 564 | or may not have been written — so a channel is reported live only because the | |
| 565 | code that emits on it took out a permit at startup. Sixteen channels the JS | |
| 566 | surface subscribes to currently have no emitter at all. | |
| 567 | ||
| 568 | ### `supports()` fails open | |
| 569 | ||
| 570 | It returns `true` before capabilities have been fetched, and `true` for an | |
| 571 | `undeclared` namespace. A feature-detection mechanism that breaks working code | |
| 572 | when 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` | |
| 574 | namespace — answers `false`. | |
| 575 | ||
| 523 | 576 | ## Window API |
| 524 | 577 | |
| 525 | 578 | Control the application window from JavaScript. |
| @@ -757,6 +810,7 @@ async function onComplete() { | ||
| 757 | 810 | | Global Shortcuts | ✅ | �� | �� | |
| 758 | 811 | | Settings… item | ✅ | �� | �� | |
| 759 | 812 | | Preferences | ✅ | �� | �� | |
| 813 | | Capabilities | ✅ | �� | �� | | |
| 760 | 814 | |
| 761 | 815 | ✅ Implemented | �� In Progress | ➖ Not Applicable |
| 762 | 816 | |
| @@ -563,6 +563,10 @@ export type { | ||
| 563 | 563 | CraftDatabaseAPI, |
| 564 | 564 | CraftHttpAPI, |
| 565 | 565 | CraftCryptoAPI, |
| 566 | CraftCapabilities, | |
| 567 | CapabilityNamespace, | |
| 568 | CapabilityNamespaceStatus, | |
| 569 | CapabilityAction, | |
| 566 | 570 | CraftPreferencesAPI, |
| 567 | 571 | CraftPreferencesInfo, |
| 568 | 572 | CraftSettingsAPI, |
| @@ -1187,8 +1187,76 @@ export interface CraftBridgeAPI { | ||
| 1187 | 1187 | * The Cmd+, convention: where the App menu's Settings… item arrives. |
| 1188 | 1188 | */ |
| 1189 | 1189 | 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 | |
| 1190 | 1210 | } |
| 1191 | 1211 | |
| 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 | */ | |
| 1223 | export type CapabilityNamespaceStatus = 'declared' | 'undeclared' | 'unavailable' | 'unrouted' | |
| 1224 | ||
| 1225 | export 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 | ||
| 1233 | export 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 | */ | |
| 1247 | export 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 | ||
| 1192 | 1260 | /** |
| 1193 | 1261 | * The only value types `craft.prefs` stores. |
| 1194 | 1262 | * |
| @@ -630,6 +630,49 @@ pub fn build(b: *std.Build) void { | ||
| 630 | 630 | prefs_macos_tests.root_module.link_libc = true; |
| 631 | 631 | } |
| 632 | 632 | |
| 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 | ||
| 633 | 676 | const config_tests = b.addTest(.{ |
| 634 | 677 | .root_module = b.createModule(.{ |
| 635 | 678 | .root_source_file = b.path("test/config_test.zig"), |
| @@ -966,6 +1009,7 @@ pub fn build(b: *std.Build) void { | ||
| 966 | 1009 | const run_space_list_tests = b.addRunArtifact(space_list_tests); |
| 967 | 1010 | const run_local_tls_tests = b.addRunArtifact(local_tls_tests); |
| 968 | 1011 | const run_menu_roles_tests = b.addRunArtifact(menu_roles_tests); |
| 1012 | const run_capabilities_tests = b.addRunArtifact(capabilities_tests); | |
| 969 | 1013 | const run_prefs_tests = b.addRunArtifact(prefs_tests); |
| 970 | 1014 | const run_prefs_macos_tests = b.addRunArtifact(prefs_macos_tests); |
| 971 | 1015 | const run_key_codes_tests = b.addRunArtifact(key_codes_tests); |
| @@ -1078,6 +1122,11 @@ pub fn build(b: *std.Build) void { | ||
| 1078 | 1122 | test_step.dependOn(&run_space_list_tests.step); |
| 1079 | 1123 | test_step.dependOn(&run_local_tls_tests.step); |
| 1080 | 1124 | 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); | |
| 1081 | 1130 | test_step.dependOn(&run_prefs_tests.step); |
| 1082 | 1131 | test_step.dependOn(&run_prefs_macos_tests.step); |
| 1083 | 1132 | test_step.dependOn(&run_key_codes_tests.step); |
| @@ -1183,6 +1232,10 @@ pub fn build(b: *std.Build) void { | ||
| 1183 | 1232 | const test_menu_roles_step = b.step("test:menu-roles", "Run menu role table tests"); |
| 1184 | 1233 | test_menu_roles_step.dependOn(&run_menu_roles_tests.step); |
| 1185 | 1234 | |
| 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 | ||
| 1186 | 1239 | const test_prefs_step = b.step("test:prefs", "Run preference store tests"); |
| 1187 | 1240 | test_prefs_step.dependOn(&run_prefs_tests.step); |
| 1188 | 1241 | 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 | ||
| 1699 | 1752 | // when the app dynamically loads them. |
| 1700 | 1753 | module.linkFramework("CoreMIDI", .{}); |
| 1701 | 1754 | 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", .{}); | |
| 1702 | 1760 | // Carbon for `macos_hotkey.zig`: `RegisterEventHotKey` is the only |
| 1703 | 1761 | // route to a global hotkey that neither needs an Objective-C block |
| 1704 | 1762 | // (which Zig cannot write) nor Accessibility permission (which a |
| @@ -1,6 +1,35 @@ | ||
| 1 | 1 | const std = ; |
| 2 | 2 | const builtin = ; |
| 3 | 3 | const bridge_error = ; |
| 4 | const 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. | |
| 13 | pub 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. | |
| 24 | pub 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 | }; | |
| 4 | 33 | |
| 5 | 34 | /// Bridge handler for app-level control messages from JavaScript |
| 6 | 35 | pub const AppBridge = struct { |
| @@ -22,19 +51,19 @@ pub const AppBridge = struct { | ||
| 22 | 51 | } |
| 23 | 52 | |
| 24 | 53 | 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)) { | |
| 26 | 55 | try self.hideDockIcon(); |
| 27 | } else if (std.mem.eql(u8, action, "showDockIcon")) { | |
| 56 | } else if (std.mem.eql(u8, action, A.show_dock_icon)) { | |
| 28 | 57 | try self.showDockIcon(); |
| 29 | } else if (std.mem.eql(u8, action, "quit")) { | |
| 58 | } else if (std.mem.eql(u8, action, A.quit)) { | |
| 30 | 59 | try self.quit(); |
| 31 | } else if (std.mem.eql(u8, action, "getInfo")) { | |
| 60 | } else if (std.mem.eql(u8, action, A.get_info)) { | |
| 32 | 61 | try self.getInfo(); |
| 33 | } else if (std.mem.eql(u8, action, "notify")) { | |
| 62 | } else if (std.mem.eql(u8, action, A.notify)) { | |
| 34 | 63 | try self.notify(data); |
| 35 | } else if (std.mem.eql(u8, action, "setBadge")) { | |
| 64 | } else if (std.mem.eql(u8, action, A.set_badge)) { | |
| 36 | 65 | try self.setBadge(data); |
| 37 | } else if (std.mem.eql(u8, action, "bounce")) { | |
| 66 | } else if (std.mem.eql(u8, action, A.bounce)) { | |
| 38 | 67 | try self.bounce(); |
| 39 | 68 | } else { |
| 40 | 69 | if (comptime builtin.mode == .debug) |
| @@ -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 | ||
| 9 | const std = ; | |
| 10 | const bridge_error = ; | |
| 11 | const registry = ; | |
| 12 | ||
| 13 | const 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. | |
| 18 | pub const A = ; | |
| 19 | ||
| 20 | /// What craft serves on the `capabilities` namespace. | |
| 21 | pub const capability_actions = [_].ActionDecl{ | |
| 22 | .{ .name = A.get, .reply = .result }, | |
| 23 | }; | |
| 24 | ||
| 25 | pub 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. | |
| 31 | pub const action_unknown = A.unknown; | |
| 32 | ||
| 33 | pub 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 | ||
| 63 | const testing = std.testing; | |
| 64 | ||
| 65 | test "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 | } | |
| @@ -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. | |
| 11 | pub 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. | |
| 16 | pub const unknown = "capabilities:unknownAction"; | |
| @@ -8,6 +8,37 @@ const log = logging.clipboard; | ||
| 8 | 8 | |
| 9 | 9 | // Import GTK clipboard API from linux.zig (works on both X11 and Wayland) |
| 10 | 10 | const linux = if (builtin.os.tag == .linux) else undefined; |
| 11 | const 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. | |
| 20 | pub 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. | |
| 32 | pub 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 | }; | |
| 11 | 42 | |
| 12 | 43 | // Win32 symbols used by the Windows clipboard implementation. Declared at |
| 13 | 44 | // file scope as `extern` so the file parses on every platform — Zig 0.17 |
| @@ -48,21 +79,21 @@ pub const ClipboardBridge = struct { | ||
| 48 | 79 | } |
| 49 | 80 | |
| 50 | 81 | 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)) { | |
| 52 | 83 | try self.writeText(data); |
| 53 | } else if (std.mem.eql(u8, action, "readText")) { | |
| 84 | } else if (std.mem.eql(u8, action, A.read_text)) { | |
| 54 | 85 | 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)) { | |
| 56 | 87 | 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)) { | |
| 58 | 89 | try self.readHTML(); |
| 59 | } else if (std.mem.eql(u8, action, "clear")) { | |
| 90 | } else if (std.mem.eql(u8, action, A.clear)) { | |
| 60 | 91 | try self.clear(); |
| 61 | } else if (std.mem.eql(u8, action, "hasText")) { | |
| 92 | } else if (std.mem.eql(u8, action, A.has_text)) { | |
| 62 | 93 | 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)) { | |
| 64 | 95 | try self.hasHTML(); |
| 65 | } else if (std.mem.eql(u8, action, "hasImage")) { | |
| 96 | } else if (std.mem.eql(u8, action, A.has_image)) { | |
| 66 | 97 | try self.hasImage(); |
| 67 | 98 | } else { |
| 68 | 99 | return BridgeError.UnknownAction; |
| @@ -4,8 +4,8 @@ | ||
| 4 | 4 | //! decodes what it posted with the very code the bridge dispatches to — which |
| 5 | 5 | //! is the only kind of test that can catch a JS side and a native side that |
| 6 | 6 | //! 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. | |
| 9 | 9 | //! |
| 10 | 10 | //! They arrive through one aggregate rather than as separate named modules |
| 11 | 11 | //! because several of them reach `bridge_error.zig`, and a file may belong to |
| @@ -17,3 +17,14 @@ pub const menu = ; | ||
| 17 | 17 | pub const shortcuts = ; |
| 18 | 18 | pub const prefs = ; |
| 19 | 19 | pub const prefs_actions = ; |
| 20 | /// Pure: the manifest types and renderer, with no bridge behind them. | |
| 21 | pub 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. | |
| 30 | pub const capabilities_actions = ; | |
| @@ -544,6 +544,15 @@ pub const FSBridge = struct { | ||
| 544 | 544 | } |
| 545 | 545 | } |
| 546 | 546 | |
| 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 | ||
| 547 | 556 | /// Get home directory |
| 548 | 557 | fn getHomeDir(self: *Self) !void { |
| 549 | 558 | _ = self; |
| @@ -580,9 +589,15 @@ pub const FSBridge = struct { | ||
| 580 | 589 | } |
| 581 | 590 | } |
| 582 | 591 | } 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 ""); | |
| 586 | 601 | |
| 587 | 602 | if (home.len > 0) { |
| 588 | 603 | // Same escape as above — env vars can contain any bytes. |
renamed throughout · 10 hidden lines
| @@ -635,8 +650,8 @@ pub const FSBridge = struct { renamed throughout | ||
| 635 | 650 | } |
| 636 | 651 | } else { |
| 637 | 652 | // 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"; | |
| 640 | 655 | |
| 641 | 656 | // Windows default uses a backslash which Zig reads as a JS escape |
| 642 | 657 | // in the string literal — escape before embedding. |
renamed throughout · 14 hidden lines
| @@ -695,12 +710,12 @@ pub const FSBridge = struct { renamed throughout | ||
| 695 | 710 | } |
| 696 | 711 | } else if (comptime builtin.os.tag == .linux) { |
| 697 | 712 | // 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 ""; | |
| 699 | 714 | var path_buf: [512]u8 = undefined; |
| 700 | 715 | const app_data_path = if (app_data.len > 0) |
| 701 | 716 | app_data |
| 702 | 717 | else blk: { |
| 703 | const home = std.posix.getenv("HOME") orelse ""; | |
| 718 | const home = envOrEmpty("HOME") orelse ""; | |
| 704 | 719 | if (home.len == 0) break :blk ""; |
| 705 | 720 | break :blk std.fmt.bufPrint(&path_buf, "{s}/.local/share", .{home}) catch ""; |
| 706 | 721 | }; |
| @@ -721,7 +736,7 @@ pub const FSBridge = struct { | ||
| 721 | 736 | } else if (comptime builtin.os.tag == .windows) { |
| 722 | 737 | // Windows: %APPDATA% contains backslashes which are JS escapes — |
| 723 | 738 | // 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 ""; | |
| 725 | 740 | |
| 726 | 741 | if (app_data.len > 0) { |
| 727 | 742 | var esc_buf: [1024]u8 = undefined; |
| @@ -1,6 +1,25 @@ | ||
| 1 | 1 | const std = ; |
| 2 | 2 | const builtin = ; |
| 3 | 3 | const bridge_error = ; |
| 4 | const 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. | |
| 13 | pub 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. | |
| 19 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 20 | .{ .name = A.get_displays, .reply = .result }, | |
| 21 | .{ .name = A.get_primary, .reply = .result }, | |
| 22 | }; | |
| 4 | 23 | |
| 5 | 24 | const BridgeError = bridge_error.BridgeError; |
| 6 | 25 | |
| @@ -25,9 +44,9 @@ pub const ScreenBridge = struct { | ||
| 25 | 44 | } |
| 26 | 45 | |
| 27 | 46 | 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)) { | |
| 29 | 48 | try self.getDisplays(); |
| 30 | } else if (std.mem.eql(u8, action, "getPrimary")) { | |
| 49 | } else if (std.mem.eql(u8, action, A.get_primary)) { | |
| 31 | 50 | try self.getPrimary(); |
| 32 | 51 | } else { |
| 33 | 52 | return BridgeError.UnknownAction; |
| @@ -98,6 +117,16 @@ pub const ScreenBridge = struct { | ||
| 98 | 117 | var screen_observer_installed = false; |
| 99 | 118 | var screen_observer_instance: .objc.id = null; |
| 100 | 119 | |
| 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. | |
| 128 | var screen_change_emitter: ?capabilities.Emitter = null; | |
| 129 | ||
| 101 | 130 | fn installScreenChangeObserver() void { |
| 102 | 131 | if (screen_observer_installed) return; |
| 103 | 132 | if (builtin.os.tag != .macos) return; |
| @@ -126,11 +155,15 @@ fn installScreenChangeObserver() void { | ||
| 126 | 155 | f(center, macos.sel("addObserver:selector:name:object:"), screen_observer_instance, macos.sel("onScreenChange:"), note_name, @as(objc.id, null)); |
| 127 | 156 | |
| 128 | 157 | screen_observer_installed = true; |
| 158 | screen_change_emitter = capabilities.registerEmitter(.screen_change); | |
| 129 | 159 | } |
| 130 | 160 | |
| 131 | 161 | export fn handleScreenChange(_: .objc.id, _: .objc.SEL, _: .objc.id) callconv(.c) void { |
| 132 | 162 | const macos = ; |
| 133 | 163 | 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; | |
| 134 | 167 | const script = "if (window.dispatchEvent) window.dispatchEvent(new CustomEvent('craft:screen:change'));"; |
| 135 | 168 | const NSString = macos.getClass("NSString"); |
| 136 | 169 | const js = macos.msgSend1(NSString, "stringWithUTF8String:", @as([*:0]const u8, script)); |
| @@ -4,6 +4,48 @@ const tray_menu = ; | ||
| 4 | 4 | const bridge_error = ; |
| 5 | 5 | const logging = ; |
| 6 | 6 | const icons = ; |
| 7 | const 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. | |
| 16 | pub 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. | |
| 28 | pub 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 | }; | |
| 7 | 49 | |
| 8 | 50 | const log = logging.tray; |
| 9 | 51 | |
| @@ -115,21 +157,21 @@ pub const TrayBridge = struct { | ||
| 115 | 157 | } |
| 116 | 158 | |
| 117 | 159 | 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)) { | |
| 119 | 161 | try self.setTitle(data); |
| 120 | } else if (std.mem.eql(u8, action, "setTooltip")) { | |
| 162 | } else if (std.mem.eql(u8, action, A.set_tooltip)) { | |
| 121 | 163 | try self.setTooltip(data); |
| 122 | } else if (std.mem.eql(u8, action, "setMenu")) { | |
| 164 | } else if (std.mem.eql(u8, action, A.set_menu)) { | |
| 123 | 165 | try self.setMenu(data); |
| 124 | } else if (std.mem.eql(u8, action, "pollActions")) { | |
| 166 | } else if (std.mem.eql(u8, action, A.poll_actions)) { | |
| 125 | 167 | try self.pollActions(); |
| 126 | } else if (std.mem.eql(u8, action, "hide")) { | |
| 168 | } else if (std.mem.eql(u8, action, A.hide)) { | |
| 127 | 169 | try self.hide(); |
| 128 | } else if (std.mem.eql(u8, action, "show")) { | |
| 170 | } else if (std.mem.eql(u8, action, A.show)) { | |
| 129 | 171 | try self.showTray(); |
| 130 | } else if (std.mem.eql(u8, action, "setIcon")) { | |
| 172 | } else if (std.mem.eql(u8, action, A.set_icon)) { | |
| 131 | 173 | try self.setIcon(data); |
| 132 | } else if (std.mem.eql(u8, action, "setBadge")) { | |
| 174 | } else if (std.mem.eql(u8, action, A.set_badge)) { | |
| 133 | 175 | try self.setBadge(data); |
| 134 | 176 | } else { |
| 135 | 177 | return BridgeError.UnknownAction; |
| @@ -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 | ||
| 34 | const 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. | |
| 42 | pub const Reply = enum { | |
| 43 | /// Fire-and-forget: native acknowledges nothing. | |
| 44 | none, | |
| 45 | /// Native calls `sendResultToJS` under this action name. | |
| 46 | result, | |
| 47 | }; | |
| 48 | ||
| 49 | pub const ActionStatus = enum { | |
| 50 | /// Dispatches, does the thing. | |
| 51 | live, | |
| 52 | /// Reachable, and known not to work. `reason` is required. | |
| 53 | unavailable, | |
| 54 | }; | |
| 55 | ||
| 56 | pub 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 | ||
| 64 | pub 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 | ||
| 76 | pub 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. | |
| 94 | pub 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 | ||
| 146 | const 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. | |
| 154 | var 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. | |
| 162 | pub 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. | |
| 171 | pub fn registerEmitter(channel: Channel) Emitter { | |
| 172 | live_channels[@backingInt(channel)] = true; | |
| 173 | return .{ .channel = channel }; | |
| 174 | } | |
| 175 | ||
| 176 | pub 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. | |
| 182 | pub fn resetEmittersForTesting() void { | |
| 183 | live_channels = std.mem.zeroes([channel_count]bool); | |
| 184 | } | |
| 185 | ||
| 186 | // ============================================================================= | |
| 187 | // The manifest | |
| 188 | // ============================================================================= | |
| 189 | ||
| 190 | fn 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 | ||
| 208 | fn 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. | |
| 222 | pub 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 | ||
| 272 | const testing = std.testing; | |
| 273 | ||
| 274 | test "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 | ||
| 287 | test "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 | ||
| 298 | test "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 | ||
| 304 | test "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, ®istry); | |
| 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 | ||
| 331 | test "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, ®istry); | |
| 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 | ||
| 359 | test "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, ®istry); | |
| 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 | ||
| 376 | test "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 | ||
| 395 | test "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, ®istry); | |
| 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 | ||
| 412 | test "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 | } | |
| @@ -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 | ||
| 20 | pub const capabilities = ; | |
| 21 | const bridge_app = ; | |
| 22 | const bridge_capabilities = ; | |
| 23 | const bridge_clipboard = ; | |
| 24 | const bridge_screen = ; | |
| 25 | const bridge_tray = ; | |
| 26 | ||
| 27 | pub 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. | |
| 97 | pub fn manifestJson(gpa: .mem.Allocator) ![]u8 { | |
| 98 | return capabilities.buildManifest(gpa, ®istry); | |
| 99 | } | |
| @@ -737,6 +737,54 @@ | ||
| 737 | 737 | info: function () { return _req('prefs', 'prefs:info', '{}', PREFS_TIMEOUT_MS) }, |
| 738 | 738 | } |
| 739 | 739 | |
| 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 | ||
| 740 | 788 | // ------------------------------------------------------------------------- |
| 741 | 789 | // theme — system appearance |
| 742 | 790 | // ------------------------------------------------------------------------- |
| @@ -24,6 +24,15 @@ | ||
| 24 | 24 | const std = ; |
| 25 | 25 | const builtin = ; |
| 26 | 26 | |
| 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. | |
| 31 | fn 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 | ||
| 27 | 36 | /// Supported locale identifiers |
| 28 | 37 | pub const Locale = struct { |
| 29 | 38 | language: []const u8, // ISO 639-1 (e.g., "en", "es", "zh") |
| @@ -412,7 +421,7 @@ pub const Localization = struct { | ||
| 412 | 421 | // and returned a Locale whose slices pointed into that dead buffer. |
| 413 | 422 | const env_vars = [_][]const u8{ "LC_ALL", "LC_MESSAGES", "LANG" }; |
| 414 | 423 | for (env_vars) |env_var| { |
| 415 | if (std.posix.getenv(env_var)) |value| { | |
| 424 | if (envOrEmpty(env_var)) |value| { | |
| 416 | 425 | // Strip `.UTF-8`, `.ISO-8859-1`, etc. — the codepage suffix |
| 417 | 426 | // isn't part of the locale identifier. |
| 418 | 427 | const dot_pos = std.mem.indexOf(u8, value, "."); |
| @@ -4101,6 +4101,7 @@ var global_serial_bridge: ?*.SerialBridge = null; | ||
| 4101 | 4101 | var global_focus_bridge: ?*.FocusBridge = null; |
| 4102 | 4102 | var global_screen_sharing_bridge: ?*.ScreenSharingBridge = null; |
| 4103 | 4103 | var global_prefs_bridge: ?*.PrefsBridge = null; |
| 4104 | var global_capabilities_bridge: ?*.CapabilitiesBridge = null; | |
| 4104 | 4105 | |
| 4105 | 4106 | pub fn setGlobalTrayHandle(handle: *anyopaque) void { |
| 4106 | 4107 | global_tray_handle_for_bridge = handle; |
| @@ -4394,6 +4395,11 @@ pub fn setupBridgeHandlers(allocator: std.mem.Allocator, tray_handle: ?*anyopaqu | ||
| 4394 | 4395 | global_prefs_bridge = try allocator.create(T); |
| 4395 | 4396 | global_prefs_bridge.?.* = T.init(allocator); |
| 4396 | 4397 | } |
| 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 | } | |
| 4397 | 4403 | |
| 4398 | 4404 | // theme + dragOut + deepLink: native modules with their own state, no |
| 4399 | 4405 | // shared bridge struct — they install handlers directly via init(). |
| @@ -4759,6 +4765,8 @@ pub fn handleBridgeMessageJSON(json_str: []const u8) !void { | ||
| 4759 | 4765 | if (global_screen_sharing_bridge) |bridge| try bridge.handleMessage(action, data_json_str); |
| 4760 | 4766 | } else if (std.mem.eql(u8, msg_type, "prefs")) { |
| 4761 | 4767 | 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); | |
| 4762 | 4770 | } else if (std.mem.eql(u8, msg_type, "debug")) { |
| 4763 | 4771 | // Handle debug messages |
| 4764 | 4772 | if (comptime builtin.mode == .debug) { |
| @@ -2,6 +2,15 @@ const std = ; | ||
| 2 | 2 | const io_context = ; |
| 3 | 3 | const builtin = ; |
| 4 | 4 | |
| 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. | |
| 9 | fn 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 | ||
| 5 | 14 | /// System-level enhancements for Craft framework |
| 6 | 15 | /// Includes: keyboard shortcuts, system events, dock features, etc. |
| 7 | 16 | |
| @@ -612,7 +621,7 @@ pub const LocalStorage = struct { | ||
| 612 | 621 | |
| 613 | 622 | pub fn init(allocator: std.mem.Allocator, app_name: []const u8) !LocalStorage { |
| 614 | 623 | // 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; | |
| 616 | 625 | |
| 617 | 626 | const path = try std.fmt.allocPrint( |
| 618 | 627 | allocator, |
| @@ -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 | ||
| 13 | const std = ; | |
| 14 | const testing = std.testing; | |
| 15 | ||
| 16 | const registry_mod = ; | |
| 17 | const capabilities = registry_mod.capabilities; | |
| 18 | const 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. | |
| 26 | const 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 | ||
| 50 | const 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. | |
| 57 | const max_undeclared: usize = 45; | |
| 58 | ||
| 59 | test "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 | ||
| 80 | test "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 | ||
| 98 | test "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. | |
| 124 | fn 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 | ||
| 147 | test "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 | ||
| 186 | test "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 | ||
| 219 | test "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 | ||
| 274 | test "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 | ||
| 294 | test "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 | ||
| 317 | test "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 | ||
| 336 | test "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 | ||
| 355 | test "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 | } | |
| @@ -18,6 +18,8 @@ const js = ; | ||
| 18 | 18 | const testing = std.testing; |
| 19 | 19 | const contracts = ; |
| 20 | 20 | const bridge_menu = contracts.menu; |
| 21 | const capabilities = contracts.capabilities; | |
| 22 | const capability_actions = contracts.capabilities_actions; | |
| 21 | 23 | const prefs = contracts.prefs; |
| 22 | 24 | const prefs_actions = contracts.prefs_actions; |
| 23 | 25 | const shortcut_registry = contracts.shortcuts; |
| @@ -815,3 +817,118 @@ test "unsubscribing from settings stops delivery, and does not double-count" { | ||
| 815 | 817 | // an unsubscribe called twice must not push it negative and silence it. |
| 816 | 818 | try testing.expectEqualStrings("0", try fx.text("String(window.__craftSettingsListeners)")); |
| 817 | 819 | } |
| 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. | |
| 829 | fn 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, ®istry); | |
| 843 | } | |
| 844 | ||
| 845 | test "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 | ||
| 859 | test "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 | ||
| 892 | test "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 | } | |