also looking at this
fix(shortcuts): make global hotkeys actually fire
#54
11 files
+2435
-417
| @@ -8,6 +8,7 @@ The Craft JavaScript Bridge provides a seamless interface for your web applicati | ||
| 8 | 8 | - [Getting Started](#getting-started) |
| 9 | 9 | - [System Tray API](#system-tray-api) |
| 10 | 10 | - [Application Menu (macOS)](#application-menu-macos) |
| 11 | - [Global Shortcuts (macOS)](#global-shortcuts-macos) | |
| 11 | 12 | - [Window API](#window-api) |
| 12 | 13 | - [App API](#app-api) |
| 13 | 14 | - [TypeScript Support](#typescript-support) |
| @@ -286,6 +287,86 @@ a newer bridge surface therefore fails soft on an older binary. | ||
| 286 | 287 | | `checkItem(itemId)` / `uncheckItem(itemId)` | checkmark | |
| 287 | 288 | | `setItemLabel(itemId, label)` | rename in place | |
| 288 | 289 | |
| 290 | ## Global Shortcuts (macOS) | |
| 291 | ||
| 292 | System-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 | |
| 294 | implementation there and a shortcut that can never fire is worse than one that | |
| 295 | was never accepted. | |
| 296 | ||
| 297 | Craft registers these through Carbon's `RegisterEventHotKey`, which needs no | |
| 298 | Accessibility or Input Monitoring permission and only ever calls back for the | |
| 299 | exact combinations you asked for. Your app never sees any other keystroke. | |
| 300 | ||
| 301 | ### `window.craft.shortcuts.register(id, accelerator): Promise<void>` | |
| 302 | ||
| 303 | Reserve a combination. `id` is yours to choose and is what comes back when the | |
| 304 | key is pressed. Registering an id twice replaces the first binding. | |
| 305 | ||
| 306 | ```javascript | |
| 307 | await window.craft.shortcuts.register('summon', 'Cmd+Shift+H'); | |
| 308 | ||
| 309 | window.craft.shortcuts.on(({ id, accelerator }) => { | |
| 310 | if (id === 'summon') window.craft.window.toggle(); | |
| 311 | }); | |
| 312 | ``` | |
| 313 | ||
| 314 | Accelerators are `+`-separated and case-insensitive. The last component is the | |
| 315 | key; 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 | ||
| 325 | Keys are named by position on the keyboard, not by the character they produce, | |
| 326 | so 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`, …) | |
| 329 | are 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 | |
| 333 | function keys. A bare global hotkey on `H` would mean no application on the | |
| 334 | system — including yours — ever saw the user type an h again; failing the | |
| 335 | registration is recoverable, and that is not. Shift alone does not count, since | |
| 336 | Shift+H is how you type a capital H. | |
| 337 | ||
| 338 | ### `window.craft.shortcuts.onError(handler): () => void` | |
| 339 | ||
| 340 | Where 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 | |
| 342 | the message is posted, not when the key is reserved — so this is how you learn | |
| 343 | that a combination belongs to another app or to the system, or that `enable()` | |
| 344 | could not take a released key back. | |
| 345 | ||
| 346 | ```javascript | |
| 347 | window.craft.shortcuts.onError(({ id, message }) => { | |
| 348 | showToast(`Could not bind ${id}: ${message}`); | |
| 349 | }); | |
| 350 | ``` | |
| 351 | ||
| 352 | ### `window.craft.shortcuts.on(handler): () => void` | |
| 353 | ||
| 354 | Fires on every press of a registered, enabled shortcut, with `{ id, accelerator }`. | |
| 355 | The accelerator is craft's canonical spelling — `Cmd+Delete`, whatever you | |
| 356 | originally wrote — so it round-trips back through `register()`. Returns an | |
| 357 | unsubscribe 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 | ||
| 289 | 370 | ## Window API |
| 290 | 371 | |
| 291 | 372 | Control the application window from JavaScript. |
| @@ -519,6 +600,7 @@ async function onComplete() { | ||
| 519 | 600 | | Tray Menu | �� | �� | �� | |
| 520 | 601 | | Window Control | ✅ | ✅ | ✅ | |
| 521 | 602 | | Hide Dock Icon | ✅ | ➖ | ➖ | |
| 603 | | Global Shortcuts | ✅ | �� | �� | | |
| 522 | 604 | |
| 523 | 605 | ✅ Implemented | �� In Progress | ➖ Not Applicable |
| 524 | 606 | |
| @@ -1119,6 +1119,92 @@ export interface CraftBridgeAPI { | ||
| 1119 | 1119 | * Screen-sharing and screen-recording detection (macOS) |
| 1120 | 1120 | */ |
| 1121 | 1121 | 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. */ | |
| 1134 | export 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. */ | |
| 1150 | export 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 | */ | |
| 1171 | export 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 | |
| 1122 | 1208 | } |
| 1123 | 1209 | |
| 1124 | 1210 | /** |
| @@ -165,10 +165,6 @@ pub fn build(b: *std.Build) void { | ||
| 165 | 165 | .root_source_file = b.path("src/menubar.zig"), |
| 166 | 166 | }); |
| 167 | 167 | |
| 168 | const bridge_menu_module = b.createModule(.{ | |
| 169 | .root_source_file = b.path("src/bridge_menu.zig"), | |
| 170 | }); | |
| 171 | ||
| 172 | 168 | const components_module = b.createModule(.{ |
| 173 | 169 | .root_source_file = b.path("src/components.zig"), |
| 174 | 170 | }); |
| @@ -561,6 +557,54 @@ pub fn build(b: *std.Build) void { | ||
| 561 | 557 | }), |
| 562 | 558 | }); |
| 563 | 559 | |
| 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 | ||
| 564 | 608 | const config_tests = b.addTest(.{ |
| 565 | 609 | .root_module = b.createModule(.{ |
| 566 | 610 | .root_source_file = b.path("test/config_test.zig"), |
| @@ -897,6 +941,10 @@ pub fn build(b: *std.Build) void { | ||
| 897 | 941 | const run_space_list_tests = b.addRunArtifact(space_list_tests); |
| 898 | 942 | const run_local_tls_tests = b.addRunArtifact(local_tls_tests); |
| 899 | 943 | 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); | |
| 900 | 948 | const run_config_tests = b.addRunArtifact(config_tests); |
| 901 | 949 | const run_ipc_tests = b.addRunArtifact(ipc_tests); |
| 902 | 950 | const run_performance_tests = b.addRunArtifact(performance_tests); |
| @@ -945,12 +993,17 @@ pub fn build(b: *std.Build) void { | ||
| 945 | 993 | .optimize = optimize, |
| 946 | 994 | .imports = &.{ |
| 947 | 995 | .{ .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 | }) }, | |
| 954 | 1007 | }, |
| 955 | 1008 | }), |
| 956 | 1009 | }); |
| @@ -998,6 +1051,10 @@ pub fn build(b: *std.Build) void { | ||
| 998 | 1051 | test_step.dependOn(&run_space_list_tests.step); |
| 999 | 1052 | test_step.dependOn(&run_local_tls_tests.step); |
| 1000 | 1053 | 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); | |
| 1001 | 1058 | test_step.dependOn(&run_config_tests.step); |
| 1002 | 1059 | test_step.dependOn(&run_ipc_tests.step); |
| 1003 | 1060 | test_step.dependOn(&run_performance_tests.step); |
| @@ -1097,6 +1154,16 @@ pub fn build(b: *std.Build) void { | ||
| 1097 | 1154 | const test_menu_roles_step = b.step("test:menu-roles", "Run menu role table tests"); |
| 1098 | 1155 | test_menu_roles_step.dependOn(&run_menu_roles_tests.step); |
| 1099 | 1156 | |
| 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 | ||
| 1100 | 1167 | const test_config_step = b.step("test:config", "Run Config tests"); |
| 1101 | 1168 | test_config_step.dependOn(&run_config_tests.step); |
| 1102 | 1169 | |
| @@ -1599,6 +1666,11 @@ fn linkPlatformLibraries(b: *std.Build, module: *std.Build.Module, target_os: st | ||
| 1599 | 1666 | // when the app dynamically loads them. |
| 1600 | 1667 | module.linkFramework("CoreMIDI", .{}); |
| 1601 | 1668 | 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", .{}); | |
| 1602 | 1674 | applySdkPaths(b, module, sdk_path); |
| 1603 | 1675 | }, |
| 1604 | 1676 | .linux => { |
| @@ -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 | ||
| 13 | const std = ; | |
| 14 | const builtin = ; | |
| 15 | const key_codes = ; | |
| 16 | ||
| 17 | pub 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 | ||
| 66 | pub 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 | ||
| 79 | pub 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. | |
| 98 | fn 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 | ||
| 104 | fn 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 | ||
| 133 | fn 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. | |
| 141 | pub 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()`. | |
| 173 | pub 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 | ||
| 198 | test "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 | ||
| 209 | test "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 | ||
| 224 | test "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 | ||
| 235 | test "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 | ||
| 243 | test "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 | ||
| 260 | test "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 | ||
| 270 | test "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 | ||
| 279 | test "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 | ||
| 290 | test "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 | ||
| 296 | test "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 | ||
| 302 | test "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 | ||
| 318 | test "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 | ||
| 332 | test "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 | } | |
| @@ -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 | ||
| 15 | pub const menu = ; | |
| 16 | pub const shortcuts = ; | |
packages/zig/src/bridge_shortcuts.zigmodified+161-403
| @@ -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 | ||
| 1 | 26 | const std = ; |
| 2 | 27 | const builtin = ; |
| 3 | 28 | const bridge_error = ; |
| 4 | 29 | const logging = ; |
| 30 | const registry_mod = ; | |
| 31 | const accel = ; | |
| 5 | 32 | |
| 6 | 33 | const log = logging.shortcuts; |
| 7 | 34 | |
| 8 | 35 | const BridgeError = bridge_error.BridgeError; |
| 9 | 36 | |
| 10 | /// Modifier flags for keyboard shortcuts | |
| 11 | pub 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. | |
| 38 | pub const Modifiers = accel.Modifiers; | |
| 39 | pub const Shortcut = registry_mod.Registration; | |
| 40 | ||
| 41 | pub const action_register = registry_mod.action_register; | |
| 42 | pub const action_unregister = registry_mod.action_unregister; | |
| 43 | pub const action_unregister_all = registry_mod.action_unregister_all; | |
| 44 | pub const action_enable = registry_mod.action_enable; | |
| 45 | pub const action_disable = registry_mod.action_disable; | |
| 46 | pub const action_is_registered = registry_mod.action_is_registered; | |
| 47 | pub 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`. | |
| 51 | pub 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. | |
| 59 | pub const event_error = "craft:shortcut:error"; | |
| 60 | ||
| 61 | fn 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 | }; | |
| 24 | 75 | } |
| 25 | }; | |
| 26 | ||
| 27 | /// Registered shortcut | |
| 28 | pub 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 | } | |
| 35 | 81 | |
| 36 | /// Bridge handler for global keyboard shortcuts | |
| 37 | 82 | pub const ShortcutsBridge = struct { |
| 38 | 83 | allocator: std.mem.Allocator, |
| 39 | shortcuts: std.StringHashMap(Shortcut), | |
| 40 | global_monitor: ?*anyopaque = null, | |
| 41 | local_monitor: ?*anyopaque = null, | |
| 84 | registry: registry_mod.Registry, | |
| 42 | 85 | |
| 43 | 86 | const Self = @This(); |
| 44 | 87 | |
| 45 | 88 | pub fn init(allocator: std.mem.Allocator) Self { |
| 46 | 89 | return .{ |
| 47 | 90 | .allocator = allocator, |
| 48 | .shortcuts = std.StringHashMap(Shortcut).init(allocator), | |
| 91 | .registry = registry_mod.Registry.init(allocator, platform()), | |
| 49 | 92 | }; |
| 50 | 93 | } |
| 51 | 94 | |
| 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 | ||
| 53 | 104 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { |
| 54 | 105 | 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); | |
| 56 | 119 | }; |
| 57 | 120 | } |
| 58 | 121 | |
| 59 | 122 | 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); | |
| 74 | 143 | } else { |
| 75 | 144 | return BridgeError.UnknownAction; |
| 76 | 145 | } |
| 77 | 146 | } |
| 78 | 147 | |
| 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 { | |
| 172 | 152 | const global_state = ; |
| 173 | 153 | 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; | |
| 198 | 156 | } |
| 199 | 157 | } |
| 200 | 158 | |
| 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; | |
| 218 | 165 | |
| 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); | |
| 291 | 170 | } |
| 292 | 171 | |
| 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 = "" }; | |
| 296 | 177 | 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 { | |
| 322 | 197 | const bridge = ; |
| 323 | 198 | |
| 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); | |
| 354 | 201 | |
| 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 }); | |
| 357 | 204 | }; |
| 358 | 205 | } |
| 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 | } | |
| 371 | 206 | }; |
| 372 | 207 | |
| 373 | /// Convert keycode to key name | |
| 374 | fn 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 | |
| 449 | pub fn handleGlobalKeyEvent(keycode: u16, flags: c_ulong) void { | |
| 208 | /// Bridge between the platform's C callback and the live bridge instance. | |
| 209 | fn onHotKeyPressed(hotkey_id: u32) void { | |
| 450 | 210 | 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); | |
| 454 | 212 | } |
| @@ -539,6 +539,13 @@ | ||
| 539 | 539 | // ------------------------------------------------------------------------- |
| 540 | 540 | // shortcuts — global hotkeys |
| 541 | 541 | // ------------------------------------------------------------------------- |
| 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). | |
| 542 | 549 | window.craft.shortcuts = { |
| 543 | 550 | register: function (id, accelerator, opts) { |
| 544 | 551 | return _send('shortcuts', 'register', _stringify(Object.assign({ |
| @@ -552,6 +559,7 @@ | ||
| 552 | 559 | isRegistered: function (id) { return _req('shortcuts', 'isRegistered', _stringify({ id: String(id) })).then(function (r) { return !!(r && r.value) }) }, |
| 553 | 560 | list: function () { return _req('shortcuts', 'list').then(function (r) { return (r && r.shortcuts) || [] }) }, |
| 554 | 561 | on: _evt('craft:shortcut'), |
| 562 | onError: _evt('craft:shortcut:error'), | |
| 555 | 563 | } |
| 556 | 564 | |
| 557 | 565 | // ------------------------------------------------------------------------- |
| @@ -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 | ||
| 15 | const std = ; | |
| 16 | ||
| 17 | pub 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. | |
| 27 | pub 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. | |
| 149 | const 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. | |
| 180 | pub 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. | |
| 191 | pub fn nameFor(code: u16) ?[]const u8 { | |
| 192 | for (table) |entry| { | |
| 193 | if (entry.code == code) return entry.name; | |
| 194 | } | |
| 195 | return null; | |
| 196 | } | |
| 197 | ||
| 198 | test "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 | ||
| 205 | test "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 | ||
| 212 | test "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 | ||
| 226 | test "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 | ||
| 235 | test "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 | ||
| 246 | test "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 | ||
| 255 | test "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 | ||
| 262 | test "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 | } | |
| @@ -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 | ||
| 23 | const std = ; | |
| 24 | const builtin = ; | |
| 25 | ||
| 26 | const is_macos = builtin.os.tag == .macos; | |
| 27 | ||
| 28 | /// Opaque `EventHotKeyRef`. Held so the registration can be undone. | |
| 29 | pub 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. | |
| 33 | fn 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 | ||
| 37 | const k_event_class_keyboard = fourCC("keyb"); | |
| 38 | const k_event_hotkey_pressed: u32 = 5; | |
| 39 | const k_event_param_direct_object = fourCC("----"); | |
| 40 | const 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. | |
| 44 | const craft_signature = fourCC("craf"); | |
| 45 | ||
| 46 | const noErr: i32 = 0; | |
| 47 | ||
| 48 | const EventHotKeyID = extern struct { | |
| 49 | signature: u32, | |
| 50 | id: u32, | |
| 51 | }; | |
| 52 | ||
| 53 | const 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`. | |
| 60 | extern "c" fn GetApplicationEventTarget() ?*anyopaque; | |
| 61 | extern "c" fn RegisterEventHotKey( | |
| 62 | inHotKeyCode: u32, | |
| 63 | inHotKeyModifiers: u32, | |
| 64 | inHotKeyID: EventHotKeyID, | |
| 65 | inTarget: ?*anyopaque, | |
| 66 | inOptions: u32, | |
| 67 | outRef: *Ref, | |
| 68 | ) i32; | |
| 69 | extern "c" fn UnregisterEventHotKey(inHotKey: ?*anyopaque) i32; | |
| 70 | extern "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; | |
| 78 | extern "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. | |
| 90 | pub var on_pressed: ?*const fn (id: u32) void = null; | |
| 91 | ||
| 92 | var handler_installed = false; | |
| 93 | ||
| 94 | fn 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. | |
| 119 | pub 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. | |
| 151 | pub 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. | |
| 170 | pub 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. | |
| 179 | extern "c" fn CreateEvent(inAllocator: ?*anyopaque, inClassID: u32, inKind: u32, inWhen: f64, inAttributes: u32, outEvent: *?*anyopaque) i32; | |
| 180 | extern "c" fn SetEventParameter(inEvent: ?*anyopaque, inName: u32, inType: u32, inSize: usize, inDataPtr: *const anyopaque) i32; | |
| 181 | extern "c" fn SendEventToEventTarget(inEvent: ?*anyopaque, inTarget: ?*anyopaque) i32; | |
| 182 | extern "c" fn ReleaseEvent(inEvent: ?*anyopaque) void; | |
| 183 | ||
| 184 | var test_last_id: ?u32 = null; | |
| 185 | ||
| 186 | fn 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. | |
| 192 | fn 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 | ||
| 205 | test "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 | ||
| 229 | test "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 | ||
| 245 | test "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 | ||
| 255 | test "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
| @@ -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 | ||
| 14 | const std = ; | |
| 15 | const accel = ; | |
| 16 | const key_codes = ; | |
| 17 | const bridge_error = ; | |
| 18 | ||
| 19 | const 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. | |
| 25 | pub const action_register = "register"; | |
| 26 | pub const action_unregister = "unregister"; | |
| 27 | pub const action_unregister_all = "unregisterAll"; | |
| 28 | pub const action_enable = "enable"; | |
| 29 | pub const action_disable = "disable"; | |
| 30 | pub const action_is_registered = "isRegistered"; | |
| 31 | pub const action_list = "list"; | |
| 32 | ||
| 33 | /// An opaque handle from the platform, held so a reservation can be released. | |
| 34 | pub 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. | |
| 42 | pub 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. | |
| 50 | pub 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 | ||
| 61 | pub 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. | |
| 82 | const 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. | |
| 99 | const IdPayload = struct { | |
| 100 | id: []const u8 = "", | |
| 101 | }; | |
| 102 | ||
| 103 | pub 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. | |
| 335 | pub 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. | |
| 345 | pub 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. | |
| 354 | pub 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. | |
| 363 | fn 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. | |
| 371 | fn 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 | ||
| 398 | fn 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. | |
| 412 | const 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 | ||
| 448 | fn testRegistry() Registry { | |
| 449 | FakePlatform.reset(); | |
| 450 | return Registry.init(std.testing.allocator, FakePlatform.platform()); | |
| 451 | } | |
| 452 | ||
| 453 | test "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 | ||
| 468 | test "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 | ||
| 486 | test "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 | ||
| 505 | test "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 | ||
| 525 | test "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 | ||
| 544 | test "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 | ||
| 564 | test "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 | ||
| 577 | test "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 | ||
| 594 | test "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 | ||
| 613 | test "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 | ||
| 629 | test "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 | ||
| 645 | test "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 | ||
| 665 | test "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 | ||
| 676 | test "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 | ||
| 686 | test "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 | ||
| 696 | test "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 | ||
| 717 | test "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 | ||
| 732 | test "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 | ||
| 753 | test "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 | ||
| 779 | test "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 | ||
| 798 | test "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 | ||
| 814 | test "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 | ||
| 830 | test "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 | ||
| 851 | test "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 | ||
| 859 | test "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 | ||
| 868 | test "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 | } | |
| @@ -16,7 +16,9 @@ | ||
| 16 | 16 | const std = ; |
| 17 | 17 | const js = ; |
| 18 | 18 | const testing = std.testing; |
| 19 | const bridge_menu = ; | |
| 19 | const contracts = ; | |
| 20 | const bridge_menu = contracts.menu; | |
| 21 | const shortcut_registry = contracts.shortcuts; | |
| 20 | 22 | |
| 21 | 23 | // Supplied by build.zig as named imports — a test module cannot embed |
| 22 | 24 | // files outside its own package path. |
| @@ -190,15 +192,36 @@ test "the bridge script parses" { | ||
| 190 | 192 | /// |
| 191 | 193 | /// Everything native would do with a posted message begins with these bytes, |
| 192 | 194 | /// 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. | |
| 193 | 199 | const WEBVIEW_HOST = |
| 194 | 200 | \\var posted = []; |
| 195 | 201 | \\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 | \\}; | |
| 199 | 213 | \\function CustomEvent(type, init) { this.type = type; this.detail = init && init.detail } |
| 200 | 214 | \\var document = { readyState: 'complete', addEventListener: function () {} }; |
| 215 | \\globalThis.setTimeout = function () { return 0 }; | |
| 216 | \\globalThis.clearTimeout = function () {}; | |
| 201 | 217 | ; |
| 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. | |
| 202 | 225 | |
| 203 | 226 | test "craft.menu.set posts what bridge_menu.zig's parser reads" { |
| 204 | 227 | // 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" { | ||
| 277 | 300 | |
| 278 | 301 | try testing.expect(parsed.value.menus == null); |
| 279 | 302 | } |
| 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. | |
| 310 | const 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 | ||
| 320 | fn 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 | ||
| 328 | test "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 | ||
| 357 | test "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 | ||
| 385 | test "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 | ||
| 424 | test "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 | ||
| 458 | test "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 | ||
| 483 | test "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 | ||
| 512 | test "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 | } | |