ReviewOS

also looking at this

craft-native/craft

fix(shortcuts): make global hotkeys actually fire

#54
Merged glennmichael123 wants to merge fix/global-shortcuts-never-fire into main
11 files +2435 -417
docs/BRIDGE_API.mdmodified+82-0
Changes to docs/BRIDGE_API.md
@@ -8,6 +8,7 @@ The Craft JavaScript Bridge provides a seamless interface for your web applicati
88- [Getting Started](#getting-started)
99- [System Tray API](#system-tray-api)
1010- [Application Menu (macOS)](#application-menu-macos)
11- [Global Shortcuts (macOS)](#global-shortcuts-macos)
1112- [Window API](#window-api)
1213- [App API](#app-api)
1314- [TypeScript Support](#typescript-support)
@@ -286,6 +287,86 @@ a newer bridge surface therefore fails soft on an older binary.
286287| `checkItem(itemId)` / `uncheckItem(itemId)` | checkmark |
287288| `setItemLabel(itemId, label)` | rename in place |
288289
290## Global Shortcuts (macOS)
291
292System-wide hotkeys: they fire whether or not your app is frontmost. macOS only
293— on Linux and Windows every registration is refused, because craft has no
294implementation there and a shortcut that can never fire is worse than one that
295was never accepted.
296
297Craft registers these through Carbon's `RegisterEventHotKey`, which needs no
298Accessibility or Input Monitoring permission and only ever calls back for the
299exact combinations you asked for. Your app never sees any other keystroke.
300
301### `window.craft.shortcuts.register(id, accelerator): Promise<void>`
302
303Reserve a combination. `id` is yours to choose and is what comes back when the
304key is pressed. Registering an id twice replaces the first binding.
305
306```javascript
307await window.craft.shortcuts.register('summon', 'Cmd+Shift+H');
308
309window.craft.shortcuts.on(({ id, accelerator }) => {
310 if (id === 'summon') window.craft.window.toggle();
311});
312```
313
314Accelerators are `+`-separated and case-insensitive. The last component is the
315key; everything before it is a modifier:
316
317| Modifier | Accepted as |
318|---|---|
319| Command | `Cmd`, `Command`, `Meta`, `Super`, `⌘` |
320| Control | `Ctrl`, `Control`, `⌃` |
321| Option | `Alt`, `Option`, `Opt`, `⌥` |
322| Shift | `Shift`, `⇧` |
323| Either | `CmdOrCtrl`, `CommandOrControl`, `Mod` — command on macOS, control elsewhere |
324
325Keys are named by position on the keyboard, not by the character they produce,
326so a shortcut survives the user switching layout. Letters, digits, punctuation,
327`Space`, `Tab`, `Return`, `Escape`, `Delete`, the arrows, `Home`/`End`/`PageUp`/
328`PageDown`, `F1`–`F20` and the keypad (`Keypad0`–`Keypad9`, `KeypadEnter`, …)
329are all bindable. Web and Electron spellings — `Backspace`, `Esc`, `Enter`,
330`ArrowLeft`, `Plus` — resolve to the same keys.
331
332**At least one of Command, Control or Option is required**, except on the
333function keys. A bare global hotkey on `H` would mean no application on the
334system — including yours — ever saw the user type an h again; failing the
335registration is recoverable, and that is not. Shift alone does not count, since
336Shift+H is how you type a capital H.
337
338### `window.craft.shortcuts.onError(handler): () => void`
339
340Where a failed call arrives: `{ id, code, message }`. Every verb here except
341`isRegistered` and `list` is fire-and-forget — the promise resolves as soon as
342the message is posted, not when the key is reserved — so this is how you learn
343that a combination belongs to another app or to the system, or that `enable()`
344could not take a released key back.
345
346```javascript
347window.craft.shortcuts.onError(({ id, message }) => {
348 showToast(`Could not bind ${id}: ${message}`);
349});
350```
351
352### `window.craft.shortcuts.on(handler): () => void`
353
354Fires on every press of a registered, enabled shortcut, with `{ id, accelerator }`.
355The accelerator is craft's canonical spelling — `Cmd+Delete`, whatever you
356originally wrote — so it round-trips back through `register()`. Returns an
357unsubscribe function.
358
359### The rest
360
361| Call | What it does |
362|---|---|
363| `unregister(id)` | give the key back to the system and forget the shortcut |
364| `unregisterAll()` | the same, for every shortcut this app holds |
365| `disable(id)` | stop firing **and release the key**, so other apps can use it again; the shortcut stays listed |
366| `enable(id)` | take it back — this can fail if something else claimed it in the meantime, and reports on `onError` |
367| `isRegistered(id): Promise<boolean>` | whether the id is known, enabled or not |
368| `list(): Promise<Shortcut[]>` | `{ id, accelerator, key, enabled }` for each |
369
289370## Window API
290371
291372Control the application window from JavaScript.
@@ -519,6 +600,7 @@ async function onComplete() {
519600| Tray Menu | �� | �� | �� |
520601| Window Control | ✅ | ✅ | ✅ |
521602| Hide Dock Icon | ✅ | ➖ | ➖ |
603| Global Shortcuts | ✅ | �� | �� |
522604
523605✅ Implemented | �� In Progress | ➖ Not Applicable
524606
packages/typescript/src/types.tsmodified+86-0
Changes to packages/typescript/src/types.ts
@@ -1119,6 +1119,92 @@ export interface CraftBridgeAPI {
11191119 * Screen-sharing and screen-recording detection (macOS)
11201120 */
11211121 screenSharing?: CraftScreenSharingAPI
1122
1123 /**
1124 * System-wide hotkeys (macOS).
1125 *
1126 * Absent in effect on Linux and Windows: the calls exist, and every
1127 * registration is refused, because Craft has no implementation there and a
1128 * shortcut that can never fire is worse than one that was never accepted.
1129 */
1130 shortcuts?: CraftGlobalShortcutsAPI
1131}
1132
1133/** A global hotkey, as `craft.shortcuts.list()` reports it. */
1134export interface GlobalShortcut {
1135 /** The id the app registered it under. */
1136 id: string
1137 /**
1138 * Craft's canonical spelling of the combination — `Cmd+Delete`, whatever
1139 * the app originally wrote — so it can be passed straight back to
1140 * `register()`.
1141 */
1142 accelerator: string
1143 /** The key alone, canonically named. */
1144 key: string
1145 /** False while `disable()` has the key released back to the system. */
1146 enabled: boolean
1147}
1148
1149/** Why a registration was refused. */
1150export interface GlobalShortcutError {
1151 /** The id from the payload that failed, or `''` if it had none. */
1152 id: string
1153 /** Craft's error code, e.g. `NATIVE_CALL_FAILED`, `INVALID_PARAMETER`. */
1154 code: string
1155 message: string
1156}
1157
1158/**
1159 * System-wide hotkeys: they fire whether or not the app is frontmost.
1160 *
1161 * Accelerators are `+`-separated and case-insensitive — `'Cmd+Shift+H'`. The
1162 * last component is the key, everything before it a modifier (`Cmd`/`Command`/
1163 * `Meta`, `Ctrl`/`Control`, `Alt`/`Option`, `Shift`, or `CmdOrCtrl` for the
1164 * platform's own). Keys are named by position on the keyboard, not by the
1165 * character they produce, so a binding survives a layout change.
1166 *
1167 * At least one of Command, Control or Option is required, except on the
1168 * function keys: a bare global hotkey on `H` would mean no application on the
1169 * system ever saw the user type an h again.
1170 */
1171export interface CraftGlobalShortcutsAPI {
1172 /**
1173 * Reserve a combination system-wide. Registering an `id` twice replaces the
1174 * first binding.
1175 *
1176 * The promise resolves once the message is posted, **not** once the key is
1177 * reserved — so a combination that belongs to another app or to the system
1178 * resolves here and reports on {@link CraftGlobalShortcutsAPI.onError}.
1179 */
1180 register: (id: string, accelerator: string) => Promise<void>
1181 /** Give the key back to the system and forget the shortcut. */
1182 unregister: (id: string) => Promise<void>
1183 /** The same, for every shortcut this app holds. */
1184 unregisterAll: () => Promise<void>
1185 /**
1186 * Stop firing *and release the key*, so other apps can use it again. The
1187 * shortcut stays listed. Holding a reservation while dropping the event
1188 * would make the combination dead in every other app for as long as Craft
1189 * ran.
1190 */
1191 disable: (id: string) => Promise<void>
1192 /**
1193 * Take the key back. Can fail if something else claimed it while it was
1194 * released, which reports on {@link CraftGlobalShortcutsAPI.onError}.
1195 */
1196 enable: (id: string) => Promise<void>
1197 /** Whether the id is known — enabled or not. */
1198 isRegistered: (id: string) => Promise<boolean>
1199 list: () => Promise<GlobalShortcut[]>
1200 /** Every press of a registered, enabled shortcut. Returns an unsubscribe. */
1201 on: (handler: (event: { id: string, accelerator: string }) => void) => () => void
1202 /**
1203 * Where every fire-and-forget call reports its failure — `register`,
1204 * `enable`, `disable`, `unregister`. (`isRegistered` and `list` are
1205 * requests and reject their own promise instead.) Returns an unsubscribe.
1206 */
1207 onError: (handler: (error: GlobalShortcutError) => void) => () => void
11221208}
11231209
11241210/**
packages/zig/build.zigmodified+82-10
Changes to packages/zig/build.zig
@@ -165,10 +165,6 @@ pub fn build(b: *std.Build) void {
165165 .root_source_file = b.path("src/menubar.zig"),
166166 });
167167
168 const bridge_menu_module = b.createModule(.{
169 .root_source_file = b.path("src/bridge_menu.zig"),
170 });
171
172168 const components_module = b.createModule(.{
173169 .root_source_file = b.path("src/components.zig"),
174170 });
@@ -561,6 +557,54 @@ pub fn build(b: *std.Build) void {
561557 }),
562558 });
563559
560 // Accelerator strings ("Cmd+Shift+H") and the Carbon hotkey binding they
561 // are registered through. Both are pure enough to test without a window.
562 const accelerator_tests = b.addTest(.{
563 .root_module = b.createModule(.{
564 .root_source_file = b.path("src/accelerator.zig"),
565 .target = target,
566 .optimize = optimize,
567 }),
568 });
569
570 const macos_hotkey_tests = b.addTest(.{
571 .root_module = b.createModule(.{
572 .root_source_file = b.path("src/macos_hotkey.zig"),
573 .target = target,
574 .optimize = optimize,
575 }),
576 });
577 if (target_os == .macos) {
578 // These tests put a real `EventRef` through Carbon's own dispatcher —
579 // the only way to find out whether the handler signature and the
580 // `EventHotKeyID` layout are what Carbon expects, short of pressing
581 // the key on a Mac.
582 macos_hotkey_tests.root_module.linkFramework("Carbon", .{});
583 macos_hotkey_tests.root_module.link_libc = true;
584 }
585
586 // The global-shortcut registry: register / replace / disable / unregister,
587 // driven against a fake platform so the whole lifecycle is covered without
588 // an app, a window or a key press.
589 const shortcut_registry_tests = b.addTest(.{
590 .root_module = b.createModule(.{
591 .root_source_file = b.path("src/shortcut_registry.zig"),
592 .target = target,
593 .optimize = optimize,
594 }),
595 });
596
597 // The name <-> macOS virtual key code table that global shortcuts are
598 // registered from. Pure data, so it is tested here rather than by pressing
599 // keys at a running app.
600 const key_codes_tests = b.addTest(.{
601 .root_module = b.createModule(.{
602 .root_source_file = b.path("src/key_codes.zig"),
603 .target = target,
604 .optimize = optimize,
605 }),
606 });
607
564608 const config_tests = b.addTest(.{
565609 .root_module = b.createModule(.{
566610 .root_source_file = b.path("test/config_test.zig"),
@@ -897,6 +941,10 @@ pub fn build(b: *std.Build) void {
897941 const run_space_list_tests = b.addRunArtifact(space_list_tests);
898942 const run_local_tls_tests = b.addRunArtifact(local_tls_tests);
899943 const run_menu_roles_tests = b.addRunArtifact(menu_roles_tests);
944 const run_key_codes_tests = b.addRunArtifact(key_codes_tests);
945 const run_accelerator_tests = b.addRunArtifact(accelerator_tests);
946 const run_macos_hotkey_tests = b.addRunArtifact(macos_hotkey_tests);
947 const run_shortcut_registry_tests = b.addRunArtifact(shortcut_registry_tests);
900948 const run_config_tests = b.addRunArtifact(config_tests);
901949 const run_ipc_tests = b.addRunArtifact(ipc_tests);
902950 const run_performance_tests = b.addRunArtifact(performance_tests);
@@ -945,12 +993,17 @@ pub fn build(b: *std.Build) void {
945993 .optimize = optimize,
946994 .imports = &.{
947995 .{ .name = "js", .module = js_dep.module("js") },
948 // The native half of the menu contract. The test drives
949 // the real craft-bridge.js and then decodes what it
950 // posted with the very parser `setAppMenu` uses, so a
951 // rename on either side fails the build rather than
952 // silently producing a menu nobody can click (#27, #31).
953 .{ .name = "../src/bridge_menu.zig", .module = bridge_menu_module },
996 // The native halves of the contracts under test. The
997 // test drives the real craft-bridge.js and then decodes
998 // what it posted with the very code the bridge
999 // dispatches to, so a rename on either side fails the
1000 // build rather than silently producing a menu nobody
1001 // can click (#27, #31) or a hotkey that never fires
1002 // (#47). One module, because both reach
1003 // `bridge_error.zig` — see the file's own comment.
1004 .{ .name = "bridge_contracts", .module = b.createModule(.{
1005 .root_source_file = b.path("src/bridge_contracts.zig"),
1006 }) },
9541007 },
9551008 }),
9561009 });
@@ -998,6 +1051,10 @@ pub fn build(b: *std.Build) void {
9981051 test_step.dependOn(&run_space_list_tests.step);
9991052 test_step.dependOn(&run_local_tls_tests.step);
10001053 test_step.dependOn(&run_menu_roles_tests.step);
1054 test_step.dependOn(&run_key_codes_tests.step);
1055 test_step.dependOn(&run_accelerator_tests.step);
1056 test_step.dependOn(&run_macos_hotkey_tests.step);
1057 test_step.dependOn(&run_shortcut_registry_tests.step);
10011058 test_step.dependOn(&run_config_tests.step);
10021059 test_step.dependOn(&run_ipc_tests.step);
10031060 test_step.dependOn(&run_performance_tests.step);
@@ -1097,6 +1154,16 @@ pub fn build(b: *std.Build) void {
10971154 const test_menu_roles_step = b.step("test:menu-roles", "Run menu role table tests");
10981155 test_menu_roles_step.dependOn(&run_menu_roles_tests.step);
10991156
1157 const test_key_codes_step = b.step("test:key-codes", "Run key code table tests");
1158 test_key_codes_step.dependOn(&run_key_codes_tests.step);
1159
1160 const test_accelerator_step = b.step("test:accelerator", "Run accelerator parsing tests");
1161 test_accelerator_step.dependOn(&run_accelerator_tests.step);
1162 test_accelerator_step.dependOn(&run_macos_hotkey_tests.step);
1163
1164 const test_shortcut_registry_step = b.step("test:shortcut-registry", "Run global shortcut registry tests");
1165 test_shortcut_registry_step.dependOn(&run_shortcut_registry_tests.step);
1166
11001167 const test_config_step = b.step("test:config", "Run Config tests");
11011168 test_config_step.dependOn(&run_config_tests.step);
11021169
@@ -1599,6 +1666,11 @@ fn linkPlatformLibraries(b: *std.Build, module: *std.Build.Module, target_os: st
15991666 // when the app dynamically loads them.
16001667 module.linkFramework("CoreMIDI", .{});
16011668 module.linkFramework("CoreSpotlight", .{});
1669 // Carbon for `macos_hotkey.zig`: `RegisterEventHotKey` is the only
1670 // route to a global hotkey that neither needs an Objective-C block
1671 // (which Zig cannot write) nor Accessibility permission (which a
1672 // CGEventTap would).
1673 module.linkFramework("Carbon", .{});
16021674 applySdkPaths(b, module, sdk_path);
16031675 },
16041676 .linux => {
packages/zig/src/accelerator.zigadded+336-0
Changes to packages/zig/src/accelerator.zig
@@ -0,0 +1,336 @@
1//! Accelerator strings — `"Cmd+Shift+H"` — parsed into a key and modifiers.
2//!
3//! This is the shape `window.craft.shortcuts.register(id, accelerator)` sends,
4//! and it is deliberately the same spelling Electron and the web use, so an
5//! accelerator copied out of existing app code means here what it meant there.
6//!
7//! Pure: no Objective-C, no Carbon, nothing platform-specific. That is what
8//! lets the whole of it be tested without a window, a key press or a running
9//! app — which matters, because the bug this file exists to fix
10//! (craft-native/craft#47) was precisely that nothing between the JS call and
11//! the keyboard was exercised by anything.
12
13const std = ;
14const builtin = ;
15const key_codes = ;
16
17pub const Modifiers = struct {
18 cmd: bool = false,
19 ctrl: bool = false,
20 alt: bool = false,
21 shift: bool = false,
22
23 /// `NSEventModifierFlag` bits, for matching against a Cocoa event.
24 pub fn toCocoaFlags(self: Modifiers) c_ulong {
25 var flags: c_ulong = 0;
26 if (self.shift) flags |= (1 << 17); // NSEventModifierFlagShift
27 if (self.ctrl) flags |= (1 << 18); // NSEventModifierFlagControl
28 if (self.alt) flags |= (1 << 19); // NSEventModifierFlagOption
29 if (self.cmd) flags |= (1 << 20); // NSEventModifierFlagCommand
30 return flags;
31 }
32
33 /// Carbon modifier bits, for `RegisterEventHotKey`. A different set of
34 /// values entirely from the Cocoa ones above: Cocoa's command bit is
35 /// 1<<20, Carbon's is 1<<8, and passing one where the other is expected
36 /// registers a hotkey nobody can press.
37 ///
38 /// From `<Carbon/HIToolbox/Events.h>`.
39 pub const carbon_command: u32 = 1 << 8;
40 pub const carbon_shift: u32 = 1 << 9;
41 pub const carbon_option: u32 = 1 << 11;
42 pub const carbon_control: u32 = 1 << 12;
43
44 pub fn toCarbonFlags(self: Modifiers) u32 {
45 var flags: u32 = 0;
46 if (self.cmd) flags |= carbon_command;
47 if (self.shift) flags |= carbon_shift;
48 if (self.alt) flags |= carbon_option;
49 if (self.ctrl) flags |= carbon_control;
50 return flags;
51 }
52
53 /// Whether any modifier that changes what a key *does*, rather than which
54 /// character it produces, is held. Shift is excluded on purpose: Shift+H
55 /// is just how you type H.
56 pub fn hasCommandingModifier(self: Modifiers) bool {
57 return self.cmd or self.ctrl or self.alt;
58 }
59
60 pub fn eql(self: Modifiers, other: Modifiers) bool {
61 return self.cmd == other.cmd and self.ctrl == other.ctrl and
62 self.alt == other.alt and self.shift == other.shift;
63 }
64};
65
66pub const Binding = struct {
67 /// Canonical name of the key, from `key_codes.zig` — so a shortcut
68 /// registered as `"Cmd+Backspace"` reports itself as `Cmd+Delete`.
69 key: []const u8,
70 /// macOS virtual key code.
71 keycode: u16,
72 modifiers: Modifiers,
73
74 pub fn eql(self: Binding, other: Binding) bool {
75 return self.keycode == other.keycode and self.modifiers.eql(other.modifiers);
76 }
77};
78
79pub const ParseError = error{
80 /// The accelerator was empty, or was nothing but modifiers.
81 MissingKey,
82 /// A component was empty — `"Cmd++"`, `"+H"`, `"Cmd+ +H"`.
83 EmptyComponent,
84 /// The final component names no key craft knows.
85 UnknownKey,
86 /// A component before the key is neither a modifier nor a key.
87 UnknownModifier,
88 /// The same modifier was named twice.
89 DuplicateModifier,
90 /// A global hotkey with no commanding modifier would make the key
91 /// untypeable in every app on the system.
92 ModifierRequired,
93};
94
95/// Function keys are the exception to `ModifierRequired`: they produce no
96/// text, so reserving F13 on its own costs the user nothing. Everything else
97/// bare — `"H"`, `"Space"`, `"Shift+H"` — would take a key away system-wide.
98fn isBareBindable(name: []const u8) bool {
99 if (name.len < 2 or (name[0] != 'F' and name[0] != 'f')) return false;
100 _ = std.fmt.parseInt(u8, name[1..], 10) catch return false;
101 return true;
102}
103
104fn applyModifier(name: []const u8, mods: *Modifiers) ParseError!void {
105 const Target = enum { cmd, ctrl, alt, shift };
106 const target: Target = blk: {
107 // `CmdOrCtrl` is the portable spelling: command where command is what
108 // apps use, control everywhere else. Resolved here rather than being
109 // carried through, so everything downstream sees a concrete modifier.
110 if (eq(name, "cmdorctrl") or eq(name, "commandorcontrol") or eq(name, "mod"))
111 break :blk if (builtin.os.tag == .macos) .cmd else .ctrl;
112 if (eq(name, "cmd") or eq(name, "command") or eq(name, "meta") or
113 eq(name, "super") or eq(name, "win") or eq(name, "\u{2318}")) break :blk .cmd;
114 if (eq(name, "ctrl") or eq(name, "control") or eq(name, "\u{2303}")) break :blk .ctrl;
115 if (eq(name, "alt") or eq(name, "option") or eq(name, "opt") or
116 eq(name, "\u{2325}")) break :blk .alt;
117 if (eq(name, "shift") or eq(name, "\u{21E7}")) break :blk .shift;
118 return ParseError.UnknownModifier;
119 };
120
121 const slot = switch (target) {
122 .cmd => &mods.cmd,
123 .ctrl => &mods.ctrl,
124 .alt => &mods.alt,
125 .shift => &mods.shift,
126 };
127 // Naming a modifier twice is a typo, and silently accepting it hides which
128 // half of `"Cmd+Cmd+Shift"` the author meant to write.
129 if (slot.*) return ParseError.DuplicateModifier;
130 slot.* = true;
131}
132
133fn eq(a: []const u8, comptime b: []const u8) bool {
134 return std.ascii.eqlIgnoreCase(a, b);
135}
136
137/// Parse `"Cmd+Shift+H"` into the key and modifiers it names.
138///
139/// The last `+`-separated component is the key; everything before it is a
140/// modifier. Components are matched case-insensitively.
141pub fn parse(input: []const u8) ParseError!Binding {
142 const trimmed = std.mem.trim(u8, input, " \t");
143 if (trimmed.len == 0) return ParseError.MissingKey;
144
145 var mods = Modifiers{};
146 var key_part: ?[]const u8 = null;
147
148 var it = std.mem.splitScalar(u8, trimmed, '+');
149 while (it.next()) |raw| {
150 const part = std.mem.trim(u8, raw, " \t");
151 if (part.len == 0) return ParseError.EmptyComponent;
152
153 // The key is whatever is last, so a component only becomes a modifier
154 // once another component turns up behind it.
155 if (key_part) |previous| try applyModifier(previous, &mods);
156 key_part = part;
157 }
158
159 const key_name = key_part orelse return ParseError.MissingKey;
160 const keycode = key_codes.codeFor(key_name) orelse return ParseError.UnknownKey;
161 const canonical = key_codes.nameFor(keycode).?;
162
163 if (!mods.hasCommandingModifier() and !isBareBindable(canonical)) {
164 return ParseError.ModifierRequired;
165 }
166
167 return .{ .key = canonical, .keycode = keycode, .modifiers = mods };
168}
169
170/// Render a binding back into the canonical accelerator string, so what an
171/// app reads out of `craft.shortcuts.list()` is something it could pass
172/// straight back to `register()`.
173pub fn format(buf: []u8, binding: Binding) ![]const u8 {
174 var pos: usize = 0;
175 const parts = [_]struct { on: bool, name: []const u8 }{
176 .{ .on = binding.modifiers.cmd, .name = "Cmd" },
177 .{ .on = binding.modifiers.ctrl, .name = "Ctrl" },
178 .{ .on = binding.modifiers.alt, .name = "Alt" },
179 .{ .on = binding.modifiers.shift, .name = "Shift" },
180 };
181 for (parts) |part| {
182 if (!part.on) continue;
183 if (pos + part.name.len + 1 > buf.len) return error.NoSpaceLeft;
184 @memcpy(buf[pos..][0..part.name.len], part.name);
185 pos += part.name.len;
186 buf[pos] = '+';
187 pos += 1;
188 }
189 if (pos + binding.key.len > buf.len) return error.NoSpaceLeft;
190 @memcpy(buf[pos..][0..binding.key.len], binding.key);
191 return buf[0 .. pos + binding.key.len];
192}
193
194// =============================================================================
195// Tests
196// =============================================================================
197
198test "the accelerator the downstream bug report names parses" {
199 // stacksjs/harness registers exactly this and has never received a key.
200 const b = try parse("Cmd+Shift+H");
201 try std.testing.expectEqualStrings("H", b.key);
202 try std.testing.expectEqual(@as(u16, 0x04), b.keycode);
203 try std.testing.expect(b.modifiers.cmd);
204 try std.testing.expect(b.modifiers.shift);
205 try std.testing.expect(!b.modifiers.ctrl);
206 try std.testing.expect(!b.modifiers.alt);
207}
208
209test "modifier spellings are interchangeable" {
210 const expected = try parse("Cmd+Alt+K");
211 for ([_][]const u8{
212 "cmd+alt+k",
213 "Command+Option+K",
214 "COMMAND+OPT+K",
215 "Meta+Alt+K",
216 "Super+Option+K",
217 "\u{2318}+\u{2325}+K",
218 }) |spelling| {
219 const b = try parse(spelling);
220 try std.testing.expect(b.eql(expected));
221 }
222}
223
224test "CmdOrCtrl resolves to this platform's modifier" {
225 const b = try parse("CmdOrCtrl+S");
226 if (builtin.os.tag == .macos) {
227 try std.testing.expect(b.modifiers.cmd);
228 try std.testing.expect(!b.modifiers.ctrl);
229 } else {
230 try std.testing.expect(b.modifiers.ctrl);
231 try std.testing.expect(!b.modifiers.cmd);
232 }
233}
234
235test "the key reported back is canonical, not the spelling that arrived" {
236 const b = try parse("Cmd+Backspace");
237 try std.testing.expectEqualStrings("Delete", b.key);
238
239 var buf: [64]u8 = undefined;
240 try std.testing.expectEqualStrings("Cmd+Delete", try format(&buf, b));
241}
242
243test "format round-trips through parse" {
244 var buf: [64]u8 = undefined;
245 for ([_][]const u8{
246 "Cmd+Shift+H",
247 "Ctrl+Alt+Delete",
248 "Cmd+Ctrl+Alt+Shift+Space",
249 "Alt+Left",
250 "F13",
251 }) |source| {
252 const first = try parse(source);
253 const rendered = try format(&buf, first);
254 const again = try parse(rendered);
255 try std.testing.expect(first.eql(again));
256 try std.testing.expectEqualStrings(first.key, again.key);
257 }
258}
259
260test "a bare key is refused rather than made untypeable everywhere" {
261 // A global hotkey on `H` means no application on the system, craft's
262 // included, ever sees the user type an h again. Failing the registration
263 // is recoverable; that is not.
264 try std.testing.expectError(ParseError.ModifierRequired, parse("H"));
265 try std.testing.expectError(ParseError.ModifierRequired, parse("Space"));
266 // Shift does not count: Shift+H is how you type a capital H.
267 try std.testing.expectError(ParseError.ModifierRequired, parse("Shift+H"));
268}
269
270test "function keys may be bound bare" {
271 // They produce no text, so reserving one costs the user nothing.
272 const b = try parse("F13");
273 try std.testing.expectEqualStrings("F13", b.key);
274 try std.testing.expect(!b.modifiers.hasCommandingModifier());
275 _ = try parse("F5");
276 _ = try parse("Shift+F1");
277}
278
279test "malformed accelerators name what is wrong with them" {
280 try std.testing.expectError(ParseError.MissingKey, parse(""));
281 try std.testing.expectError(ParseError.MissingKey, parse(" "));
282 try std.testing.expectError(ParseError.EmptyComponent, parse("Cmd+"));
283 try std.testing.expectError(ParseError.EmptyComponent, parse("+H"));
284 try std.testing.expectError(ParseError.EmptyComponent, parse("Cmd++H"));
285 try std.testing.expectError(ParseError.UnknownKey, parse("Cmd+Nonsense"));
286 try std.testing.expectError(ParseError.UnknownModifier, parse("Hyper+Cmd+H"));
287 try std.testing.expectError(ParseError.DuplicateModifier, parse("Cmd+Cmd+H"));
288}
289
290test "surrounding whitespace is tolerated" {
291 const spaced = try parse(" Cmd + Shift + H ");
292 const tight = try parse("Cmd+Shift+H");
293 try std.testing.expect(spaced.eql(tight));
294}
295
296test "a modifier name in the key position is the key, not a modifier" {
297 // `"Cmd+Shift"` names no key: Shift is the last component, so it is where
298 // the key should be, and it is not one.
299 try std.testing.expectError(ParseError.UnknownKey, parse("Cmd+Shift"));
300}
301
302test "Cocoa and Carbon flag sets do not agree, and both are right" {
303 const mods = Modifiers{ .cmd = true, .shift = true };
304 try std.testing.expectEqual(@as(c_ulong, (1 << 20) | (1 << 17)), mods.toCocoaFlags());
305 try std.testing.expectEqual(@as(u32, 0x0100 | 0x0200), mods.toCarbonFlags());
306
307 // Pin the Carbon values against the header. The two sets overlap in
308 // meaning and in nothing else, and a hotkey registered with Cocoa's 1<<20
309 // for command asks for a combination involving no modifier the user can
310 // press.
311 try std.testing.expectEqual(@as(u32, 0x0100), Modifiers.carbon_command);
312 try std.testing.expectEqual(@as(u32, 0x0200), Modifiers.carbon_shift);
313 try std.testing.expectEqual(@as(u32, 0x0800), Modifiers.carbon_option);
314 try std.testing.expectEqual(@as(u32, 0x1000), Modifiers.carbon_control);
315 try std.testing.expect(Modifiers.carbon_command != (1 << 20));
316}
317
318test "every modifier maps to a distinct bit in both sets" {
319 const each = [_]Modifiers{
320 .{ .cmd = true }, .{ .ctrl = true }, .{ .alt = true }, .{ .shift = true },
321 };
322 for (each, 0..) |a, i| {
323 try std.testing.expect(a.toCocoaFlags() != 0);
324 try std.testing.expect(a.toCarbonFlags() != 0);
325 for (each[i + 1 ..]) |b| {
326 try std.testing.expect(a.toCocoaFlags() != b.toCocoaFlags());
327 try std.testing.expect(a.toCarbonFlags() != b.toCarbonFlags());
328 }
329 }
330}
331
332test "keypad keys are bindable and distinct from the number row" {
333 const pad = try parse("Cmd+Keypad5");
334 const row = try parse("Cmd+5");
335 try std.testing.expect(!pad.eql(row));
336}
packages/zig/src/bridge_contracts.zigadded+16-0
Changes to packages/zig/src/bridge_contracts.zig
@@ -0,0 +1,16 @@
1//! The native halves of the JS bridge contracts, in one place.
2//!
3//! `test/injected_js_test.zig` drives the real injected JavaScript and then
4//! decodes what it posted with the very code the bridge dispatches to — which
5//! is the only kind of test that can catch a JS side and a native side that
6//! are each individually correct and disagree with each other. Both #27 (menu
7//! action names) and #47 (shortcut payload shape) were exactly that.
8//!
9//! They arrive through one aggregate rather than as two named modules because
10//! both reach `bridge_error.zig`, and a file may belong to only one module per
11//! compilation. A test module also cannot `@import` outside its own directory,
12//! so this has to live here beside the code it re-exports rather than in
13//! `test/`.
14
15pub const menu = ;
16pub const shortcuts = ;
packages/zig/src/bridge_shortcuts.zigmodified+161-403
Changes to packages/zig/src/bridge_shortcuts.zig
@@ -1,454 +1,212 @@
1//! `window.craft.shortcuts` — global hotkeys.
2//!
3//! A thin adapter: it decodes the action, hands the payload to
4//! `shortcut_registry.zig`, and turns the answer back into JavaScript. The
5//! decisions live in the registry, where they are testable; the key is
6//! actually reserved by `macos_hotkey.zig`.
7//!
8//! What used to be here, and what craft-native/craft#47 was about: an app
9//! could register a hotkey, be told it worked, and never receive a keystroke.
10//! `setupGlobalMonitor` computed an event mask, discarded it, installed
11//! nothing, and logged "Global monitor setup (polling mode)" — there was no
12//! polling either. The matcher it was supposed to feed was reachable only from
13//! a function with no callers anywhere in the tree. Three further mismatches
14//! sat on top of it, each individually fatal:
15//!
16//! * the JS bridge sends `{id, accelerator}`; this file read `key` and
17//! `modifiers`, so every registration failed its own validation before it
18//! got anywhere near the missing monitor;
19//! * `isRegistered` and `list` answered by calling `__craftShortcutList` and
20//! friends, which nothing defines — so those promises hung for the full
21//! 30-second request timeout and then rejected;
22//! * a triggered shortcut called `__craftShortcutCallback`, which nothing
23//! defines either, while the JS side was listening for a `craft:shortcut`
24//! event that was never dispatched.
25
126const std = ;
227const builtin = ;
328const bridge_error = ;
429const logging = ;
30const registry_mod = ;
31const accel = ;
532
633const log = logging.shortcuts;
734
835const BridgeError = bridge_error.BridgeError;
936
10/// Modifier flags for keyboard shortcuts
11pub const Modifiers = struct {
12 cmd: bool = false,
13 ctrl: bool = false,
14 alt: bool = false,
15 shift: bool = false,
16
17 pub fn toCocoaFlags(self: Modifiers) c_ulong {
18 var flags: c_ulong = 0;
19 if (self.cmd) flags |= (1 << 20); // NSEventModifierFlagCommand
20 if (self.ctrl) flags |= (1 << 18); // NSEventModifierFlagControl
21 if (self.alt) flags |= (1 << 19); // NSEventModifierFlagOption
22 if (self.shift) flags |= (1 << 17); // NSEventModifierFlagShift
23 return flags;
37/// Re-exported so callers that named these through this module keep working.
38pub const Modifiers = accel.Modifiers;
39pub const Shortcut = registry_mod.Registration;
40
41pub const action_register = registry_mod.action_register;
42pub const action_unregister = registry_mod.action_unregister;
43pub const action_unregister_all = registry_mod.action_unregister_all;
44pub const action_enable = registry_mod.action_enable;
45pub const action_disable = registry_mod.action_disable;
46pub const action_is_registered = registry_mod.action_is_registered;
47pub const action_list = registry_mod.action_list;
48
49/// The event a triggered shortcut arrives on, matching `_evt('craft:shortcut')`
50/// in `craft-bridge.js`.
51pub const event_triggered = "craft:shortcut";
52
53/// Where a registration that could not be granted is reported.
54///
55/// `register` is fire-and-forget on the JS side — its action name collides
56/// with other bridges' in the request queue, so it cannot be a request without
57/// mixing replies — which leaves an app no way to hear that the key it asked
58/// for belongs to someone else. This event is that way.
59pub const event_error = "craft:shortcut:error";
60
61fn platform() registry_mod.Platform {
62 if (comptime builtin.os.tag == .macos) {
63 return .{
64 .register = struct {
65 fn f(keycode: u16, modifiers: u32, hotkey_id: u32) registry_mod.Ref {
66 return .register(keycode, modifiers, hotkey_id);
67 }
68 }.f,
69 .unregister = struct {
70 fn f(ref: registry_mod.Ref) void {
71 .unregister(ref);
72 }
73 }.f,
74 };
2475 }
25};
26
27/// Registered shortcut
28pub const Shortcut = struct {
29 id: []const u8,
30 key: []const u8,
31 modifiers: Modifiers,
32 callback_id: []const u8,
33 enabled: bool = true,
34};
76 // Linux and Windows have no global-hotkey implementation in craft yet.
77 // Refusing every registration is the honest answer: the alternative —
78 // accepting them — is the bug this file was rewritten to fix.
79 return registry_mod.unsupported_platform;
80}
3581
36/// Bridge handler for global keyboard shortcuts
3782pub const ShortcutsBridge = struct {
3883 allocator: std.mem.Allocator,
39 shortcuts: std.StringHashMap(Shortcut),
40 global_monitor: ?*anyopaque = null,
41 local_monitor: ?*anyopaque = null,
84 registry: registry_mod.Registry,
4285
4386 const Self = @This();
4487
4588 pub fn init(allocator: std.mem.Allocator) Self {
4689 return .{
4790 .allocator = allocator,
48 .shortcuts = std.StringHashMap(Shortcut).init(allocator),
91 .registry = registry_mod.Registry.init(allocator, platform()),
4992 };
5093 }
5194
52 /// Handle shortcut-related messages from JavaScript
95 pub fn deinit(self: *Self) void {
96 self.registry.deinit();
97 if (comptime builtin.os.tag == .macos) {
98 .on_pressed = null;
99 }
100 const global_state = ;
101 global_state.instance.setShortcutsBridge(null);
102 }
103
53104 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
54105 self.handleMessageInternal(action, data) catch |err| {
55 self.reportError(action, err);
106 const bridge_err: BridgeError = err;
107 // The action name travels with the error so `__craftBridgeError`
108 // rejects only that action's callers.
109 bridge_error.sendErrorToJS(self.allocator, action, bridge_err);
110
111 // `isRegistered` and `list` are requests: the line above rejects
112 // the promise their caller is holding, and that is the whole
113 // report. Every other verb is fire-and-forget, so its caller has
114 // already moved on and there is nothing to reject — the error
115 // event is the only way it hears anything at all.
116 const is_request = std.mem.eql(u8, action, action_is_registered) or
117 std.mem.eql(u8, action, action_list);
118 if (!is_request) self.reportFailure(data, bridge_err);
56119 };
57120 }
58121
59122 fn handleMessageInternal(self: *Self, action: []const u8, data: []const u8) !void {
60 if (std.mem.eql(u8, action, "register")) {
61 try self.registerShortcut(data);
62 } else if (std.mem.eql(u8, action, "unregister")) {
63 try self.unregisterShortcut(data);
64 } else if (std.mem.eql(u8, action, "unregisterAll")) {
65 try self.unregisterAllShortcuts();
66 } else if (std.mem.eql(u8, action, "enable")) {
67 try self.enableShortcut(data);
68 } else if (std.mem.eql(u8, action, "disable")) {
69 try self.disableShortcut(data);
70 } else if (std.mem.eql(u8, action, "isRegistered")) {
71 try self.isRegistered(data);
72 } else if (std.mem.eql(u8, action, "list")) {
73 try self.listShortcuts();
123 if (std.mem.eql(u8, action, action_register)) {
124 self.ensureDelivery();
125 const entry = try self.registry.register(data);
126 log.debug("registered {s} as {s}", .{ entry.id, entry.accelerator });
127 } else if (std.mem.eql(u8, action, action_unregister)) {
128 try self.registry.unregister(data);
129 } else if (std.mem.eql(u8, action, action_unregister_all)) {
130 self.registry.unregisterAll();
131 } else if (std.mem.eql(u8, action, action_enable)) {
132 try self.registry.setEnabled(data, true);
133 } else if (std.mem.eql(u8, action, action_disable)) {
134 try self.registry.setEnabled(data, false);
135 } else if (std.mem.eql(u8, action, action_is_registered)) {
136 const json = try self.registry.isRegisteredJson(data);
137 defer self.allocator.free(json);
138 bridge_error.sendResultToJS(self.allocator, action, json);
139 } else if (std.mem.eql(u8, action, action_list)) {
140 const json = try self.registry.listJson();
141 defer self.allocator.free(json);
142 bridge_error.sendResultToJS(self.allocator, action, json);
74143 } else {
75144 return BridgeError.UnknownAction;
76145 }
77146 }
78147
79 fn reportError(self: *Self, action: []const u8, err: anyerror) void {
80 const bridge_err: BridgeError = switch (err) {
81 BridgeError.MissingData => BridgeError.MissingData,
82 BridgeError.InvalidJSON => BridgeError.InvalidJSON,
83 else => BridgeError.NativeCallFailed,
84 };
85 bridge_error.sendErrorToJS(self.allocator, action, bridge_err);
86 }
87
88 /// Register a global shortcut
89 /// JSON: {"id": "toggle", "key": "Space", "modifiers": {"cmd": true, "shift": true}, "callback": "onToggle"}
90 fn registerShortcut(self: *Self, data: []const u8) !void {
91 // Parse shortcut data
92 var id: []const u8 = "";
93 var key: []const u8 = "";
94 var callback_id: []const u8 = "";
95 var modifiers = Modifiers{};
96
97 // Parse id
98 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
99 const start = idx + 6;
100 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
101 id = data[start..end];
102 }
103 }
104
105 // Parse key
106 if (std.mem.indexOf(u8, data, "\"key\":\"")) |idx| {
107 const start = idx + 7;
108 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
109 key = data[start..end];
110 }
111 }
112
113 // Parse callback
114 if (std.mem.indexOf(u8, data, "\"callback\":\"")) |idx| {
115 const start = idx + 12;
116 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
117 callback_id = data[start..end];
118 }
119 }
120
121 // Parse modifiers
122 if (std.mem.indexOf(u8, data, "\"cmd\":true")) |_| modifiers.cmd = true;
123 if (std.mem.indexOf(u8, data, "\"ctrl\":true")) |_| modifiers.ctrl = true;
124 if (std.mem.indexOf(u8, data, "\"alt\":true")) |_| modifiers.alt = true;
125 if (std.mem.indexOf(u8, data, "\"shift\":true")) |_| modifiers.shift = true;
126
127 if (id.len == 0 or key.len == 0) {
128 return BridgeError.MissingData;
129 }
130
131 log.debug("register: id={s}, key={s}, cmd={}, ctrl={}, alt={}, shift={}", .{ id, key, modifiers.cmd, modifiers.ctrl, modifiers.alt, modifiers.shift });
132
133 // Store shortcut
134 const id_owned = try self.allocator.dupe(u8, id);
135 const key_owned = try self.allocator.dupe(u8, key);
136 const callback_owned = try self.allocator.dupe(u8, callback_id);
137
138 try self.shortcuts.put(id_owned, Shortcut{
139 .id = id_owned,
140 .key = key_owned,
141 .modifiers = modifiers,
142 .callback_id = callback_owned,
143 .enabled = true,
144 });
145
146 // Setup global monitor if not already done
147 if (self.global_monitor == null) {
148 try self.setupGlobalMonitor();
149 }
150 }
151
152 /// Setup global event monitor
153 fn setupGlobalMonitor(self: *Self) !void {
154 if (comptime builtin.os.tag != .macos) return;
155
156 const macos = ;
157
158 // Create a global monitor for key down events
159 // NSEventMaskKeyDown = 1 << 10 = 1024
160 const event_mask: c_ulonglong = 1 << 10;
161
162 // We need to use addGlobalMonitorForEventsMatchingMask:handler:
163 // This requires creating an Objective-C block, which is complex in Zig
164 // For now, we'll use a polling approach via the run loop
165
166 // Alternative: Use local monitor for when app is active
167 const NSEvent = macos.getClass("NSEvent");
168 _ = NSEvent;
169 _ = event_mask;
170
171 // Store reference to self for callback
148 /// Point the platform's hotkey callback at this bridge. Cheap and
149 /// idempotent, so it runs before every registration rather than depending
150 /// on an initialisation order.
151 fn ensureDelivery(self: *Self) void {
172152 const global_state = ;
173153 global_state.instance.setShortcutsBridge(self);
174
175 log.debug("Global monitor setup (polling mode)", .{});
176 }
177
178 /// Check if a key event matches any registered shortcut
179 pub fn checkKeyEvent(self: *Self, keycode: u16, flags: c_ulong) void {
180 const key_name = keycodeToName(keycode);
181
182 var it = self.shortcuts.iterator();
183 while (it.next()) |entry| {
184 const shortcut = entry.value_ptr.*;
185 if (!shortcut.enabled) continue;
186
187 // Check if key matches
188 if (!std.mem.eql(u8, shortcut.key, key_name)) continue;
189
190 // Check modifiers
191 const required_flags = shortcut.modifiers.toCocoaFlags();
192 const masked_flags = flags & ((1 << 17) | (1 << 18) | (1 << 19) | (1 << 20));
193
194 if (masked_flags == required_flags) {
195 log.debug("Shortcut triggered: {s}", .{shortcut.id});
196 self.triggerCallback(shortcut.id, shortcut.callback_id);
197 }
154 if (comptime builtin.os.tag == .macos) {
155 .on_pressed = onHotKeyPressed;
198156 }
199157 }
200158
201 /// Trigger JavaScript callback for shortcut.
202 /// Escapes both IDs before embedding them in the JS literal so a value
203 /// containing `'` / `\` / newline cannot break out and inject code.
204 fn triggerCallback(self: *Self, shortcut_id: []const u8, callback_id: []const u8) void {
205 _ = self;
206
207 const bridge = ;
208
209 var sid_buf: [128]u8 = undefined;
210 var cb_buf: [128]u8 = undefined;
211 const sid_esc = bridge_error.escapeJsSingleQuoted(&sid_buf, shortcut_id) catch return;
212 const cb_esc = bridge_error.escapeJsSingleQuoted(&cb_buf, callback_id) catch return;
213
214 var buf: [512]u8 = undefined;
215 const js = std.fmt.bufPrint(&buf,
216 \\if(window.__craftShortcutCallback)window.__craftShortcutCallback('{s}','{s}');
217 , .{ sid_esc, cb_esc }) catch return;
159 /// Called on the main thread when a registered combination is pressed
160 /// anywhere in the system.
161 pub fn deliver(self: *Self, hotkey_id: u32) void {
162 const entry = self.registry.findByHotkeyId(hotkey_id) orelse return;
163 // A hotkey disabled between the press and its delivery must not fire.
164 if (!entry.enabled) return;
218165
219 bridge.evalJS(js) catch |err| {
220 log.debug("Failed to trigger callback: {}", .{err});
221 };
222 }
223
224 /// Unregister a shortcut
225 /// JSON: {"id": "toggle"}
226 fn unregisterShortcut(self: *Self, data: []const u8) !void {
227 var id: []const u8 = "";
228 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
229 const start = idx + 6;
230 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
231 id = data[start..end];
232 }
233 }
234
235 if (id.len == 0) return BridgeError.MissingData;
236
237 log.debug("unregister: {s}", .{id});
238
239 if (self.shortcuts.fetchRemove(id)) |kv| {
240 self.allocator.free(kv.value.id);
241 self.allocator.free(kv.value.key);
242 self.allocator.free(kv.value.callback_id);
243 }
244 }
245
246 /// Unregister all shortcuts
247 fn unregisterAllShortcuts(self: *Self) !void {
248 log.debug("unregisterAll", .{});
249
250 var it = self.shortcuts.iterator();
251 while (it.next()) |entry| {
252 self.allocator.free(entry.value_ptr.id);
253 self.allocator.free(entry.value_ptr.key);
254 self.allocator.free(entry.value_ptr.callback_id);
255 }
256 self.shortcuts.clearAndFree();
257 }
258
259 /// Enable a shortcut
260 /// JSON: {"id": "toggle"}
261 fn enableShortcut(self: *Self, data: []const u8) !void {
262 var id: []const u8 = "";
263 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
264 const start = idx + 6;
265 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
266 id = data[start..end];
267 }
268 }
269
270 if (self.shortcuts.getPtr(id)) |shortcut| {
271 shortcut.enabled = true;
272 log.debug("enabled: {s}", .{id});
273 }
274 }
275
276 /// Disable a shortcut
277 /// JSON: {"id": "toggle"}
278 fn disableShortcut(self: *Self, data: []const u8) !void {
279 var id: []const u8 = "";
280 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
281 const start = idx + 6;
282 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
283 id = data[start..end];
284 }
285 }
286
287 if (self.shortcuts.getPtr(id)) |shortcut| {
288 shortcut.enabled = false;
289 log.debug("disabled: {s}", .{id});
290 }
166 log.debug("triggered {s} ({s})", .{ entry.id, entry.accelerator });
167 const detail = registry_mod.triggeredDetail(self.allocator, entry.id, entry.accelerator) catch return;
168 defer self.allocator.free(detail);
169 self.dispatch(event_triggered, detail);
291170 }
292171
293 /// Check if shortcut is registered
294 /// JSON: {"id": "toggle"}
295 fn isRegistered(self: *Self, data: []const u8) !void {
172 fn reportFailure(self: *Self, data: []const u8, err: BridgeError) void {
173 // Best effort: the id is read back out of the payload so the app can
174 // tell which of its registrations failed. A payload too malformed to
175 // yield an id still gets the error, just without one.
176 const IdOnly = struct { id: []const u8 = "" };
296177 var id: []const u8 = "";
297 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
298 const start = idx + 6;
299 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
300 id = data[start..end];
301 }
302 }
303
304 const registered = self.shortcuts.contains(id);
305
306 const bridge = ;
307 var id_buf: [128]u8 = undefined;
308 const id_esc = bridge_error.escapeJsSingleQuoted(&id_buf, id) catch return;
309
310 var buf: [256]u8 = undefined;
311 const js = std.fmt.bufPrint(&buf,
312 \\if(window.__craftShortcutRegistered)window.__craftShortcutRegistered('{s}',{});
313 , .{ id_esc, registered }) catch return;
314
315 bridge.evalJS(js) catch |err| {
316 std.log.debug("JS eval failed for shortcut registered callback: {}", .{err});
317 };
318 }
319
320 /// List all registered shortcuts
321 fn listShortcuts(self: *Self) !void {
178 const parsed = std.json.parseFromSlice(IdOnly, self.allocator, data, .{
179 .ignore_unknown_fields = true,
180 .allocate = .alloc_always,
181 }) catch null;
182 defer if (parsed) |p| p.deinit();
183 if (parsed) |p| id = p.value.id;
184
185 const detail = registry_mod.errorDetail(
186 self.allocator,
187 id,
188 bridge_error.errorCodeString(err),
189 bridge_error.errorMessage(err),
190 ) catch return;
191 defer self.allocator.free(detail);
192 self.dispatch(event_error, detail);
193 }
194
195 /// Emit `new CustomEvent(name, { detail })` into the page.
196 fn dispatch(self: *Self, name: []const u8, detail: []const u8) void {
322197 const bridge = ;
323198
324 var buf: [2048]u8 = undefined;
325 var pos: usize = 0;
326
327 // Start JSON array
328 buf[pos] = '[';
329 pos += 1;
330
331 var first = true;
332 var it = self.shortcuts.iterator();
333 while (it.next()) |entry| {
334 const s = entry.value_ptr.*;
335 if (!first) {
336 buf[pos] = ',';
337 pos += 1;
338 }
339 first = false;
340
341 const item = std.fmt.bufPrint(buf[pos..],
342 \\
343 , .{ s.id, s.key, s.enabled }) catch break;
344 pos += item.len;
345 }
346
347 buf[pos] = ']';
348 pos += 1;
349
350 var js_buf: [2200]u8 = undefined;
351 const js = std.fmt.bufPrint(&js_buf,
352 \\if(window.__craftShortcutList)window.__craftShortcutList({s});
353 , .{buf[0..pos]}) catch return;
199 const js = registry_mod.eventScript(self.allocator, name, detail) catch return;
200 defer self.allocator.free(js);
354201
355 bridge.evalJS(js) catch |err| {
356 std.log.debug("JS eval failed for shortcut list callback: {}", .{err});
202 bridge.evalJS(js) catch |eval_err| {
203 log.debug("could not deliver {s}: {}", .{ name, eval_err });
357204 };
358205 }
359
360 pub fn deinit(self: *Self) void {
361 var it = self.shortcuts.iterator();
362 while (it.next()) |entry| {
363 self.allocator.free(entry.value_ptr.id);
364 self.allocator.free(entry.value_ptr.key);
365 self.allocator.free(entry.value_ptr.callback_id);
366 }
367 self.shortcuts.deinit();
368 const global_state = ;
369 global_state.instance.setShortcutsBridge(null);
370 }
371206};
372207
373/// Convert keycode to key name
374fn keycodeToName(keycode: u16) []const u8 {
375 return switch (keycode) {
376 0 => "A",
377 1 => "S",
378 2 => "D",
379 3 => "F",
380 4 => "H",
381 5 => "G",
382 6 => "Z",
383 7 => "X",
384 8 => "C",
385 9 => "V",
386 11 => "B",
387 12 => "Q",
388 13 => "W",
389 14 => "E",
390 15 => "R",
391 16 => "Y",
392 17 => "T",
393 18 => "1",
394 19 => "2",
395 20 => "3",
396 21 => "4",
397 22 => "6",
398 23 => "5",
399 24 => "=",
400 25 => "9",
401 26 => "7",
402 27 => "-",
403 28 => "8",
404 29 => "0",
405 30 => "]",
406 31 => "O",
407 32 => "U",
408 33 => "[",
409 34 => "I",
410 35 => "P",
411 36 => "Return",
412 37 => "L",
413 38 => "J",
414 39 => "'",
415 40 => "K",
416 41 => ";",
417 42 => "\\",
418 43 => ",",
419 44 => "/",
420 45 => "N",
421 46 => "M",
422 47 => ".",
423 48 => "Tab",
424 49 => "Space",
425 50 => "`",
426 51 => "Delete",
427 53 => "Escape",
428 96 => "F5",
429 97 => "F6",
430 98 => "F7",
431 99 => "F3",
432 100 => "F8",
433 101 => "F9",
434 103 => "F11",
435 109 => "F10",
436 111 => "F12",
437 118 => "F4",
438 120 => "F2",
439 122 => "F1",
440 123 => "Left",
441 124 => "Right",
442 125 => "Down",
443 126 => "Up",
444 else => "Unknown",
445 };
446}
447
448/// Public function for external event handling
449pub fn handleGlobalKeyEvent(keycode: u16, flags: c_ulong) void {
208/// Bridge between the platform's C callback and the live bridge instance.
209fn onHotKeyPressed(hotkey_id: u32) void {
450210 const global_state = ;
451 if (global_state.instance.getShortcutsBridge()) |bridge| {
452 bridge.checkKeyEvent(keycode, flags);
453 }
211 if (global_state.instance.getShortcutsBridge()) |bridge| bridge.deliver(hotkey_id);
454212}
packages/zig/src/js/craft-bridge.jsmodified+8-0
Changes to packages/zig/src/js/craft-bridge.js
@@ -539,6 +539,13 @@
539539 // -------------------------------------------------------------------------
540540 // shortcuts — global hotkeys
541541 // -------------------------------------------------------------------------
542 // `register` is fire-and-forget by necessity, not by choice: `_req` keys its
543 // pending queue by action name alone, and `register`/`enable`/`disable` are
544 // action names other bridges use too, so making these requests would mix
545 // replies between namespaces. `onError` is how a refused registration —
546 // the key already belongs to another app, or to the system — still reaches
547 // the caller. Registering a hotkey and never hearing that you did not get it
548 // is the bug this whole namespace was rewritten to fix (#47).
542549 window.craft.shortcuts = {
543550 register: function (id, accelerator, opts) {
544551 return _send('shortcuts', 'register', _stringify(Object.assign({
@@ -552,6 +559,7 @@
552559 isRegistered: function (id) { return _req('shortcuts', 'isRegistered', _stringify({ id: String(id) })).then(function (r) { return !!(r && r.value) }) },
553560 list: function () { return _req('shortcuts', 'list').then(function (r) { return (r && r.shortcuts) || [] }) },
554561 on: _evt('craft:shortcut'),
562 onError: _evt('craft:shortcut:error'),
555563 }
556564
557565 // -------------------------------------------------------------------------
packages/zig/src/key_codes.zigadded+269-0
Changes to packages/zig/src/key_codes.zig
@@ -0,0 +1,269 @@
1//! macOS virtual key codes, and the accelerator strings that name them.
2//!
3//! One table, read in both directions. `bridge_shortcuts.zig` needs the
4//! name→code direction to hand a key to `RegisterEventHotKey`, and the
5//! code→name direction to describe a shortcut back to JavaScript. Those used
6//! to be two independent switch statements, one of which did not exist — so
7//! there was nothing to keep them agreeing.
8//!
9//! The codes are the `kVK_*` constants from `<Carbon/HIToolbox/Events.h>`.
10//! They are positions on the keyboard, not characters: `kVK_ANSI_A` is 0
11//! whatever the active layout prints there. That is exactly what a global
12//! hotkey wants — the user who set Cmd+Shift+H keeps it after switching to
13//! Dvorak, because the physical key is what was registered.
14
15const std = ;
16
17pub const Entry = struct {
18 /// The name craft uses in accelerators and reports back to JS.
19 name: []const u8,
20 code: u16,
21};
22
23/// The canonical name for each key: the one `nameFor` returns.
24///
25/// Order matters only in that the first entry for a code wins in `nameFor`;
26/// aliases live in `aliases` below so they can never shadow a canonical name.
27pub const table = [_]Entry{
28 // Letters, in keyboard order rather than alphabetical — this is the
29 // order Events.h lists them in, and matching it makes the constants
30 // checkable against the header by eye.
31 .{ .name = "A", .code = 0x00 },
32 .{ .name = "S", .code = 0x01 },
33 .{ .name = "D", .code = 0x02 },
34 .{ .name = "F", .code = 0x03 },
35 .{ .name = "H", .code = 0x04 },
36 .{ .name = "G", .code = 0x05 },
37 .{ .name = "Z", .code = 0x06 },
38 .{ .name = "X", .code = 0x07 },
39 .{ .name = "C", .code = 0x08 },
40 .{ .name = "V", .code = 0x09 },
41 .{ .name = "B", .code = 0x0B },
42 .{ .name = "Q", .code = 0x0C },
43 .{ .name = "W", .code = 0x0D },
44 .{ .name = "E", .code = 0x0E },
45 .{ .name = "R", .code = 0x0F },
46 .{ .name = "Y", .code = 0x10 },
47 .{ .name = "T", .code = 0x11 },
48 .{ .name = "1", .code = 0x12 },
49 .{ .name = "2", .code = 0x13 },
50 .{ .name = "3", .code = 0x14 },
51 .{ .name = "4", .code = 0x15 },
52 .{ .name = "6", .code = 0x16 },
53 .{ .name = "5", .code = 0x17 },
54 .{ .name = "=", .code = 0x18 },
55 .{ .name = "9", .code = 0x19 },
56 .{ .name = "7", .code = 0x1A },
57 .{ .name = "-", .code = 0x1B },
58 .{ .name = "8", .code = 0x1C },
59 .{ .name = "0", .code = 0x1D },
60 .{ .name = "]", .code = 0x1E },
61 .{ .name = "O", .code = 0x1F },
62 .{ .name = "U", .code = 0x20 },
63 .{ .name = "[", .code = 0x21 },
64 .{ .name = "I", .code = 0x22 },
65 .{ .name = "P", .code = 0x23 },
66 .{ .name = "Return", .code = 0x24 },
67 .{ .name = "L", .code = 0x25 },
68 .{ .name = "J", .code = 0x26 },
69 .{ .name = "'", .code = 0x27 },
70 .{ .name = "K", .code = 0x28 },
71 .{ .name = ";", .code = 0x29 },
72 .{ .name = "\\", .code = 0x2A },
73 .{ .name = ",", .code = 0x2B },
74 .{ .name = "/", .code = 0x2C },
75 .{ .name = "N", .code = 0x2D },
76 .{ .name = "M", .code = 0x2E },
77 .{ .name = ".", .code = 0x2F },
78 .{ .name = "Tab", .code = 0x30 },
79 .{ .name = "Space", .code = 0x31 },
80 .{ .name = "`", .code = 0x32 },
81 // `kVK_Delete` is the key labelled Delete on a Mac keyboard, which sends
82 // backspace. The one labelled Delete on a PC keyboard is
83 // `kVK_ForwardDelete` below. Both spellings are accepted; see `aliases`.
84 .{ .name = "Delete", .code = 0x33 },
85 .{ .name = "Escape", .code = 0x35 },
86
87 // Keypad. Distinct codes from the number row, so a shortcut bound to
88 // keypad 5 does not fire on the 5 above the T.
89 .{ .name = "KeypadDecimal", .code = 0x41 },
90 .{ .name = "KeypadMultiply", .code = 0x43 },
91 .{ .name = "KeypadPlus", .code = 0x45 },
92 .{ .name = "KeypadClear", .code = 0x47 },
93 .{ .name = "KeypadDivide", .code = 0x4B },
94 .{ .name = "KeypadEnter", .code = 0x4C },
95 .{ .name = "KeypadMinus", .code = 0x4E },
96 .{ .name = "KeypadEquals", .code = 0x51 },
97 .{ .name = "Keypad0", .code = 0x52 },
98 .{ .name = "Keypad1", .code = 0x53 },
99 .{ .name = "Keypad2", .code = 0x54 },
100 .{ .name = "Keypad3", .code = 0x55 },
101 .{ .name = "Keypad4", .code = 0x56 },
102 .{ .name = "Keypad5", .code = 0x57 },
103 .{ .name = "Keypad6", .code = 0x58 },
104 .{ .name = "Keypad7", .code = 0x59 },
105 .{ .name = "Keypad8", .code = 0x5B },
106 .{ .name = "Keypad9", .code = 0x5C },
107
108 // Function keys. Note that the codes are not in numeric order — F5 comes
109 // before F3 — which is why writing them out beats computing them.
110 .{ .name = "F1", .code = 0x7A },
111 .{ .name = "F2", .code = 0x78 },
112 .{ .name = "F3", .code = 0x63 },
113 .{ .name = "F4", .code = 0x76 },
114 .{ .name = "F5", .code = 0x60 },
115 .{ .name = "F6", .code = 0x61 },
116 .{ .name = "F7", .code = 0x62 },
117 .{ .name = "F8", .code = 0x64 },
118 .{ .name = "F9", .code = 0x65 },
119 .{ .name = "F10", .code = 0x6D },
120 .{ .name = "F11", .code = 0x67 },
121 .{ .name = "F12", .code = 0x6F },
122 .{ .name = "F13", .code = 0x69 },
123 .{ .name = "F14", .code = 0x6B },
124 .{ .name = "F15", .code = 0x71 },
125 .{ .name = "F16", .code = 0x6A },
126 .{ .name = "F17", .code = 0x40 },
127 .{ .name = "F18", .code = 0x4F },
128 .{ .name = "F19", .code = 0x50 },
129 .{ .name = "F20", .code = 0x5A },
130
131 // Navigation and editing.
132 .{ .name = "Help", .code = 0x72 },
133 .{ .name = "Home", .code = 0x73 },
134 .{ .name = "PageUp", .code = 0x74 },
135 .{ .name = "ForwardDelete", .code = 0x75 },
136 .{ .name = "End", .code = 0x77 },
137 .{ .name = "PageDown", .code = 0x79 },
138 .{ .name = "Left", .code = 0x7B },
139 .{ .name = "Right", .code = 0x7C },
140 .{ .name = "Down", .code = 0x7D },
141 .{ .name = "Up", .code = 0x7E },
142};
143
144/// Extra spellings accepted by `codeFor`, never returned by `nameFor`.
145///
146/// The point is that an accelerator copied out of an Electron app, a web
147/// `KeyboardEvent.key`, or a Mac keyboard's own labelling all resolve to the
148/// same key rather than failing registration.
149const aliases = [_]Entry{
150 .{ .name = "Backspace", .code = 0x33 },
151 .{ .name = "Esc", .code = 0x35 },
152 .{ .name = "Enter", .code = 0x24 },
153 .{ .name = "Plus", .code = 0x18 },
154 .{ .name = "Equal", .code = 0x18 },
155 .{ .name = "Minus", .code = 0x1B },
156 .{ .name = "Comma", .code = 0x2B },
157 .{ .name = "Period", .code = 0x2F },
158 .{ .name = "Slash", .code = 0x2C },
159 .{ .name = "Backslash", .code = 0x2A },
160 .{ .name = "Semicolon", .code = 0x29 },
161 .{ .name = "Quote", .code = 0x27 },
162 .{ .name = "Backquote", .code = 0x32 },
163 .{ .name = "Grave", .code = 0x32 },
164 .{ .name = "BracketLeft", .code = 0x21 },
165 .{ .name = "BracketRight", .code = 0x1E },
166 .{ .name = "Del", .code = 0x75 },
167 .{ .name = "ArrowLeft", .code = 0x7B },
168 .{ .name = "ArrowRight", .code = 0x7C },
169 .{ .name = "ArrowDown", .code = 0x7D },
170 .{ .name = "ArrowUp", .code = 0x7E },
171 .{ .name = "PgUp", .code = 0x74 },
172 .{ .name = "PgDn", .code = 0x79 },
173};
174
175/// The virtual key code named by `name`, or null if nothing is.
176///
177/// Case-insensitive: an app writing `cmd+shift+h` means the same key as one
178/// writing `Cmd+Shift+H`, and neither should have to know which craft
179/// prefers.
180pub fn codeFor(name: []const u8) ?u16 {
181 for (table) |entry| {
182 if (std.ascii.eqlIgnoreCase(name, entry.name)) return entry.code;
183 }
184 for (aliases) |entry| {
185 if (std.ascii.eqlIgnoreCase(name, entry.name)) return entry.code;
186 }
187 return null;
188}
189
190/// The canonical name for `code`, or null if it is not a key craft names.
191pub fn nameFor(code: u16) ?[]const u8 {
192 for (table) |entry| {
193 if (entry.code == code) return entry.name;
194 }
195 return null;
196}
197
198test "every canonical name resolves back to its own code" {
199 for (table) |entry| {
200 try std.testing.expectEqual(entry.code, codeFor(entry.name).?);
201 try std.testing.expectEqualStrings(entry.name, nameFor(entry.code).?);
202 }
203}
204
205test "lookup is case-insensitive" {
206 try std.testing.expectEqual(@as(u16, 0x04), codeFor("h").?);
207 try std.testing.expectEqual(@as(u16, 0x04), codeFor("H").?);
208 try std.testing.expectEqual(@as(u16, 0x31), codeFor("space").?);
209 try std.testing.expectEqual(@as(u16, 0x31), codeFor("SPACE").?);
210}
211
212test "aliases resolve, and every one of them names a real key" {
213 for (aliases) |alias| {
214 try std.testing.expectEqual(alias.code, codeFor(alias.name).?);
215 // An alias pointing at a code no canonical entry claims would resolve
216 // fine and then list itself as "Unknown".
217 try std.testing.expect(nameFor(alias.code) != null);
218 }
219 // `nameFor` answers with the canonical spelling, so a shortcut registered
220 // as "Backspace" lists itself as "Delete" rather than echoing whichever
221 // spelling happened to arrive.
222 try std.testing.expectEqualStrings("Delete", nameFor(codeFor("Backspace").?).?);
223 try std.testing.expectEqualStrings("Escape", nameFor(codeFor("Esc").?).?);
224}
225
226test "no two canonical entries share a name or a code" {
227 for (table, 0..) |a, i| {
228 for (table[i + 1 ..]) |b| {
229 try std.testing.expect(!std.ascii.eqlIgnoreCase(a.name, b.name));
230 try std.testing.expect(a.code != b.code);
231 }
232 }
233}
234
235test "an alias never collides with a canonical name" {
236 // A duplicate here would be silently unreachable: `codeFor` checks the
237 // canonical table first, so an alias shadowing one could disagree with it
238 // forever without anything noticing.
239 for (aliases) |alias| {
240 for (table) |entry| {
241 try std.testing.expect(!std.ascii.eqlIgnoreCase(alias.name, entry.name));
242 }
243 }
244}
245
246test "unknown keys are absent rather than guessed" {
247 try std.testing.expect(codeFor("") == null);
248 try std.testing.expect(codeFor("Meta") == null);
249 try std.testing.expect(codeFor("F21") == null);
250 // 0x34 is unassigned between Escape and kVK_Delete.
251 try std.testing.expect(nameFor(0x34) == null);
252 try std.testing.expect(nameFor(0xFFFF) == null);
253}
254
255test "the number row and the keypad are different keys" {
256 // Bound to Keypad5, a shortcut must not fire on the 5 above the T — they
257 // are distinct physical keys and macOS gives them distinct codes.
258 try std.testing.expect(codeFor("5").? != codeFor("Keypad5").?);
259 try std.testing.expect(codeFor("Return").? != codeFor("KeypadEnter").?);
260}
261
262test "Delete is backspace and ForwardDelete is not" {
263 // The Mac key labelled Delete sends backspace; the PC key labelled Delete
264 // is a different code. Getting these the wrong way round silently binds
265 // the wrong key, so pin both.
266 try std.testing.expectEqual(@as(u16, 0x33), codeFor("Delete").?);
267 try std.testing.expectEqual(@as(u16, 0x75), codeFor("ForwardDelete").?);
268 try std.testing.expectEqual(@as(u16, 0x33), codeFor("Backspace").?);
269}
packages/zig/src/macos_hotkey.zigadded+263-0
Changes to packages/zig/src/macos_hotkey.zig
@@ -0,0 +1,263 @@
1//! Global hotkeys on macOS, via Carbon's `RegisterEventHotKey`.
2//!
3//! This is the mechanism macOS provides for "fire even when my app is not
4//! frontmost", and the reason craft uses it over the two obvious alternatives:
5//!
6//! * `+[NSEvent addGlobalMonitorForEventsMatchingMask:handler:]` takes an
7//! Objective-C block. Zig cannot write one, which is where the previous
8//! attempt stopped — the mask was computed and then discarded, and no
9//! monitor was ever installed. See `bridge_shortcuts.zig`.
10//! * `CGEventTapCreate` takes a plain C callback and would work, but it is a
11//! keylogging-grade primitive: it demands Accessibility or Input
12//! Monitoring permission and sees every keystroke the user types. Asking
13//! for that to deliver one hotkey is a bad trade for craft's users.
14//!
15//! Carbon hotkeys take a plain C callback, require no permission prompt at
16//! all, and the system only calls us for the exact combinations we asked for.
17//! The API is old but is neither deprecated nor unavailable: it is what the
18//! system's own hotkey UI is built on, and it is present on arm64.
19//!
20//! Registration is by *physical key*, not character. Cmd+Shift+H stays on the
21//! same key after the user switches to Dvorak — see `key_codes.zig`.
22
23const std = ;
24const builtin = ;
25
26const is_macos = builtin.os.tag == .macos;
27
28/// Opaque `EventHotKeyRef`. Held so the registration can be undone.
29pub const Ref = ?*anyopaque;
30
31/// Build a four-character code the way Carbon's headers do, so the constants
32/// below read as the strings they are rather than as hex nobody can check.
33fn fourCC(comptime s: *const [4:0]u8) u32 {
34 return (@as(u32, s[0]) << 24) | (@as(u32, s[1]) << 16) | (@as(u32, s[2]) << 8) | @as(u32, s[3]);
35}
36
37const k_event_class_keyboard = fourCC("keyb");
38const k_event_hotkey_pressed: u32 = 5;
39const k_event_param_direct_object = fourCC("----");
40const type_event_hotkey_id = fourCC("hkid");
41
42/// Signature for every hotkey craft registers, so ours are distinguishable
43/// from another framework's inside the same process.
44const craft_signature = fourCC("craf");
45
46const noErr: i32 = 0;
47
48const EventHotKeyID = extern struct {
49 signature: u32,
50 id: u32,
51};
52
53const EventTypeSpec = extern struct {
54 eventClass: u32,
55 eventKind: u32,
56};
57
58// `ItemCount`/`ByteCount` are `unsigned long` in MacTypes.h — 64-bit on every
59// target craft builds for — and `OSStatus` is `SInt32`.
60extern "c" fn GetApplicationEventTarget() ?*anyopaque;
61extern "c" fn RegisterEventHotKey(
62 inHotKeyCode: u32,
63 inHotKeyModifiers: u32,
64 inHotKeyID: EventHotKeyID,
65 inTarget: ?*anyopaque,
66 inOptions: u32,
67 outRef: *Ref,
68) i32;
69extern "c" fn UnregisterEventHotKey(inHotKey: ?*anyopaque) i32;
70extern "c" fn InstallEventHandler(
71 inTarget: ?*anyopaque,
72 inHandler: ?*const anyopaque,
73 inNumTypes: usize,
74 inList: [*]const EventTypeSpec,
75 inUserData: ?*anyopaque,
76 outRef: ?*?*anyopaque,
77) i32;
78extern "c" fn GetEventParameter(
79 inEvent: ?*anyopaque,
80 inName: u32,
81 inDesiredType: u32,
82 outActualType: ?*u32,
83 inBufferSize: usize,
84 outActualSize: ?*usize,
85 outData: ?*anyopaque,
86) i32;
87
88/// Called on the main thread with the `id` a hotkey was registered under,
89/// every time that combination is pressed anywhere in the system.
90pub var on_pressed: ?*const fn (id: u32) void = null;
91
92var handler_installed = false;
93
94fn hotKeyHandler(_: ?*anyopaque, event: ?*anyopaque, _: ?*anyopaque) callconv(.c) i32 {
95 var hotkey_id: EventHotKeyID = undefined;
96 const status = GetEventParameter(
97 event,
98 k_event_param_direct_object,
99 type_event_hotkey_id,
100 null,
101 @sizeOf(EventHotKeyID),
102 null,
103 &hotkey_id,
104 );
105 // Returning noErr regardless: the event was ours to handle either way, and
106 // reporting failure here asks Carbon to keep looking for another handler
107 // for a hotkey only craft registered.
108 if (status != noErr) return noErr;
109 if (hotkey_id.signature != craft_signature) return noErr;
110 if (on_pressed) |cb| cb(hotkey_id.id);
111 return noErr;
112}
113
114/// Install the one application-wide handler that every craft hotkey is
115/// delivered through. Idempotent; safe to call before each registration.
116///
117/// Returns false if Carbon refused, in which case registering hotkeys is
118/// pointless — they would be reserved system-wide and never delivered.
119pub fn ensureHandler() bool {
120 if (comptime !is_macos) return false;
121
122 if (handler_installed) return true;
123
124 const spec = [_]EventTypeSpec{.{
125 .eventClass = k_event_class_keyboard,
126 .eventKind = k_event_hotkey_pressed,
127 }};
128 const status = InstallEventHandler(
129 GetApplicationEventTarget(),
130 @ptrCast(&hotKeyHandler),
131 spec.len,
132 &spec,
133 null,
134 null,
135 );
136 if (status != noErr) return false;
137
138 handler_installed = true;
139 return true;
140}
141
142/// Reserve `keycode` + `modifiers` system-wide, delivered as `id`.
143///
144/// `modifiers` is a Carbon modifier mask — `accelerator.Modifiers.toCarbonFlags`
145/// produces one — not the Cocoa `NSEventModifierFlag` set.
146///
147/// Returns null when the combination is already taken — by another app, or by
148/// the system itself, which owns things like Cmd+Space. That is a normal
149/// outcome and the caller should report it to the app rather than treat it as
150/// a crash: the user has to pick a different key.
151pub fn register(keycode: u16, modifiers: u32, id: u32) Ref {
152 if (comptime !is_macos) return null;
153
154 if (!ensureHandler()) return null;
155
156 var ref: Ref = null;
157 const status = RegisterEventHotKey(
158 @intCast(keycode),
159 modifiers,
160 .{ .signature = craft_signature, .id = id },
161 GetApplicationEventTarget(),
162 0,
163 &ref,
164 );
165 if (status != noErr or ref == null) return null;
166 return ref;
167}
168
169/// Release a reservation so the combination reaches whoever wants it next.
170pub fn unregister(ref: Ref) void {
171 if (comptime !is_macos) return;
172
173 if (ref) |r| _ = UnregisterEventHotKey(r);
174}
175
176// Carbon's event *construction* API, used only by the test below. Declared
177// here rather than in the file's main body because craft never builds an event
178// itself at runtime — it only ever receives them.
179extern "c" fn CreateEvent(inAllocator: ?*anyopaque, inClassID: u32, inKind: u32, inWhen: f64, inAttributes: u32, outEvent: *?*anyopaque) i32;
180extern "c" fn SetEventParameter(inEvent: ?*anyopaque, inName: u32, inType: u32, inSize: usize, inDataPtr: *const anyopaque) i32;
181extern "c" fn SendEventToEventTarget(inEvent: ?*anyopaque, inTarget: ?*anyopaque) i32;
182extern "c" fn ReleaseEvent(inEvent: ?*anyopaque) void;
183
184var test_last_id: ?u32 = null;
185
186fn recordPressed(id: u32) void {
187 test_last_id = id;
188}
189
190/// Build the event Carbon sends on a hotkey press and put it through the real
191/// dispatcher, so the handler runs exactly as it does for a real keystroke.
192fn sendSyntheticHotKey(signature: u32, id: u32) !void {
193 var event: ?*anyopaque = null;
194 if (CreateEvent(null, k_event_class_keyboard, k_event_hotkey_pressed, 0, 0, &event) != noErr)
195 return error.CreateEventFailed;
196 defer ReleaseEvent(event);
197
198 const payload = EventHotKeyID{ .signature = signature, .id = id };
199 if (SetEventParameter(event, k_event_param_direct_object, type_event_hotkey_id, @sizeOf(EventHotKeyID), &payload) != noErr)
200 return error.SetParameterFailed;
201 if (SendEventToEventTarget(event, GetApplicationEventTarget()) != noErr)
202 return error.SendFailed;
203}
204
205test "a hotkey event dispatched by Carbon reaches the callback" {
206 // The one part of this file that cannot be reasoned about from the source:
207 // whether the handler signature, the `EventHotKeyID` layout and the
208 // `GetEventParameter` call are what Carbon actually expects. Getting any
209 // of them wrong compiles, links, registers — and then silently never
210 // delivers, which is the exact shape of the bug this file exists to fix.
211 //
212 // Everything here is real except the keystroke: a genuine `EventRef`, put
213 // through Carbon's own dispatcher, decoded by the handler craft installs.
214 if (comptime !is_macos) return error.SkipZigTest;
215
216 // A build host with no Carbon event target — some headless CI images —
217 // cannot install the handler, and there is nothing left to assert.
218 if (!ensureHandler()) return error.SkipZigTest;
219
220 const previous = on_pressed;
221 defer on_pressed = previous;
222 on_pressed = recordPressed;
223
224 test_last_id = null;
225 try sendSyntheticHotKey(craft_signature, 4242);
226 try std.testing.expectEqual(@as(?u32, 4242), test_last_id);
227}
228
229test "a hotkey belonging to someone else is left alone" {
230 // Another framework in the same process can register its own hotkeys, and
231 // its events arrive at every handler installed on the application target.
232 // Acting on one would fire an unrelated app's shortcut as if it were ours.
233 if (comptime !is_macos) return error.SkipZigTest;
234 if (!ensureHandler()) return error.SkipZigTest;
235
236 const previous = on_pressed;
237 defer on_pressed = previous;
238 on_pressed = recordPressed;
239
240 test_last_id = null;
241 try sendSyntheticHotKey(fourCC("othr"), 4242);
242 try std.testing.expectEqual(@as(?u32, null), test_last_id);
243}
244
245test "the four-character codes match the constants Carbon documents" {
246 // These are the values every Carbon hotkey example hard-codes as hex.
247 // Deriving them from the strings is what makes them readable; pinning the
248 // hex is what makes the derivation checkable.
249 try std.testing.expectEqual(@as(u32, 0x6B657962), fourCC("keyb"));
250 try std.testing.expectEqual(@as(u32, 0x2D2D2D2D), fourCC("----"));
251 try std.testing.expectEqual(@as(u32, 0x686B6964), fourCC("hkid"));
252 try std.testing.expectEqual(@as(u32, 0x63726166), fourCC("craf"));
253}
254
255test "EventHotKeyID is laid out the way Carbon reads it" {
256 // `GetEventParameter` writes straight into this struct, so a wrong size or
257 // a reordered field is a silent memory-corruption bug rather than a
258 // compile error.
259 try std.testing.expectEqual(@as(usize, 8), @sizeOf(EventHotKeyID));
260 try std.testing.expectEqual(@as(usize, 0), @offsetOf(EventHotKeyID, "signature"));
261 try std.testing.expectEqual(@as(usize, 4), @offsetOf(EventHotKeyID, "id"));
262 try std.testing.expectEqual(@as(usize, 8), @sizeOf(EventTypeSpec));
263}
packages/zig/src/shortcut_registry.zigadded+878-0
Changes to packages/zig/src/shortcut_registry.zig
@@ -0,0 +1,878 @@
1//! The set of global shortcuts an app has asked for, and what craft did about
2//! each one.
3//!
4//! Separated from `bridge_shortcuts.zig` so that the whole lifecycle —
5//! register, replace, disable, re-enable, unregister — can be tested against a
6//! fake platform, without an app, a window or a key press. The bug this exists
7//! to close (craft-native/craft#47) survived because the only thing that could
8//! have caught it was pressing the key on a real Mac.
9//!
10//! The registry owns the bookkeeping and the JSON on both sides of it. Actually
11//! reserving a key with the system is the one thing it delegates, through
12//! `Platform`.
13
14const std = ;
15const accel = ;
16const key_codes = ;
17const bridge_error = ;
18
19const BridgeError = bridge_error.BridgeError;
20
21/// The `a` field of a `shortcuts` bridge message. Shared constants rather than
22/// literals on each side: a wrong action name is not an error on the native
23/// side, only a log line, so a rename would otherwise silently stop a whole
24/// verb working.
25pub const action_register = "register";
26pub const action_unregister = "unregister";
27pub const action_unregister_all = "unregisterAll";
28pub const action_enable = "enable";
29pub const action_disable = "disable";
30pub const action_is_registered = "isRegistered";
31pub const action_list = "list";
32
33/// An opaque handle from the platform, held so a reservation can be released.
34pub const Ref = ?*anyopaque;
35
36/// How the registry reserves a key with the operating system.
37///
38/// `register` returns null when the combination is unavailable — already taken
39/// by another app, or by the system. That is an ordinary outcome, not a
40/// failure of craft's, and it has to reach the app: the user must pick a
41/// different key.
42pub const Platform = struct {
43 register: *const fn (keycode: u16, carbon_modifiers: u32, hotkey_id: u32) Ref,
44 unregister: *const fn (ref: Ref) void,
45};
46
47/// A platform that reserves nothing. Used where global hotkeys have no
48/// implementation, so `register` reports honestly instead of accepting a
49/// shortcut that could never fire.
50pub const unsupported_platform = Platform{
51 .register = struct {
52 fn f(_: u16, _: u32, _: u32) Ref {
53 return null;
54 }
55 }.f,
56 .unregister = struct {
57 fn f(_: Ref) void {}
58 }.f,
59};
60
61pub const Registration = struct {
62 /// App-chosen identifier. Owned.
63 id: []const u8,
64 /// Canonical accelerator — what `format` renders, not necessarily the
65 /// spelling that arrived. Owned.
66 accelerator: []const u8,
67 binding: accel.Binding,
68 /// Identifier handed to the platform, and the only thing that comes back
69 /// when the key is pressed.
70 hotkey_id: u32,
71 /// Null exactly when the key is not currently reserved — i.e. when the
72 /// shortcut is disabled.
73 ref: Ref = null,
74 enabled: bool = true,
75};
76
77/// The payload `shortcuts.register` accepts.
78///
79/// `accelerator` is what the JS bridge sends. `key` + `modifiers` is the older
80/// shape the SDK's message tests document; both are accepted so neither
81/// caller has to change to get a shortcut that works.
82const RegisterPayload = struct {
83 id: []const u8 = "",
84 accelerator: ?[]const u8 = null,
85 key: ?[]const u8 = null,
86 modifiers: ?PayloadModifiers = null,
87
88 const PayloadModifiers = struct {
89 cmd: bool = false,
90 ctrl: bool = false,
91 alt: bool = false,
92 shift: bool = false,
93 /// Web keyboard events spell command `metaKey`.
94 meta: bool = false,
95 };
96};
97
98/// A payload naming only an id: unregister, enable, disable, isRegistered.
99const IdPayload = struct {
100 id: []const u8 = "",
101};
102
103pub const Registry = struct {
104 allocator: std.mem.Allocator,
105 entries: std.ArrayListUnmanaged(Registration) = .empty,
106 platform: Platform,
107 next_hotkey_id: u32 = 1,
108
109 const Self = @This();
110
111 pub fn init(allocator: std.mem.Allocator, platform: Platform) Self {
112 return .{ .allocator = allocator, .platform = platform };
113 }
114
115 pub fn deinit(self: *Self) void {
116 for (self.entries.items) |*entry| {
117 self.platform.unregister(entry.ref);
118 self.allocator.free(entry.id);
119 self.allocator.free(entry.accelerator);
120 }
121 self.entries.deinit(self.allocator);
122 }
123
124 pub fn count(self: *const Self) usize {
125 return self.entries.items.len;
126 }
127
128 /// The entry for `id`, or null.
129 ///
130 /// The pointer is into the backing array and is invalidated by any
131 /// subsequent `register`, so callers must finish with it before the next
132 /// one.
133 pub fn find(self: *Self, id: []const u8) ?*Registration {
134 const index = self.findIndex(id) orelse return null;
135 return &self.entries.items[index];
136 }
137
138 fn findIndex(self: *const Self, id: []const u8) ?usize {
139 for (self.entries.items, 0..) |entry, index| {
140 if (std.mem.eql(u8, entry.id, id)) return index;
141 }
142 return null;
143 }
144
145 /// The shortcut a pressed hotkey belongs to, or null if the id is stale —
146 /// which can happen for a key released between the press and its delivery.
147 pub fn findByHotkeyId(self: *Self, hotkey_id: u32) ?*Registration {
148 for (self.entries.items) |*entry| {
149 if (entry.hotkey_id == hotkey_id) return entry;
150 }
151 return null;
152 }
153
154 /// Register the shortcut described by `data`, replacing any shortcut
155 /// already using the same id.
156 ///
157 /// Returns the registration on success. The errors are all things the app
158 /// needs to hear about, and every one of them used to be a silent success.
159 pub fn register(self: *Self, data: []const u8) BridgeError!*Registration {
160 const parsed = std.json.parseFromSlice(RegisterPayload, self.allocator, data, .{
161 .ignore_unknown_fields = true,
162 .allocate = .alloc_always,
163 }) catch return BridgeError.InvalidJSON;
164 defer parsed.deinit();
165
166 const payload = parsed.value;
167 if (payload.id.len == 0) return BridgeError.MissingData;
168
169 const binding = try bindingFor(payload);
170
171 var rendered: [96]u8 = undefined;
172 const canonical = accel.format(&rendered, binding) catch return BridgeError.InvalidParameter;
173
174 // Registering over an existing id replaces it, and the old entry goes
175 // first — reservation included. Two reasons, both load-bearing:
176 // re-registering the *same* accelerator would otherwise fail against
177 // craft's own outstanding reservation, and a reservation dropped on
178 // the floor keeps that key dead system-wide for the life of the
179 // process with nothing left pointing at it to release it.
180 if (self.findIndex(payload.id)) |index| self.forget(index);
181
182 const id_owned = self.allocator.dupe(u8, payload.id) catch return BridgeError.AllocationFailed;
183 errdefer self.allocator.free(id_owned);
184 const accel_owned = self.allocator.dupe(u8, canonical) catch return BridgeError.AllocationFailed;
185 errdefer self.allocator.free(accel_owned);
186
187 const hotkey_id = self.next_hotkey_id;
188 const ref = self.platform.register(binding.keycode, binding.modifiers.toCarbonFlags(), hotkey_id);
189 // A shortcut nobody can trigger is worse than a failed registration:
190 // the app believes it has a hotkey and waits forever.
191 if (ref == null) return BridgeError.NativeCallFailed;
192 self.next_hotkey_id += 1;
193 errdefer self.platform.unregister(ref);
194
195 self.entries.append(self.allocator, .{
196 .id = id_owned,
197 .accelerator = accel_owned,
198 .binding = binding,
199 .hotkey_id = hotkey_id,
200 .ref = ref,
201 }) catch return BridgeError.AllocationFailed;
202 return &self.entries.items[self.entries.items.len - 1];
203 }
204
205 /// Release the entry's reservation and drop it from the registry.
206 fn forget(self: *Self, index: usize) void {
207 const entry = &self.entries.items[index];
208 self.release(entry);
209 self.allocator.free(entry.id);
210 self.allocator.free(entry.accelerator);
211 _ = self.entries.orderedRemove(index);
212 }
213
214 /// Release the system reservation, leaving the entry in place. Idempotent.
215 fn release(self: *Self, entry: *Registration) void {
216 if (entry.ref == null) return;
217 self.platform.unregister(entry.ref);
218 entry.ref = null;
219 }
220
221 pub fn unregister(self: *Self, data: []const u8) BridgeError!void {
222 const id = try self.idFrom(data);
223 defer self.allocator.free(id);
224
225 const index = self.findIndex(id) orelse return BridgeError.NotFound;
226 self.forget(index);
227 }
228
229 pub fn unregisterAll(self: *Self) void {
230 for (self.entries.items) |*entry| {
231 self.release(entry);
232 self.allocator.free(entry.id);
233 self.allocator.free(entry.accelerator);
234 }
235 self.entries.clearRetainingCapacity();
236 }
237
238 /// Enable or disable a shortcut.
239 ///
240 /// Disabling gives the key back to the rest of the system rather than
241 /// holding the reservation and dropping the event: a disabled shortcut
242 /// that still swallowed its key would make that combination dead in every
243 /// other app for as long as craft ran.
244 pub fn setEnabled(self: *Self, data: []const u8, enabled: bool) BridgeError!void {
245 const id = try self.idFrom(data);
246 defer self.allocator.free(id);
247
248 const entry = self.find(id) orelse return BridgeError.NotFound;
249 if (entry.enabled == enabled) return;
250 entry.enabled = enabled;
251
252 if (!enabled) {
253 self.release(entry);
254 return;
255 }
256
257 const ref = self.platform.register(
258 entry.binding.keycode,
259 entry.binding.modifiers.toCarbonFlags(),
260 entry.hotkey_id,
261 );
262 if (ref == null) {
263 // Someone else claimed the key while it was released. The app
264 // asked for it back and cannot have it.
265 entry.enabled = false;
266 return BridgeError.NativeCallFailed;
267 }
268 entry.ref = ref;
269 }
270
271 pub fn isRegistered(self: *Self, data: []const u8) BridgeError!bool {
272 const id = try self.idFrom(data);
273 defer self.allocator.free(id);
274 return self.find(id) != null;
275 }
276
277 /// `{"value":true}` — the shape `_req('shortcuts','isRegistered')` reads.
278 pub fn isRegisteredJson(self: *Self, data: []const u8) BridgeError![]u8 {
279 const registered = try self.isRegistered(data);
280 return std.fmt.allocPrint(self.allocator, "", .{registered}) catch
281 BridgeError.AllocationFailed;
282 }
283
284 /// `{"shortcuts":[…]}` — the shape `_req('shortcuts','list')` reads.
285 ///
286 /// Caller frees.
287 pub fn listJson(self: *Self) BridgeError![]u8 {
288 var out: std.ArrayListUnmanaged(u8) = .empty;
289 errdefer out.deinit(self.allocator);
290
291 out.appendSlice(self.allocator, "{\"shortcuts\":[") catch return BridgeError.AllocationFailed;
292 for (self.entries.items, 0..) |entry, index| {
293 if (index > 0) out.append(self.allocator, ',') catch return BridgeError.AllocationFailed;
294
295 // Ids come from app code and can contain anything. Interpolating
296 // one unescaped produces either broken JSON or an injection.
297 const id_escaped = try escapeId(self.allocator, entry.id);
298 defer self.allocator.free(id_escaped);
299
300 // `accelerator` and `key` need no escaping: both are craft's own
301 // canonical spellings, out of a fixed table, never app input.
302 const item = std.fmt.allocPrint(
303 self.allocator,
304 "",
305 .{ id_escaped, entry.accelerator, entry.binding.key, entry.enabled },
306 ) catch return BridgeError.AllocationFailed;
307 defer self.allocator.free(item);
308 out.appendSlice(self.allocator, item) catch return BridgeError.AllocationFailed;
309 }
310 out.appendSlice(self.allocator, "]}") catch return BridgeError.AllocationFailed;
311
312 return out.toOwnedSlice(self.allocator) catch BridgeError.AllocationFailed;
313 }
314
315 /// Copy the `id` out of a `{"id":"…"}` payload. Copied because the JSON
316 /// arena it was parsed into does not outlive the call.
317 fn idFrom(self: *Self, data: []const u8) BridgeError![]u8 {
318 const parsed = std.json.parseFromSlice(IdPayload, self.allocator, data, .{
319 .ignore_unknown_fields = true,
320 .allocate = .alloc_always,
321 }) catch return BridgeError.InvalidJSON;
322 defer parsed.deinit();
323
324 if (parsed.value.id.len == 0) return BridgeError.MissingData;
325 return self.allocator.dupe(u8, parsed.value.id) catch BridgeError.AllocationFailed;
326 }
327};
328
329/// The `detail` of the event a triggered shortcut is delivered as.
330///
331/// Built as JSON and embedded straight into the script: JSON object syntax is
332/// a subset of JavaScript's, so escaping once as JSON escapes it for the
333/// script too. That matters because shortcut ids come from app code, and an
334/// unescaped quote in one is an injection into every craft window.
335pub fn triggeredDetail(allocator: std.mem.Allocator, id: []const u8, accelerator: []const u8) BridgeError![]u8 {
336 const escaped = try escapeId(allocator, id);
337 defer allocator.free(escaped);
338 // `accelerator` is craft's own canonical spelling, out of a fixed table,
339 // and never app input.
340 return std.fmt.allocPrint(allocator, "", .{ escaped, accelerator }) catch
341 BridgeError.AllocationFailed;
342}
343
344/// The `detail` of the event a refused registration is reported as.
345pub fn errorDetail(allocator: std.mem.Allocator, id: []const u8, code: []const u8, message: []const u8) BridgeError![]u8 {
346 const escaped = try escapeId(allocator, id);
347 defer allocator.free(escaped);
348 // `code` and `message` are compile-time constants from `bridge_error`.
349 return std.fmt.allocPrint(allocator, "", .{ escaped, code, message }) catch
350 BridgeError.AllocationFailed;
351}
352
353/// Wrap a detail object in the `dispatchEvent` call that delivers it.
354pub fn eventScript(allocator: std.mem.Allocator, name: []const u8, detail_json: []const u8) BridgeError![]u8 {
355 return std.fmt.allocPrint(
356 allocator,
357 "if(window.dispatchEvent)window.dispatchEvent(new CustomEvent('{s}',));",
358 .{ name, detail_json },
359 ) catch BridgeError.AllocationFailed;
360}
361
362/// Six bytes is the worst case per input byte (`\u001f`), plus a terminator.
363fn escapeId(allocator: std.mem.Allocator, id: []const u8) BridgeError![]u8 {
364 const buf = allocator.alloc(u8, id.len * 6 + 1) catch return BridgeError.AllocationFailed;
365 errdefer allocator.free(buf);
366 const escaped = bridge_error.escapeJsonString(buf, id) catch return BridgeError.InvalidParameter;
367 return allocator.realloc(buf, escaped.len) catch buf[0..escaped.len];
368}
369
370/// Resolve whichever of the two accepted payload shapes arrived into a binding.
371fn bindingFor(payload: RegisterPayload) BridgeError!accel.Binding {
372 if (payload.accelerator) |text| {
373 return accel.parse(text) catch |err| return translate(err);
374 }
375
376 const key = payload.key orelse return BridgeError.MissingData;
377 const mods = payload.modifiers orelse RegisterPayload.PayloadModifiers{};
378 const resolved = accel.Modifiers{
379 .cmd = mods.cmd or mods.meta,
380 .ctrl = mods.ctrl,
381 .alt = mods.alt,
382 .shift = mods.shift,
383 };
384 const keycode = key_codes.codeFor(key) orelse return BridgeError.InvalidParameter;
385 const canonical = key_codes.nameFor(keycode).?;
386
387 // The same guard `accel.parse` applies, for the same reason: a global
388 // hotkey with no commanding modifier takes the key away system-wide.
389 var probe: [96]u8 = undefined;
390 const rendered = accel.format(&probe, .{
391 .key = canonical,
392 .keycode = keycode,
393 .modifiers = resolved,
394 }) catch return BridgeError.InvalidParameter;
395 return accel.parse(rendered) catch |err| return translate(err);
396}
397
398fn translate(err: accel.ParseError) BridgeError {
399 return switch (err) {
400 error.MissingKey, error.EmptyComponent => BridgeError.MissingData,
401 error.UnknownKey, error.UnknownModifier, error.DuplicateModifier => BridgeError.InvalidParameter,
402 error.ModifierRequired => BridgeError.InvalidParameter,
403 };
404}
405
406// =============================================================================
407// Tests
408// =============================================================================
409
410/// A platform that hands out increasing fake refs and records every call, so
411/// tests can assert on what the system was actually asked to reserve.
412const FakePlatform = struct {
413 var registrations: usize = 0;
414 var releases: usize = 0;
415 var last_keycode: u16 = 0;
416 var last_modifiers: u32 = 0;
417 var next_ref: usize = 0;
418 /// When set, `register` refuses — standing in for a key another app owns.
419 var refuse: bool = false;
420
421 fn reset() void {
422 registrations = 0;
423 releases = 0;
424 last_keycode = 0;
425 last_modifiers = 0;
426 next_ref = 0;
427 refuse = false;
428 }
429
430 fn register(keycode: u16, modifiers: u32, _: u32) Ref {
431 if (refuse) return null;
432 registrations += 1;
433 last_keycode = keycode;
434 last_modifiers = modifiers;
435 next_ref += 1;
436 return @ptrFromInt(next_ref);
437 }
438
439 fn unregister(ref: Ref) void {
440 if (ref != null) releases += 1;
441 }
442
443 fn platform() Platform {
444 return .{ .register = register, .unregister = unregister };
445 }
446};
447
448fn testRegistry() Registry {
449 FakePlatform.reset();
450 return Registry.init(std.testing.allocator, FakePlatform.platform());
451}
452
453test "registering reserves the key the accelerator names" {
454 var registry = testRegistry();
455 defer registry.deinit();
456
457 const entry = try registry.register(
458 \\{"id":"harness.summon","accelerator":"Cmd+Shift+H"}
459 );
460 try std.testing.expectEqualStrings("harness.summon", entry.id);
461 try std.testing.expectEqualStrings("Cmd+Shift+H", entry.accelerator);
462 try std.testing.expectEqual(@as(usize, 1), FakePlatform.registrations);
463 try std.testing.expectEqual(@as(u16, 0x04), FakePlatform.last_keycode);
464 try std.testing.expectEqual(@as(u32, 0x0100 | 0x0200), FakePlatform.last_modifiers);
465 try std.testing.expect(entry.ref != null);
466}
467
468test "a key the system will not give up is reported, not accepted" {
469 var registry = testRegistry();
470 defer registry.deinit();
471
472 FakePlatform.refuse = true;
473 try std.testing.expectError(
474 BridgeError.NativeCallFailed,
475 registry.register(
476 \\{"id":"spotlight","accelerator":"Cmd+Space"}
477 ),
478 );
479 // Nothing is left behind claiming to be registered.
480 try std.testing.expectEqual(@as(usize, 0), registry.count());
481 try std.testing.expect(!try registry.isRegistered(
482 \\{"id":"spotlight"}
483 ));
484}
485
486test "re-registering an id releases the key it used to hold" {
487 var registry = testRegistry();
488 defer registry.deinit();
489
490 _ = try registry.register(
491 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
492 );
493 _ = try registry.register(
494 \\{"id":"toggle","accelerator":"Cmd+Shift+J"}
495 );
496
497 try std.testing.expectEqual(@as(usize, 1), registry.count());
498 try std.testing.expectEqual(@as(usize, 2), FakePlatform.registrations);
499 // The old reservation must be handed back. Leaking it would keep
500 // Cmd+Shift+H dead system-wide with nothing left to release it.
501 try std.testing.expectEqual(@as(usize, 1), FakePlatform.releases);
502 try std.testing.expectEqualStrings("Cmd+Shift+J", registry.find("toggle").?.accelerator);
503}
504
505test "disabling gives the key back to the rest of the system" {
506 var registry = testRegistry();
507 defer registry.deinit();
508
509 _ = try registry.register(
510 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
511 );
512 try registry.setEnabled(
513 \\{"id":"toggle"}
514 , false);
515
516 try std.testing.expectEqual(@as(usize, 1), FakePlatform.releases);
517 try std.testing.expect(registry.find("toggle").?.ref == null);
518 try std.testing.expect(!registry.find("toggle").?.enabled);
519 // Still listed: disabled is not unregistered.
520 try std.testing.expect(try registry.isRegistered(
521 \\{"id":"toggle"}
522 ));
523}
524
525test "re-enabling takes the key back" {
526 var registry = testRegistry();
527 defer registry.deinit();
528
529 _ = try registry.register(
530 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
531 );
532 try registry.setEnabled(
533 \\{"id":"toggle"}
534 , false);
535 try registry.setEnabled(
536 \\{"id":"toggle"}
537 , true);
538
539 try std.testing.expectEqual(@as(usize, 2), FakePlatform.registrations);
540 try std.testing.expect(registry.find("toggle").?.ref != null);
541 try std.testing.expect(registry.find("toggle").?.enabled);
542}
543
544test "re-enabling a key someone else took in the meantime fails loudly" {
545 var registry = testRegistry();
546 defer registry.deinit();
547
548 _ = try registry.register(
549 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
550 );
551 try registry.setEnabled(
552 \\{"id":"toggle"}
553 , false);
554
555 FakePlatform.refuse = true;
556 try std.testing.expectError(BridgeError.NativeCallFailed, registry.setEnabled(
557 \\{"id":"toggle"}
558 , true));
559 // And it stays disabled rather than claiming to be on with no reservation.
560 try std.testing.expect(!registry.find("toggle").?.enabled);
561 try std.testing.expect(registry.find("toggle").?.ref == null);
562}
563
564test "enabling something already enabled does not double-reserve" {
565 var registry = testRegistry();
566 defer registry.deinit();
567
568 _ = try registry.register(
569 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
570 );
571 try registry.setEnabled(
572 \\{"id":"toggle"}
573 , true);
574 try std.testing.expectEqual(@as(usize, 1), FakePlatform.registrations);
575}
576
577test "unregistering releases the key and forgets the shortcut" {
578 var registry = testRegistry();
579 defer registry.deinit();
580
581 _ = try registry.register(
582 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
583 );
584 try registry.unregister(
585 \\{"id":"toggle"}
586 );
587 try std.testing.expectEqual(@as(usize, 0), registry.count());
588 try std.testing.expectEqual(@as(usize, 1), FakePlatform.releases);
589 try std.testing.expectError(BridgeError.NotFound, registry.unregister(
590 \\{"id":"toggle"}
591 ));
592}
593
594test "unregisterAll releases every reservation" {
595 var registry = testRegistry();
596 defer registry.deinit();
597
598 _ = try registry.register(
599 \\{"id":"a","accelerator":"Cmd+Shift+A"}
600 );
601 _ = try registry.register(
602 \\{"id":"b","accelerator":"Cmd+Shift+B"}
603 );
604 _ = try registry.register(
605 \\{"id":"c","accelerator":"Cmd+Shift+C"}
606 );
607 registry.unregisterAll();
608
609 try std.testing.expectEqual(@as(usize, 0), registry.count());
610 try std.testing.expectEqual(@as(usize, 3), FakePlatform.releases);
611}
612
613test "a disabled shortcut is released once, not twice" {
614 // Double-releasing an already-freed EventHotKeyRef is a use-after-free
615 // against Carbon's own table.
616 var registry = testRegistry();
617 defer registry.deinit();
618
619 _ = try registry.register(
620 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
621 );
622 try registry.setEnabled(
623 \\{"id":"toggle"}
624 , false);
625 registry.unregisterAll();
626 try std.testing.expectEqual(@as(usize, 1), FakePlatform.releases);
627}
628
629test "each shortcut gets its own hotkey id" {
630 var registry = testRegistry();
631 defer registry.deinit();
632
633 const a = try registry.register(
634 \\{"id":"a","accelerator":"Cmd+Shift+A"}
635 );
636 const first_id = a.hotkey_id;
637 const b = try registry.register(
638 \\{"id":"b","accelerator":"Cmd+Shift+B"}
639 );
640 try std.testing.expect(first_id != b.hotkey_id);
641 try std.testing.expectEqualStrings("b", registry.findByHotkeyId(b.hotkey_id).?.id);
642 try std.testing.expect(registry.findByHotkeyId(9999) == null);
643}
644
645test "a pressed hotkey still resolves after other shortcuts are removed" {
646 // `findByHotkeyId` used to be an index into a list; removing an earlier
647 // entry would then deliver the wrong shortcut's callback.
648 var registry = testRegistry();
649 defer registry.deinit();
650
651 _ = try registry.register(
652 \\{"id":"first","accelerator":"Cmd+Shift+A"}
653 );
654 const second = try registry.register(
655 \\{"id":"second","accelerator":"Cmd+Shift+B"}
656 );
657 const second_hotkey = second.hotkey_id;
658
659 try registry.unregister(
660 \\{"id":"first"}
661 );
662 try std.testing.expectEqualStrings("second", registry.findByHotkeyId(second_hotkey).?.id);
663}
664
665test "the legacy key + modifiers payload still registers" {
666 var registry = testRegistry();
667 defer registry.deinit();
668
669 const entry = try registry.register(
670 \\{"id":"toggle","key":"Space","modifiers":{"cmd":true,"shift":true},"callback":"onToggle"}
671 );
672 try std.testing.expectEqualStrings("Cmd+Shift+Space", entry.accelerator);
673 try std.testing.expectEqual(key_codes.codeFor("Space").?, FakePlatform.last_keycode);
674}
675
676test "metaKey is command" {
677 var registry = testRegistry();
678 defer registry.deinit();
679
680 const entry = try registry.register(
681 \\{"id":"toggle","key":"K","modifiers":{"meta":true}}
682 );
683 try std.testing.expectEqualStrings("Cmd+K", entry.accelerator);
684}
685
686test "the legacy shape gets the same bare-key guard" {
687 var registry = testRegistry();
688 defer registry.deinit();
689
690 try std.testing.expectError(BridgeError.InvalidParameter, registry.register(
691 \\{"id":"toggle","key":"H"}
692 ));
693 try std.testing.expectEqual(@as(usize, 0), FakePlatform.registrations);
694}
695
696test "bad payloads are refused without reserving anything" {
697 var registry = testRegistry();
698 defer registry.deinit();
699
700 try std.testing.expectError(BridgeError.InvalidJSON, registry.register("not json"));
701 try std.testing.expectError(BridgeError.MissingData, registry.register(
702 \\{"accelerator":"Cmd+Shift+H"}
703 ));
704 try std.testing.expectError(BridgeError.MissingData, registry.register(
705 \\{"id":"x"}
706 ));
707 try std.testing.expectError(BridgeError.InvalidParameter, registry.register(
708 \\{"id":"x","accelerator":"Cmd+Nonsense"}
709 ));
710 try std.testing.expectError(BridgeError.InvalidParameter, registry.register(
711 \\{"id":"x","accelerator":"H"}
712 ));
713 try std.testing.expectEqual(@as(usize, 0), FakePlatform.registrations);
714 try std.testing.expectEqual(@as(usize, 0), registry.count());
715}
716
717test "actions naming an unknown id say so" {
718 var registry = testRegistry();
719 defer registry.deinit();
720
721 try std.testing.expectError(BridgeError.NotFound, registry.unregister(
722 \\{"id":"nope"}
723 ));
724 try std.testing.expectError(BridgeError.NotFound, registry.setEnabled(
725 \\{"id":"nope"}
726 , false));
727 try std.testing.expectError(BridgeError.MissingData, registry.unregister(
728 \\{}
729 ));
730}
731
732test "isRegistered answers in the shape the JS bridge reads" {
733 var registry = testRegistry();
734 defer registry.deinit();
735
736 _ = try registry.register(
737 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
738 );
739
740 const yes = try registry.isRegisteredJson(
741 \\{"id":"toggle"}
742 );
743 defer std.testing.allocator.free(yes);
744 try std.testing.expectEqualStrings("{\"value\":true}", yes);
745
746 const no = try registry.isRegisteredJson(
747 \\{"id":"other"}
748 );
749 defer std.testing.allocator.free(no);
750 try std.testing.expectEqualStrings("{\"value\":false}", no);
751}
752
753test "list answers in the shape the JS bridge reads" {
754 var registry = testRegistry();
755 defer registry.deinit();
756
757 const empty = try registry.listJson();
758 defer std.testing.allocator.free(empty);
759 try std.testing.expectEqualStrings("{\"shortcuts\":[]}", empty);
760
761 _ = try registry.register(
762 \\{"id":"toggle","accelerator":"cmd+shift+h"}
763 );
764 _ = try registry.register(
765 \\{"id":"quit","accelerator":"Ctrl+Alt+Backspace"}
766 );
767
768 const json = try registry.listJson();
769 defer std.testing.allocator.free(json);
770 try std.testing.expectEqualStrings(
771 "{\"shortcuts\":[" ++
772 "{\"id\":\"toggle\",\"accelerator\":\"Cmd+Shift+H\",\"key\":\"H\",\"enabled\":true}," ++
773 "{\"id\":\"quit\",\"accelerator\":\"Ctrl+Alt+Delete\",\"key\":\"Delete\",\"enabled\":true}" ++
774 "]}",
775 json,
776 );
777}
778
779test "an id full of quotes cannot break the reply out of its JSON" {
780 var registry = testRegistry();
781 defer registry.deinit();
782
783 _ = try registry.register(
784 \\{"id":"ev\"il\\","accelerator":"Cmd+Shift+H"}
785 );
786
787 const json = try registry.listJson();
788 defer std.testing.allocator.free(json);
789 // Re-parsing is the assertion: if the escaping were wrong this would fail
790 // or would silently yield a different id.
791 const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{});
792 defer parsed.deinit();
793 const list = parsed.value.object.get("shortcuts").?.array;
794 try std.testing.expectEqual(@as(usize, 1), list.items.len);
795 try std.testing.expectEqualStrings("ev\"il\\", list.items[0].object.get("id").?.string);
796}
797
798test "a disabled shortcut says so in the listing" {
799 var registry = testRegistry();
800 defer registry.deinit();
801
802 _ = try registry.register(
803 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
804 );
805 try registry.setEnabled(
806 \\{"id":"toggle"}
807 , false);
808
809 const json = try registry.listJson();
810 defer std.testing.allocator.free(json);
811 try std.testing.expect(std.mem.indexOf(u8, json, "\"enabled\":false") != null);
812}
813
814test "the triggered event carries the id and the canonical accelerator" {
815 const detail = try triggeredDetail(std.testing.allocator, "harness.summon", "Cmd+Shift+H");
816 defer std.testing.allocator.free(detail);
817 try std.testing.expectEqualStrings(
818 "{\"id\":\"harness.summon\",\"accelerator\":\"Cmd+Shift+H\"}",
819 detail,
820 );
821
822 const script = try eventScript(std.testing.allocator, "craft:shortcut", detail);
823 defer std.testing.allocator.free(script);
824 try std.testing.expectEqualStrings(
825 "if(window.dispatchEvent)window.dispatchEvent(new CustomEvent('craft:shortcut',{detail:{\"id\":\"harness.summon\",\"accelerator\":\"Cmd+Shift+H\"}}));",
826 script,
827 );
828}
829
830test "an id cannot break out of the event it is delivered in" {
831 // `'); doSomething(); //` in an id would otherwise run in every window.
832 const detail = try triggeredDetail(std.testing.allocator, "a'); alert(1); //", "Cmd+Shift+H");
833 defer std.testing.allocator.free(detail);
834 try std.testing.expect(std.mem.indexOf(u8, detail, "alert(1)") != null);
835
836 const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, detail, .{});
837 defer parsed.deinit();
838 try std.testing.expectEqualStrings("a'); alert(1); //", parsed.value.object.get("id").?.string);
839
840 const script = try eventScript(std.testing.allocator, "craft:shortcut", detail);
841 defer std.testing.allocator.free(script);
842 // The single quotes that would have closed the event-name literal are
843 // still literal quotes inside a JSON string, not syntax.
844 try std.testing.expectEqualStrings(
845 "if(window.dispatchEvent)window.dispatchEvent(new CustomEvent('craft:shortcut',{detail:" ++
846 "{\"id\":\"a'); alert(1); //\",\"accelerator\":\"Cmd+Shift+H\"}}));",
847 script,
848 );
849}
850
851test "a quote in an id is escaped for the event, not just for the listing" {
852 const detail = try triggeredDetail(std.testing.allocator, "ev\"il", "Cmd+K");
853 defer std.testing.allocator.free(detail);
854 const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, detail, .{});
855 defer parsed.deinit();
856 try std.testing.expectEqualStrings("ev\"il", parsed.value.object.get("id").?.string);
857}
858
859test "a refused registration names the shortcut it refused" {
860 const detail = try errorDetail(std.testing.allocator, "toggle", "NATIVE_CALL_FAILED", "Native API call failed");
861 defer std.testing.allocator.free(detail);
862 const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, detail, .{});
863 defer parsed.deinit();
864 try std.testing.expectEqualStrings("toggle", parsed.value.object.get("id").?.string);
865 try std.testing.expectEqualStrings("NATIVE_CALL_FAILED", parsed.value.object.get("code").?.string);
866}
867
868test "the unsupported platform refuses every registration" {
869 // What a build with no global-hotkey implementation gets: an honest error
870 // per call, rather than a shortcut that registers and never fires.
871 var registry = Registry.init(std.testing.allocator, unsupported_platform);
872 defer registry.deinit();
873
874 try std.testing.expectError(BridgeError.NativeCallFailed, registry.register(
875 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
876 ));
877 try std.testing.expectEqual(@as(usize, 0), registry.count());
878}
packages/zig/test/injected_js_test.zigmodified+254-4
Changes to packages/zig/test/injected_js_test.zig
@@ -16,7 +16,9 @@
1616const std = ;
1717const js = ;
1818const testing = std.testing;
19const bridge_menu = ;
19const contracts = ;
20const bridge_menu = contracts.menu;
21const shortcut_registry = contracts.shortcuts;
2022
2123// Supplied by build.zig as named imports — a test module cannot embed
2224// files outside its own package path.
@@ -190,15 +192,36 @@ test "the bridge script parses" {
190192///
191193/// Everything native would do with a posted message begins with these bytes,
192194/// so capturing them is capturing the whole JS half of the contract.
195/// `addEventListener` and `dispatchEvent` are real rather than stubs, because
196/// half of what native sends the page arrives as an event — and a listener
197/// registered for a name nothing dispatches is exactly the failure being
198/// tested for.
193199const WEBVIEW_HOST =
194200 \\var posted = [];
195201 \\window.webkit = { messageHandlers: { craft: { postMessage: function (m) { posted.push(m) } } } };
196 \\window.addEventListener = function () {};
197 \\window.removeEventListener = function () {};
198 \\window.dispatchEvent = function () { return true };
202 \\var listeners = {};
203 \\window.addEventListener = function (name, fn) { (listeners[name] = listeners[name] || []).push(fn) };
204 \\window.removeEventListener = function (name, fn) {
205 \\ var q = listeners[name] || [];
206 \\ var i = q.indexOf(fn);
207 \\ if (i >= 0) q.splice(i, 1);
208 \\};
209 \\window.dispatchEvent = function (e) {
210 \\ (listeners[e.type] || []).slice().forEach(function (fn) { fn(e) });
211 \\ return true;
212 \\};
199213 \\function CustomEvent(type, init) { this.type = type; this.detail = init && init.detail }
200214 \\var document = { readyState: 'complete', addEventListener: function () {} };
215 \\globalThis.setTimeout = function () { return 0 };
216 \\globalThis.clearTimeout = function () {};
201217;
218// The timer stubs are load-bearing rather than cosmetic. `_req` arms a
219// 30-second reaping timeout inside its Promise executor, so without a
220// `setTimeout` the executor throws a ReferenceError and every request-shaped
221// call rejects before it has posted anything — which is not a failure mode
222// worth reproducing in a test, and is invisible if you only ever exercise the
223// fire-and-forget half of the bridge. Stubs that never fire keep it
224// deterministic: these tests resolve their own calls explicitly.
202225
203226test "craft.menu.set posts what bridge_menu.zig's parser reads" {
204227 // The one path that matters, end to end and without a window: the real
@@ -277,3 +300,230 @@ test "an empty menu bar is a parse, not a failure" {
277300
278301 try testing.expect(parsed.value.menus == null);
279302}
303
304// =============================================================================
305// Global shortcuts (#47)
306// =============================================================================
307
308/// A registry over a platform that grants every key, so these tests are about
309/// the payload contract rather than about what the machine will hand over.
310const GrantingPlatform = struct {
311 var granted: usize = 0;
312
313 fn register(_: u16, _: u32, _: u32) shortcut_registry.Ref {
314 granted += 1;
315 return @ptrFromInt(granted);
316 }
317 fn unregister(_: shortcut_registry.Ref) void {}
318};
319
320fn grantingRegistry() shortcut_registry.Registry {
321 GrantingPlatform.granted = 0;
322 return shortcut_registry.Registry.init(testing.allocator, .{
323 .register = GrantingPlatform.register,
324 .unregister = GrantingPlatform.unregister,
325 });
326}
327
328test "craft.shortcuts.register posts what the native registry reads" {
329 // The mismatch that made #47 unfixable-looking: the JS side has always
330 // posted `{id, accelerator}` and the native side has always read `key` and
331 // `modifiers`, so every registration failed its own validation long before
332 // reaching the event monitor that was also never installed. Neither half
333 // was wrong on its own terms, and neither half's tests could see it.
334 var fx = try Fixture.init();
335 defer fx.deinit();
336 const ctx = fx.ctx;
337
338 _ = try ctx.evaluate(WEBVIEW_HOST);
339 _ = try ctx.evaluate(BRIDGE);
340 _ = try ctx.evaluate("window.craft.shortcuts.register('harness.summon', 'Cmd+Shift+H');");
341
342 try testing.expectEqualStrings("1", try fx.text("String(posted.length)"));
343 try testing.expectEqualStrings("shortcuts", try fx.text("posted[0].t"));
344 try testing.expectEqualStrings(shortcut_registry.action_register, try fx.text("posted[0].a"));
345
346 var registry = grantingRegistry();
347 defer registry.deinit();
348
349 const entry = try registry.register(try fx.text("posted[0].d"));
350 try testing.expectEqualStrings("harness.summon", entry.id);
351 try testing.expectEqualStrings("Cmd+Shift+H", entry.accelerator);
352 try testing.expectEqual(@as(u16, 0x04), entry.binding.keycode); // kVK_ANSI_H
353 try testing.expect(entry.binding.modifiers.cmd);
354 try testing.expect(entry.binding.modifiers.shift);
355}
356
357test "unregister, enable and disable post the id the registry looks up" {
358 var fx = try Fixture.init();
359 defer fx.deinit();
360 const ctx = fx.ctx;
361
362 _ = try ctx.evaluate(WEBVIEW_HOST);
363 _ = try ctx.evaluate(BRIDGE);
364 _ = try ctx.evaluate(
365 \\window.craft.shortcuts.register('toggle', 'Cmd+Shift+H');
366 \\window.craft.shortcuts.disable('toggle');
367 \\window.craft.shortcuts.enable('toggle');
368 \\window.craft.shortcuts.unregister('toggle');
369 );
370
371 var registry = grantingRegistry();
372 defer registry.deinit();
373
374 _ = try registry.register(try fx.text("posted[0].d"));
375 try testing.expectEqualStrings(shortcut_registry.action_disable, try fx.text("posted[1].a"));
376 try registry.setEnabled(try fx.text("posted[1].d"), false);
377 try testing.expectEqualStrings(shortcut_registry.action_enable, try fx.text("posted[2].a"));
378 try registry.setEnabled(try fx.text("posted[2].d"), true);
379 try testing.expectEqualStrings(shortcut_registry.action_unregister, try fx.text("posted[3].a"));
380 try registry.unregister(try fx.text("posted[3].d"));
381
382 try testing.expectEqual(@as(usize, 0), registry.count());
383}
384
385test "the reply to list is the shape the JS facade unwraps" {
386 // `list()` resolves through `__craftBridgeResult`, and native used to
387 // answer by calling `window.__craftShortcutList` — which nothing defines.
388 // The promise hung for the full 30-second request timeout and rejected.
389 var fx = try Fixture.init();
390 defer fx.deinit();
391 const ctx = fx.ctx;
392
393 _ = try ctx.evaluate(WEBVIEW_HOST);
394 _ = try ctx.evaluate(BRIDGE);
395 _ = try ctx.evaluate(
396 \\var listed = null;
397 \\window.craft.shortcuts.list().then(function (s) { listed = s });
398 );
399
400 var registry = grantingRegistry();
401 defer registry.deinit();
402 _ = try registry.register(
403 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
404 );
405
406 const reply = try registry.listJson();
407 defer testing.allocator.free(reply);
408
409 // Exactly what `sendResultToJS` evaluates in the page.
410 const script = try std.fmt.allocPrint(
411 testing.allocator,
412 "window.__craftBridgeResult('{s}',{s});",
413 .{ shortcut_registry.action_list, reply },
414 );
415 defer testing.allocator.free(script);
416 _ = try ctx.evaluate(script);
417
418 try testing.expectEqualStrings("1", try fx.text("String(listed.length)"));
419 try testing.expectEqualStrings("toggle", try fx.text("listed[0].id"));
420 try testing.expectEqualStrings("Cmd+Shift+H", try fx.text("listed[0].accelerator"));
421 try testing.expectEqualStrings("true", try fx.text("String(listed[0].enabled)"));
422}
423
424test "the reply to isRegistered is the shape the JS facade unwraps" {
425 var fx = try Fixture.init();
426 defer fx.deinit();
427 const ctx = fx.ctx;
428
429 _ = try ctx.evaluate(WEBVIEW_HOST);
430 _ = try ctx.evaluate(BRIDGE);
431 _ = try ctx.evaluate(
432 \\var answer = null;
433 \\window.craft.shortcuts.isRegistered('toggle').then(function (v) { answer = v });
434 );
435
436 var registry = grantingRegistry();
437 defer registry.deinit();
438 _ = try registry.register(
439 \\{"id":"toggle","accelerator":"Cmd+Shift+H"}
440 );
441
442 const reply = try registry.isRegisteredJson(
443 \\{"id":"toggle"}
444 );
445 defer testing.allocator.free(reply);
446
447 const script = try std.fmt.allocPrint(
448 testing.allocator,
449 "window.__craftBridgeResult('{s}',{s});",
450 .{ shortcut_registry.action_is_registered, reply },
451 );
452 defer testing.allocator.free(script);
453 _ = try ctx.evaluate(script);
454
455 try testing.expectEqualStrings("true", try fx.text("String(answer)"));
456}
457
458test "a triggered shortcut reaches the listener craft.shortcuts.on installs" {
459 // The third mismatch: native called `window.__craftShortcutCallback`,
460 // which nothing defines, while the JS side listened for `craft:shortcut`,
461 // which nothing dispatched. Both sides looked implemented.
462 var fx = try Fixture.init();
463 defer fx.deinit();
464 const ctx = fx.ctx;
465
466 _ = try ctx.evaluate(WEBVIEW_HOST);
467 _ = try ctx.evaluate(BRIDGE);
468 _ = try ctx.evaluate(
469 \\var fired = [];
470 \\window.craft.shortcuts.on(function (d) { fired.push(d.id + '@' + d.accelerator) });
471 );
472
473 // The exact bytes `deliver` evaluates in the page.
474 const detail = try shortcut_registry.triggeredDetail(testing.allocator, "harness.summon", "Cmd+Shift+H");
475 defer testing.allocator.free(detail);
476 const script = try shortcut_registry.eventScript(testing.allocator, "craft:shortcut", detail);
477 defer testing.allocator.free(script);
478 _ = try ctx.evaluate(script);
479
480 try testing.expectEqualStrings("harness.summon@Cmd+Shift+H", try fx.text("fired.join('|')"));
481}
482
483test "a refused registration reaches craft.shortcuts.onError" {
484 // `register` cannot be a request — its action name collides with other
485 // bridges' in the pending queue — so this event is the only way an app
486 // hears that the key it asked for belongs to someone else.
487 var fx = try Fixture.init();
488 defer fx.deinit();
489 const ctx = fx.ctx;
490
491 _ = try ctx.evaluate(WEBVIEW_HOST);
492 _ = try ctx.evaluate(BRIDGE);
493 _ = try ctx.evaluate(
494 \\var failures = [];
495 \\window.craft.shortcuts.onError(function (d) { failures.push(d.id + ':' + d.code) });
496 );
497
498 const detail = try shortcut_registry.errorDetail(
499 testing.allocator,
500 "harness.summon",
501 "NATIVE_CALL_FAILED",
502 "Native API call failed",
503 );
504 defer testing.allocator.free(detail);
505 const script = try shortcut_registry.eventScript(testing.allocator, "craft:shortcut:error", detail);
506 defer testing.allocator.free(script);
507 _ = try ctx.evaluate(script);
508
509 try testing.expectEqualStrings("harness.summon:NATIVE_CALL_FAILED", try fx.text("failures.join('|')"));
510}
511
512test "an accelerator the native side cannot bind is refused, not accepted" {
513 // A registration that can never fire is the worst of the three outcomes:
514 // the app waits forever for a key that was never reserved.
515 var fx = try Fixture.init();
516 defer fx.deinit();
517 const ctx = fx.ctx;
518
519 _ = try ctx.evaluate(WEBVIEW_HOST);
520 _ = try ctx.evaluate(BRIDGE);
521 _ = try ctx.evaluate("window.craft.shortcuts.register('oops', 'Cmd+Nonsense');");
522
523 var registry = grantingRegistry();
524 defer registry.deinit();
525 try testing.expectError(
526 error.InvalidParameter,
527 registry.register(try fx.text("posted[0].d")),
528 );
529}