ReviewOS

also looking at this

craft-native/craft

fix(shortcuts): make global hotkeys actually fire

#54
Merged glennmichael123 wants to merge fix/global-shortcuts-never-fire into main
11 files +2435 -417
packages/zig/src/bridge_shortcuts.zigmodified+161-403
Changes to packages/zig/src/bridge_shortcuts.zig
@@ -1,454 +1,212 @@
1//! `window.craft.shortcuts` — global hotkeys.
2//!
3//! A thin adapter: it decodes the action, hands the payload to
4//! `shortcut_registry.zig`, and turns the answer back into JavaScript. The
5//! decisions live in the registry, where they are testable; the key is
6//! actually reserved by `macos_hotkey.zig`.
7//!
8//! What used to be here, and what craft-native/craft#47 was about: an app
9//! could register a hotkey, be told it worked, and never receive a keystroke.
10//! `setupGlobalMonitor` computed an event mask, discarded it, installed
11//! nothing, and logged "Global monitor setup (polling mode)" — there was no
12//! polling either. The matcher it was supposed to feed was reachable only from
13//! a function with no callers anywhere in the tree. Three further mismatches
14//! sat on top of it, each individually fatal:
15//!
16//! * the JS bridge sends `{id, accelerator}`; this file read `key` and
17//! `modifiers`, so every registration failed its own validation before it
18//! got anywhere near the missing monitor;
19//! * `isRegistered` and `list` answered by calling `__craftShortcutList` and
20//! friends, which nothing defines — so those promises hung for the full
21//! 30-second request timeout and then rejected;
22//! * a triggered shortcut called `__craftShortcutCallback`, which nothing
23//! defines either, while the JS side was listening for a `craft:shortcut`
24//! event that was never dispatched.
25
126const std = ;
227const builtin = ;
328const bridge_error = ;
429const logging = ;
30const registry_mod = ;
31const accel = ;
532
633const log = logging.shortcuts;
734
835const BridgeError = bridge_error.BridgeError;
936
10/// Modifier flags for keyboard shortcuts
11pub const Modifiers = struct {
12 cmd: bool = false,
13 ctrl: bool = false,
14 alt: bool = false,
15 shift: bool = false,
16
17 pub fn toCocoaFlags(self: Modifiers) c_ulong {
18 var flags: c_ulong = 0;
19 if (self.cmd) flags |= (1 << 20); // NSEventModifierFlagCommand
20 if (self.ctrl) flags |= (1 << 18); // NSEventModifierFlagControl
21 if (self.alt) flags |= (1 << 19); // NSEventModifierFlagOption
22 if (self.shift) flags |= (1 << 17); // NSEventModifierFlagShift
23 return flags;
37/// Re-exported so callers that named these through this module keep working.
38pub const Modifiers = accel.Modifiers;
39pub const Shortcut = registry_mod.Registration;
40
41pub const action_register = registry_mod.action_register;
42pub const action_unregister = registry_mod.action_unregister;
43pub const action_unregister_all = registry_mod.action_unregister_all;
44pub const action_enable = registry_mod.action_enable;
45pub const action_disable = registry_mod.action_disable;
46pub const action_is_registered = registry_mod.action_is_registered;
47pub const action_list = registry_mod.action_list;
48
49/// The event a triggered shortcut arrives on, matching `_evt('craft:shortcut')`
50/// in `craft-bridge.js`.
51pub const event_triggered = "craft:shortcut";
52
53/// Where a registration that could not be granted is reported.
54///
55/// `register` is fire-and-forget on the JS side — its action name collides
56/// with other bridges' in the request queue, so it cannot be a request without
57/// mixing replies — which leaves an app no way to hear that the key it asked
58/// for belongs to someone else. This event is that way.
59pub const event_error = "craft:shortcut:error";
60
61fn platform() registry_mod.Platform {
62 if (comptime builtin.os.tag == .macos) {
63 return .{
64 .register = struct {
65 fn f(keycode: u16, modifiers: u32, hotkey_id: u32) registry_mod.Ref {
66 return .register(keycode, modifiers, hotkey_id);
67 }
68 }.f,
69 .unregister = struct {
70 fn f(ref: registry_mod.Ref) void {
71 .unregister(ref);
72 }
73 }.f,
74 };
2475 }
25};
26
27/// Registered shortcut
28pub const Shortcut = struct {
29 id: []const u8,
30 key: []const u8,
31 modifiers: Modifiers,
32 callback_id: []const u8,
33 enabled: bool = true,
34};
76 // Linux and Windows have no global-hotkey implementation in craft yet.
77 // Refusing every registration is the honest answer: the alternative —
78 // accepting them — is the bug this file was rewritten to fix.
79 return registry_mod.unsupported_platform;
80}
3581
36/// Bridge handler for global keyboard shortcuts
3782pub const ShortcutsBridge = struct {
3883 allocator: std.mem.Allocator,
39 shortcuts: std.StringHashMap(Shortcut),
40 global_monitor: ?*anyopaque = null,
41 local_monitor: ?*anyopaque = null,
84 registry: registry_mod.Registry,
4285
4386 const Self = @This();
4487
4588 pub fn init(allocator: std.mem.Allocator) Self {
4689 return .{
4790 .allocator = allocator,
48 .shortcuts = std.StringHashMap(Shortcut).init(allocator),
91 .registry = registry_mod.Registry.init(allocator, platform()),
4992 };
5093 }
5194
52 /// Handle shortcut-related messages from JavaScript
95 pub fn deinit(self: *Self) void {
96 self.registry.deinit();
97 if (comptime builtin.os.tag == .macos) {
98 .on_pressed = null;
99 }
100 const global_state = ;
101 global_state.instance.setShortcutsBridge(null);
102 }
103
53104 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
54105 self.handleMessageInternal(action, data) catch |err| {
55 self.reportError(action, err);
106 const bridge_err: BridgeError = err;
107 // The action name travels with the error so `__craftBridgeError`
108 // rejects only that action's callers.
109 bridge_error.sendErrorToJS(self.allocator, action, bridge_err);
110
111 // `isRegistered` and `list` are requests: the line above rejects
112 // the promise their caller is holding, and that is the whole
113 // report. Every other verb is fire-and-forget, so its caller has
114 // already moved on and there is nothing to reject — the error
115 // event is the only way it hears anything at all.
116 const is_request = std.mem.eql(u8, action, action_is_registered) or
117 std.mem.eql(u8, action, action_list);
118 if (!is_request) self.reportFailure(data, bridge_err);
56119 };
57120 }
58121
59122 fn handleMessageInternal(self: *Self, action: []const u8, data: []const u8) !void {
60 if (std.mem.eql(u8, action, "register")) {
61 try self.registerShortcut(data);
62 } else if (std.mem.eql(u8, action, "unregister")) {
63 try self.unregisterShortcut(data);
64 } else if (std.mem.eql(u8, action, "unregisterAll")) {
65 try self.unregisterAllShortcuts();
66 } else if (std.mem.eql(u8, action, "enable")) {
67 try self.enableShortcut(data);
68 } else if (std.mem.eql(u8, action, "disable")) {
69 try self.disableShortcut(data);
70 } else if (std.mem.eql(u8, action, "isRegistered")) {
71 try self.isRegistered(data);
72 } else if (std.mem.eql(u8, action, "list")) {
73 try self.listShortcuts();
123 if (std.mem.eql(u8, action, action_register)) {
124 self.ensureDelivery();
125 const entry = try self.registry.register(data);
126 log.debug("registered {s} as {s}", .{ entry.id, entry.accelerator });
127 } else if (std.mem.eql(u8, action, action_unregister)) {
128 try self.registry.unregister(data);
129 } else if (std.mem.eql(u8, action, action_unregister_all)) {
130 self.registry.unregisterAll();
131 } else if (std.mem.eql(u8, action, action_enable)) {
132 try self.registry.setEnabled(data, true);
133 } else if (std.mem.eql(u8, action, action_disable)) {
134 try self.registry.setEnabled(data, false);
135 } else if (std.mem.eql(u8, action, action_is_registered)) {
136 const json = try self.registry.isRegisteredJson(data);
137 defer self.allocator.free(json);
138 bridge_error.sendResultToJS(self.allocator, action, json);
139 } else if (std.mem.eql(u8, action, action_list)) {
140 const json = try self.registry.listJson();
141 defer self.allocator.free(json);
142 bridge_error.sendResultToJS(self.allocator, action, json);
74143 } else {
75144 return BridgeError.UnknownAction;
76145 }
77146 }
78147
79 fn reportError(self: *Self, action: []const u8, err: anyerror) void {
80 const bridge_err: BridgeError = switch (err) {
81 BridgeError.MissingData => BridgeError.MissingData,
82 BridgeError.InvalidJSON => BridgeError.InvalidJSON,
83 else => BridgeError.NativeCallFailed,
84 };
85 bridge_error.sendErrorToJS(self.allocator, action, bridge_err);
86 }
87
88 /// Register a global shortcut
89 /// JSON: {"id": "toggle", "key": "Space", "modifiers": {"cmd": true, "shift": true}, "callback": "onToggle"}
90 fn registerShortcut(self: *Self, data: []const u8) !void {
91 // Parse shortcut data
92 var id: []const u8 = "";
93 var key: []const u8 = "";
94 var callback_id: []const u8 = "";
95 var modifiers = Modifiers{};
96
97 // Parse id
98 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
99 const start = idx + 6;
100 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
101 id = data[start..end];
102 }
103 }
104
105 // Parse key
106 if (std.mem.indexOf(u8, data, "\"key\":\"")) |idx| {
107 const start = idx + 7;
108 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
109 key = data[start..end];
110 }
111 }
112
113 // Parse callback
114 if (std.mem.indexOf(u8, data, "\"callback\":\"")) |idx| {
115 const start = idx + 12;
116 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
117 callback_id = data[start..end];
118 }
119 }
120
121 // Parse modifiers
122 if (std.mem.indexOf(u8, data, "\"cmd\":true")) |_| modifiers.cmd = true;
123 if (std.mem.indexOf(u8, data, "\"ctrl\":true")) |_| modifiers.ctrl = true;
124 if (std.mem.indexOf(u8, data, "\"alt\":true")) |_| modifiers.alt = true;
125 if (std.mem.indexOf(u8, data, "\"shift\":true")) |_| modifiers.shift = true;
126
127 if (id.len == 0 or key.len == 0) {
128 return BridgeError.MissingData;
129 }
130
131 log.debug("register: id={s}, key={s}, cmd={}, ctrl={}, alt={}, shift={}", .{ id, key, modifiers.cmd, modifiers.ctrl, modifiers.alt, modifiers.shift });
132
133 // Store shortcut
134 const id_owned = try self.allocator.dupe(u8, id);
135 const key_owned = try self.allocator.dupe(u8, key);
136 const callback_owned = try self.allocator.dupe(u8, callback_id);
137
138 try self.shortcuts.put(id_owned, Shortcut{
139 .id = id_owned,
140 .key = key_owned,
141 .modifiers = modifiers,
142 .callback_id = callback_owned,
143 .enabled = true,
144 });
145
146 // Setup global monitor if not already done
147 if (self.global_monitor == null) {
148 try self.setupGlobalMonitor();
149 }
150 }
151
152 /// Setup global event monitor
153 fn setupGlobalMonitor(self: *Self) !void {
154 if (comptime builtin.os.tag != .macos) return;
155
156 const macos = ;
157
158 // Create a global monitor for key down events
159 // NSEventMaskKeyDown = 1 << 10 = 1024
160 const event_mask: c_ulonglong = 1 << 10;
161
162 // We need to use addGlobalMonitorForEventsMatchingMask:handler:
163 // This requires creating an Objective-C block, which is complex in Zig
164 // For now, we'll use a polling approach via the run loop
165
166 // Alternative: Use local monitor for when app is active
167 const NSEvent = macos.getClass("NSEvent");
168 _ = NSEvent;
169 _ = event_mask;
170
171 // Store reference to self for callback
148 /// Point the platform's hotkey callback at this bridge. Cheap and
149 /// idempotent, so it runs before every registration rather than depending
150 /// on an initialisation order.
151 fn ensureDelivery(self: *Self) void {
172152 const global_state = ;
173153 global_state.instance.setShortcutsBridge(self);
174
175 log.debug("Global monitor setup (polling mode)", .{});
176 }
177
178 /// Check if a key event matches any registered shortcut
179 pub fn checkKeyEvent(self: *Self, keycode: u16, flags: c_ulong) void {
180 const key_name = keycodeToName(keycode);
181
182 var it = self.shortcuts.iterator();
183 while (it.next()) |entry| {
184 const shortcut = entry.value_ptr.*;
185 if (!shortcut.enabled) continue;
186
187 // Check if key matches
188 if (!std.mem.eql(u8, shortcut.key, key_name)) continue;
189
190 // Check modifiers
191 const required_flags = shortcut.modifiers.toCocoaFlags();
192 const masked_flags = flags & ((1 << 17) | (1 << 18) | (1 << 19) | (1 << 20));
193
194 if (masked_flags == required_flags) {
195 log.debug("Shortcut triggered: {s}", .{shortcut.id});
196 self.triggerCallback(shortcut.id, shortcut.callback_id);
197 }
154 if (comptime builtin.os.tag == .macos) {
155 .on_pressed = onHotKeyPressed;
198156 }
199157 }
200158
201 /// Trigger JavaScript callback for shortcut.
202 /// Escapes both IDs before embedding them in the JS literal so a value
203 /// containing `'` / `\` / newline cannot break out and inject code.
204 fn triggerCallback(self: *Self, shortcut_id: []const u8, callback_id: []const u8) void {
205 _ = self;
206
207 const bridge = ;
208
209 var sid_buf: [128]u8 = undefined;
210 var cb_buf: [128]u8 = undefined;
211 const sid_esc = bridge_error.escapeJsSingleQuoted(&sid_buf, shortcut_id) catch return;
212 const cb_esc = bridge_error.escapeJsSingleQuoted(&cb_buf, callback_id) catch return;
213
214 var buf: [512]u8 = undefined;
215 const js = std.fmt.bufPrint(&buf,
216 \\if(window.__craftShortcutCallback)window.__craftShortcutCallback('{s}','{s}');
217 , .{ sid_esc, cb_esc }) catch return;
159 /// Called on the main thread when a registered combination is pressed
160 /// anywhere in the system.
161 pub fn deliver(self: *Self, hotkey_id: u32) void {
162 const entry = self.registry.findByHotkeyId(hotkey_id) orelse return;
163 // A hotkey disabled between the press and its delivery must not fire.
164 if (!entry.enabled) return;
218165
219 bridge.evalJS(js) catch |err| {
220 log.debug("Failed to trigger callback: {}", .{err});
221 };
222 }
223
224 /// Unregister a shortcut
225 /// JSON: {"id": "toggle"}
226 fn unregisterShortcut(self: *Self, data: []const u8) !void {
227 var id: []const u8 = "";
228 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
229 const start = idx + 6;
230 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
231 id = data[start..end];
232 }
233 }
234
235 if (id.len == 0) return BridgeError.MissingData;
236
237 log.debug("unregister: {s}", .{id});
238
239 if (self.shortcuts.fetchRemove(id)) |kv| {
240 self.allocator.free(kv.value.id);
241 self.allocator.free(kv.value.key);
242 self.allocator.free(kv.value.callback_id);
243 }
244 }
245
246 /// Unregister all shortcuts
247 fn unregisterAllShortcuts(self: *Self) !void {
248 log.debug("unregisterAll", .{});
249
250 var it = self.shortcuts.iterator();
251 while (it.next()) |entry| {
252 self.allocator.free(entry.value_ptr.id);
253 self.allocator.free(entry.value_ptr.key);
254 self.allocator.free(entry.value_ptr.callback_id);
255 }
256 self.shortcuts.clearAndFree();
257 }
258
259 /// Enable a shortcut
260 /// JSON: {"id": "toggle"}
261 fn enableShortcut(self: *Self, data: []const u8) !void {
262 var id: []const u8 = "";
263 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
264 const start = idx + 6;
265 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
266 id = data[start..end];
267 }
268 }
269
270 if (self.shortcuts.getPtr(id)) |shortcut| {
271 shortcut.enabled = true;
272 log.debug("enabled: {s}", .{id});
273 }
274 }
275
276 /// Disable a shortcut
277 /// JSON: {"id": "toggle"}
278 fn disableShortcut(self: *Self, data: []const u8) !void {
279 var id: []const u8 = "";
280 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
281 const start = idx + 6;
282 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
283 id = data[start..end];
284 }
285 }
286
287 if (self.shortcuts.getPtr(id)) |shortcut| {
288 shortcut.enabled = false;
289 log.debug("disabled: {s}", .{id});
290 }
166 log.debug("triggered {s} ({s})", .{ entry.id, entry.accelerator });
167 const detail = registry_mod.triggeredDetail(self.allocator, entry.id, entry.accelerator) catch return;
168 defer self.allocator.free(detail);
169 self.dispatch(event_triggered, detail);
291170 }
292171
293 /// Check if shortcut is registered
294 /// JSON: {"id": "toggle"}
295 fn isRegistered(self: *Self, data: []const u8) !void {
172 fn reportFailure(self: *Self, data: []const u8, err: BridgeError) void {
173 // Best effort: the id is read back out of the payload so the app can
174 // tell which of its registrations failed. A payload too malformed to
175 // yield an id still gets the error, just without one.
176 const IdOnly = struct { id: []const u8 = "" };
296177 var id: []const u8 = "";
297 if (std.mem.indexOf(u8, data, "\"id\":\"")) |idx| {
298 const start = idx + 6;
299 if (std.mem.indexOfPos(u8, data, start, "\"")) |end| {
300 id = data[start..end];
301 }
302 }
303
304 const registered = self.shortcuts.contains(id);
305
306 const bridge = ;
307 var id_buf: [128]u8 = undefined;
308 const id_esc = bridge_error.escapeJsSingleQuoted(&id_buf, id) catch return;
309
310 var buf: [256]u8 = undefined;
311 const js = std.fmt.bufPrint(&buf,
312 \\if(window.__craftShortcutRegistered)window.__craftShortcutRegistered('{s}',{});
313 , .{ id_esc, registered }) catch return;
314
315 bridge.evalJS(js) catch |err| {
316 std.log.debug("JS eval failed for shortcut registered callback: {}", .{err});
317 };
318 }
319
320 /// List all registered shortcuts
321 fn listShortcuts(self: *Self) !void {
178 const parsed = std.json.parseFromSlice(IdOnly, self.allocator, data, .{
179 .ignore_unknown_fields = true,
180 .allocate = .alloc_always,
181 }) catch null;
182 defer if (parsed) |p| p.deinit();
183 if (parsed) |p| id = p.value.id;
184
185 const detail = registry_mod.errorDetail(
186 self.allocator,
187 id,
188 bridge_error.errorCodeString(err),
189 bridge_error.errorMessage(err),
190 ) catch return;
191 defer self.allocator.free(detail);
192 self.dispatch(event_error, detail);
193 }
194
195 /// Emit `new CustomEvent(name, { detail })` into the page.
196 fn dispatch(self: *Self, name: []const u8, detail: []const u8) void {
322197 const bridge = ;
323198
324 var buf: [2048]u8 = undefined;
325 var pos: usize = 0;
326
327 // Start JSON array
328 buf[pos] = '[';
329 pos += 1;
330
331 var first = true;
332 var it = self.shortcuts.iterator();
333 while (it.next()) |entry| {
334 const s = entry.value_ptr.*;
335 if (!first) {
336 buf[pos] = ',';
337 pos += 1;
338 }
339 first = false;
340
341 const item = std.fmt.bufPrint(buf[pos..],
342 \\
343 , .{ s.id, s.key, s.enabled }) catch break;
344 pos += item.len;
345 }
346
347 buf[pos] = ']';
348 pos += 1;
349
350 var js_buf: [2200]u8 = undefined;
351 const js = std.fmt.bufPrint(&js_buf,
352 \\if(window.__craftShortcutList)window.__craftShortcutList({s});
353 , .{buf[0..pos]}) catch return;
199 const js = registry_mod.eventScript(self.allocator, name, detail) catch return;
200 defer self.allocator.free(js);
354201
355 bridge.evalJS(js) catch |err| {
356 std.log.debug("JS eval failed for shortcut list callback: {}", .{err});
202 bridge.evalJS(js) catch |eval_err| {
203 log.debug("could not deliver {s}: {}", .{ name, eval_err });
357204 };
358205 }
359
360 pub fn deinit(self: *Self) void {
361 var it = self.shortcuts.iterator();
362 while (it.next()) |entry| {
363 self.allocator.free(entry.value_ptr.id);
364 self.allocator.free(entry.value_ptr.key);
365 self.allocator.free(entry.value_ptr.callback_id);
366 }
367 self.shortcuts.deinit();
368 const global_state = ;
369 global_state.instance.setShortcutsBridge(null);
370 }
371206};
372207
373/// Convert keycode to key name
374fn keycodeToName(keycode: u16) []const u8 {
375 return switch (keycode) {
376 0 => "A",
377 1 => "S",
378 2 => "D",
379 3 => "F",
380 4 => "H",
381 5 => "G",
382 6 => "Z",
383 7 => "X",
384 8 => "C",
385 9 => "V",
386 11 => "B",
387 12 => "Q",
388 13 => "W",
389 14 => "E",
390 15 => "R",
391 16 => "Y",
392 17 => "T",
393 18 => "1",
394 19 => "2",
395 20 => "3",
396 21 => "4",
397 22 => "6",
398 23 => "5",
399 24 => "=",
400 25 => "9",
401 26 => "7",
402 27 => "-",
403 28 => "8",
404 29 => "0",
405 30 => "]",
406 31 => "O",
407 32 => "U",
408 33 => "[",
409 34 => "I",
410 35 => "P",
411 36 => "Return",
412 37 => "L",
413 38 => "J",
414 39 => "'",
415 40 => "K",
416 41 => ";",
417 42 => "\\",
418 43 => ",",
419 44 => "/",
420 45 => "N",
421 46 => "M",
422 47 => ".",
423 48 => "Tab",
424 49 => "Space",
425 50 => "`",
426 51 => "Delete",
427 53 => "Escape",
428 96 => "F5",
429 97 => "F6",
430 98 => "F7",
431 99 => "F3",
432 100 => "F8",
433 101 => "F9",
434 103 => "F11",
435 109 => "F10",
436 111 => "F12",
437 118 => "F4",
438 120 => "F2",
439 122 => "F1",
440 123 => "Left",
441 124 => "Right",
442 125 => "Down",
443 126 => "Up",
444 else => "Unknown",
445 };
446}
447
448/// Public function for external event handling
449pub fn handleGlobalKeyEvent(keycode: u16, flags: c_ulong) void {
208/// Bridge between the platform's C callback and the live bridge instance.
209fn onHotKeyPressed(hotkey_id: u32) void {
450210 const global_state = ;
451 if (global_state.instance.getShortcutsBridge()) |bridge| {
452 bridge.checkKeyEvent(keycode, flags);
453 }
211 if (global_state.instance.getShortcutsBridge()) |bridge| bridge.deliver(hotkey_id);
454212}