usecomputer 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +324 -0
  2. package/dist/bridge-contract.test.js +124 -63
  3. package/dist/bridge.d.ts.map +1 -1
  4. package/dist/bridge.js +241 -46
  5. package/dist/cli-parsing.test.js +34 -11
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +323 -28
  8. package/dist/coord-map.d.ts +14 -0
  9. package/dist/coord-map.d.ts.map +1 -0
  10. package/dist/coord-map.js +75 -0
  11. package/dist/coord-map.test.d.ts +2 -0
  12. package/dist/coord-map.test.d.ts.map +1 -0
  13. package/dist/coord-map.test.js +157 -0
  14. package/dist/darwin-arm64/usecomputer.node +0 -0
  15. package/dist/darwin-x64/usecomputer.node +0 -0
  16. package/dist/debug-point-image.d.ts +8 -0
  17. package/dist/debug-point-image.d.ts.map +1 -0
  18. package/dist/debug-point-image.js +43 -0
  19. package/dist/debug-point-image.test.d.ts +2 -0
  20. package/dist/debug-point-image.test.d.ts.map +1 -0
  21. package/dist/debug-point-image.test.js +44 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -1
  25. package/dist/lib.d.ts +26 -0
  26. package/dist/lib.d.ts.map +1 -0
  27. package/dist/lib.js +88 -0
  28. package/dist/native-click-smoke.test.js +69 -29
  29. package/dist/native-lib.d.ts +59 -1
  30. package/dist/native-lib.d.ts.map +1 -1
  31. package/dist/terminal-table.d.ts +10 -0
  32. package/dist/terminal-table.d.ts.map +1 -0
  33. package/dist/terminal-table.js +55 -0
  34. package/dist/terminal-table.test.d.ts +2 -0
  35. package/dist/terminal-table.test.d.ts.map +1 -0
  36. package/dist/terminal-table.test.js +41 -0
  37. package/dist/types.d.ts +45 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +16 -4
  40. package/src/bridge-contract.test.ts +140 -69
  41. package/src/bridge.ts +293 -53
  42. package/src/cli-parsing.test.ts +61 -0
  43. package/src/cli.ts +393 -32
  44. package/src/coord-map.test.ts +178 -0
  45. package/src/coord-map.ts +105 -0
  46. package/src/debug-point-image.test.ts +50 -0
  47. package/src/debug-point-image.ts +69 -0
  48. package/src/index.ts +3 -1
  49. package/src/lib.ts +125 -0
  50. package/src/native-click-smoke.test.ts +81 -63
  51. package/src/native-lib.ts +39 -1
  52. package/src/terminal-table.test.ts +44 -0
  53. package/src/terminal-table.ts +88 -0
  54. package/src/types.ts +50 -0
  55. package/zig/src/lib.zig +1258 -267
  56. package/zig/src/scroll.zig +213 -0
  57. package/zig/src/window.zig +123 -0
package/zig/src/lib.zig CHANGED
@@ -1,121 +1,362 @@
1
1
  // Native N-API module for usecomputer commands on macOS using Zig.
2
- // First implementation step translates CUA macOS click semantics to Quartz
3
- // events: post mouse down/up pairs at absolute coordinates with click state.
2
+ // Exports direct typed methods (no string command dispatcher) so TS can call
3
+ // high-level native functions and receive structured error objects.
4
4
 
5
5
  const std = @import("std");
6
6
  const builtin = @import("builtin");
7
+ const scroll_impl = @import("scroll.zig");
8
+ const window = @import("window.zig");
7
9
  const napigen = if (builtin.is_test) undefined else @import("napigen");
8
- const c = if (builtin.target.os.tag == .macos) @cImport({
10
+ const c_macos = if (builtin.target.os.tag == .macos) @cImport({
9
11
  @cInclude("CoreGraphics/CoreGraphics.h");
10
12
  @cInclude("CoreFoundation/CoreFoundation.h");
11
13
  @cInclude("ImageIO/ImageIO.h");
12
14
  }) else struct {};
13
15
 
16
+ const c_windows = if (builtin.target.os.tag == .windows) @cImport({
17
+ @cInclude("windows.h");
18
+ }) else struct {};
19
+
20
+ const c_x11 = if (builtin.target.os.tag == .linux) @cImport({
21
+ @cInclude("X11/Xlib.h");
22
+ @cInclude("X11/keysym.h");
23
+ @cInclude("X11/extensions/XTest.h");
24
+ }) else struct {};
25
+
26
+ const c = c_macos;
27
+ const screenshot_max_long_edge_px: f64 = 1568;
28
+
29
+ const mac_keycode = struct {
30
+ const a = 0x00;
31
+ const s = 0x01;
32
+ const d = 0x02;
33
+ const f = 0x03;
34
+ const h = 0x04;
35
+ const g = 0x05;
36
+ const z = 0x06;
37
+ const x = 0x07;
38
+ const c = 0x08;
39
+ const v = 0x09;
40
+ const b = 0x0B;
41
+ const q = 0x0C;
42
+ const w = 0x0D;
43
+ const e = 0x0E;
44
+ const r = 0x0F;
45
+ const y = 0x10;
46
+ const t = 0x11;
47
+ const one = 0x12;
48
+ const two = 0x13;
49
+ const three = 0x14;
50
+ const four = 0x15;
51
+ const six = 0x16;
52
+ const five = 0x17;
53
+ const equal = 0x18;
54
+ const nine = 0x19;
55
+ const seven = 0x1A;
56
+ const minus = 0x1B;
57
+ const eight = 0x1C;
58
+ const zero = 0x1D;
59
+ const right_bracket = 0x1E;
60
+ const o = 0x1F;
61
+ const u = 0x20;
62
+ const left_bracket = 0x21;
63
+ const i = 0x22;
64
+ const p = 0x23;
65
+ const l = 0x25;
66
+ const j = 0x26;
67
+ const quote = 0x27;
68
+ const k = 0x28;
69
+ const semicolon = 0x29;
70
+ const backslash = 0x2A;
71
+ const comma = 0x2B;
72
+ const slash = 0x2C;
73
+ const n = 0x2D;
74
+ const m = 0x2E;
75
+ const period = 0x2F;
76
+ const tab = 0x30;
77
+ const space = 0x31;
78
+ const grave = 0x32;
79
+ const delete = 0x33;
80
+ const enter = 0x24;
81
+ const escape = 0x35;
82
+ const command = 0x37;
83
+ const shift = 0x38;
84
+ const option = 0x3A;
85
+ const control = 0x3B;
86
+ const fn_key = 0x3F;
87
+ const f1 = 0x7A;
88
+ const f2 = 0x78;
89
+ const f3 = 0x63;
90
+ const f4 = 0x76;
91
+ const f5 = 0x60;
92
+ const f6 = 0x61;
93
+ const f7 = 0x62;
94
+ const f8 = 0x64;
95
+ const f9 = 0x65;
96
+ const f10 = 0x6D;
97
+ const f11 = 0x67;
98
+ const f12 = 0x6F;
99
+ const home = 0x73;
100
+ const page_up = 0x74;
101
+ const forward_delete = 0x75;
102
+ const end = 0x77;
103
+ const page_down = 0x79;
104
+ const left_arrow = 0x7B;
105
+ const right_arrow = 0x7C;
106
+ const down_arrow = 0x7D;
107
+ const up_arrow = 0x7E;
108
+ };
109
+
14
110
  pub const std_options: std.Options = .{
15
111
  .log_level = .err,
16
112
  };
17
113
 
18
- fn makeOkJson(allocator: std.mem.Allocator, data_json: []const u8) ![]const u8 {
19
- return std.fmt.allocPrint(allocator, "{{\"ok\":true,\"data\":{s}}}", .{data_json});
20
- }
114
+ const DisplayInfoOutput = struct {
115
+ id: u32,
116
+ index: u32,
117
+ name: []const u8,
118
+ x: f64,
119
+ y: f64,
120
+ width: f64,
121
+ height: f64,
122
+ scale: f64,
123
+ isPrimary: bool,
124
+ };
125
+
126
+ const WindowInfoOutput = struct {
127
+ id: u32,
128
+ ownerPid: i32,
129
+ ownerName: []const u8,
130
+ title: []const u8,
131
+ x: f64,
132
+ y: f64,
133
+ width: f64,
134
+ height: f64,
135
+ desktopIndex: u32,
136
+ };
137
+
138
+ const NativeErrorObject = struct {
139
+ code: []const u8,
140
+ message: []const u8,
141
+ command: []const u8,
142
+ };
143
+
144
+ const CommandResult = struct {
145
+ ok: bool,
146
+ @"error": ?NativeErrorObject = null,
147
+ };
21
148
 
22
- fn makeErrorJson(allocator: std.mem.Allocator, message: []const u8) ![]const u8 {
23
- return std.fmt.allocPrint(allocator, "{{\"ok\":false,\"error\":\"{s}\"}}", .{message});
149
+ fn DataResult(comptime T: type) type {
150
+ return struct {
151
+ ok: bool,
152
+ data: ?T = null,
153
+ @"error": ?NativeErrorObject = null,
154
+ };
24
155
  }
25
156
 
26
- fn execute(command: []const u8, payload_json: []const u8) ![]const u8 {
27
- const allocator = std.heap.c_allocator;
157
+ fn okCommand() CommandResult {
158
+ return .{ .ok = true };
159
+ }
28
160
 
29
- if (std.mem.eql(u8, command, "click")) {
30
- return executeClickCommand(allocator, payload_json);
31
- }
32
- if (std.mem.eql(u8, command, "mouse-move")) {
33
- return executeMouseMoveCommand(allocator, payload_json);
34
- }
35
- if (std.mem.eql(u8, command, "mouse-down")) {
36
- return executeMouseDownCommand(allocator, payload_json);
37
- }
38
- if (std.mem.eql(u8, command, "mouse-up")) {
39
- return executeMouseUpCommand(allocator, payload_json);
40
- }
41
- if (std.mem.eql(u8, command, "mouse-position")) {
42
- return executeMousePositionCommand(allocator);
43
- }
44
- if (std.mem.eql(u8, command, "hover")) {
45
- return executeHoverCommand(allocator, payload_json);
46
- }
47
- if (std.mem.eql(u8, command, "drag")) {
48
- return executeDragCommand(allocator, payload_json);
49
- }
161
+ fn failCommand(command: []const u8, code: []const u8, message: []const u8) CommandResult {
162
+ return .{
163
+ .ok = false,
164
+ .@"error" = .{
165
+ .code = code,
166
+ .message = message,
167
+ .command = command,
168
+ },
169
+ };
170
+ }
50
171
 
51
- if (std.mem.eql(u8, command, "display-list")) {
52
- return makeOkJson(allocator, "[]");
53
- }
54
- if (std.mem.eql(u8, command, "clipboard-get")) {
55
- return makeOkJson(allocator, "{\"text\":\"\"}");
56
- }
57
- if (std.mem.eql(u8, command, "screenshot")) {
58
- return executeScreenshotCommand(allocator, payload_json);
59
- }
172
+ fn okData(comptime T: type, value: T) DataResult(T) {
173
+ return .{
174
+ .ok = true,
175
+ .data = value,
176
+ };
177
+ }
60
178
 
61
- if (
62
- std.mem.eql(u8, command, "type-text") or
63
- std.mem.eql(u8, command, "press") or
64
- std.mem.eql(u8, command, "scroll") or
65
- std.mem.eql(u8, command, "clipboard-set")
66
- ) {
67
- const message = try std.fmt.allocPrint(allocator, "TODO not implemented: {s}", .{command});
68
- return makeErrorJson(allocator, message);
69
- }
179
+ fn failData(comptime T: type, command: []const u8, code: []const u8, message: []const u8) DataResult(T) {
180
+ return .{
181
+ .ok = false,
182
+ .@"error" = .{
183
+ .code = code,
184
+ .message = message,
185
+ .command = command,
186
+ },
187
+ };
188
+ }
70
189
 
71
- return makeErrorJson(allocator, "unknown command");
190
+ fn todoNotImplemented(command: []const u8) CommandResult {
191
+ return failCommand(command, "TODO_NOT_IMPLEMENTED", "TODO not implemented");
72
192
  }
73
193
 
74
- const ClickPoint = struct {
194
+ const Point = struct {
75
195
  x: f64,
76
196
  y: f64,
77
197
  };
78
198
 
79
- const ClickPayload = struct {
80
- point: ClickPoint,
199
+ const MouseButtonKind = enum {
200
+ left,
201
+ right,
202
+ middle,
203
+ };
204
+
205
+ const ClickInput = struct {
206
+ point: Point,
81
207
  button: ?[]const u8 = null,
82
- count: ?u32 = null,
208
+ count: ?f64 = null,
209
+ };
210
+
211
+ const MouseMoveInput = Point;
212
+
213
+ const MouseButtonInput = struct {
214
+ button: ?[]const u8 = null,
215
+ };
216
+
217
+ const DragInput = struct {
218
+ from: Point,
219
+ to: Point,
220
+ durationMs: ?f64 = null,
221
+ button: ?[]const u8 = null,
222
+ };
223
+
224
+ const ScreenshotRegion = struct {
225
+ x: f64,
226
+ y: f64,
227
+ width: f64,
228
+ height: f64,
229
+ };
230
+
231
+ const ScreenshotInput = struct {
232
+ path: ?[]const u8 = null,
233
+ display: ?f64 = null,
234
+ window: ?f64 = null,
235
+ region: ?ScreenshotRegion = null,
236
+ annotate: ?bool = null,
237
+ };
238
+
239
+ const ScreenshotOutput = struct {
240
+ path: []const u8,
241
+ desktopIndex: f64,
242
+ captureX: f64,
243
+ captureY: f64,
244
+ captureWidth: f64,
245
+ captureHeight: f64,
246
+ imageWidth: f64,
247
+ imageHeight: f64,
248
+ };
249
+
250
+ const SelectedDisplay = struct {
251
+ id: c.CGDirectDisplayID,
252
+ index: usize,
253
+ bounds: c.CGRect,
254
+ };
255
+
256
+ const ScreenshotCapture = struct {
257
+ image: c.CGImageRef,
258
+ capture_x: f64,
259
+ capture_y: f64,
260
+ capture_width: f64,
261
+ capture_height: f64,
262
+ desktop_index: usize,
263
+ };
264
+
265
+ const ScaledScreenshotImage = struct {
266
+ image: c.CGImageRef,
267
+ width: f64,
268
+ height: f64,
269
+ };
270
+
271
+ const TypeTextInput = struct {
272
+ text: []const u8,
273
+ delayMs: ?f64 = null,
274
+ };
275
+
276
+ const PressInput = struct {
277
+ key: []const u8,
278
+ count: ?f64 = null,
279
+ delayMs: ?f64 = null,
280
+ };
281
+
282
+ const ScrollInput = struct {
283
+ direction: []const u8,
284
+ amount: f64,
285
+ at: ?Point = null,
83
286
  };
84
287
 
85
- fn executeClickCommand(allocator: std.mem.Allocator, payload_json: []const u8) ![]const u8 {
288
+ const ClipboardSetInput = struct {
289
+ text: []const u8,
290
+ };
291
+
292
+ pub fn screenshot(input: ScreenshotInput) DataResult(ScreenshotOutput) {
86
293
  if (builtin.target.os.tag != .macos) {
87
- return makeErrorJson(allocator, "click is only supported on macOS");
294
+ return failData(ScreenshotOutput, "screenshot", "UNSUPPORTED_PLATFORM", "screenshot is only supported on macOS");
88
295
  }
89
296
 
90
- var parsed = std.json.parseFromSlice(ClickPayload, allocator, payload_json, .{
91
- .ignore_unknown_fields = true,
297
+ _ = input.annotate;
298
+ const output_path = input.path orelse "./screenshot.png";
299
+
300
+ const capture = createScreenshotImage(.{
301
+ .display_index = input.display,
302
+ .window_id = input.window,
303
+ .region = input.region,
304
+ }) catch {
305
+ return failData(ScreenshotOutput, "screenshot", "CAPTURE_FAILED", "failed to capture screenshot image");
306
+ };
307
+ defer c.CFRelease(capture.image);
308
+
309
+ const scaled_image = scaleScreenshotImageIfNeeded(capture.image) catch {
310
+ return failData(ScreenshotOutput, "screenshot", "SCALE_FAILED", "failed to scale screenshot image");
311
+ };
312
+ defer c.CFRelease(scaled_image.image);
313
+
314
+ writeScreenshotPng(.{
315
+ .image = scaled_image.image,
316
+ .output_path = output_path,
92
317
  }) catch {
93
- return makeErrorJson(allocator, "invalid click payload json");
318
+ return failData(ScreenshotOutput, "screenshot", "WRITE_FAILED", "failed to write screenshot file");
94
319
  };
95
- defer parsed.deinit();
96
320
 
97
- const click_payload = parsed.value;
98
- const click_count: u32 = if (click_payload.count) |count| blk: {
99
- if (count == 0) {
321
+ return okData(ScreenshotOutput, .{
322
+ .path = output_path,
323
+ .desktopIndex = @as(f64, @floatFromInt(capture.desktop_index)),
324
+ .captureX = capture.capture_x,
325
+ .captureY = capture.capture_y,
326
+ .captureWidth = capture.capture_width,
327
+ .captureHeight = capture.capture_height,
328
+ .imageWidth = scaled_image.width,
329
+ .imageHeight = scaled_image.height,
330
+ });
331
+ }
332
+
333
+ pub fn click(input: ClickInput) CommandResult {
334
+ if (builtin.target.os.tag != .macos) {
335
+ return failCommand("click", "UNSUPPORTED_PLATFORM", "click is only supported on macOS");
336
+ }
337
+
338
+ const click_count: u32 = if (input.count) |count| blk: {
339
+ const normalized = @as(i64, @intFromFloat(std.math.round(count)));
340
+ if (normalized <= 0) {
100
341
  break :blk 1;
101
342
  }
102
- break :blk count;
343
+ break :blk @as(u32, @intCast(normalized));
103
344
  } else 1;
104
345
 
105
- const button_kind = resolveMouseButton(click_payload.button orelse "left") catch {
106
- return makeErrorJson(allocator, "invalid click button");
346
+ const button_kind = resolveMouseButton(input.button orelse "left") catch {
347
+ return failCommand("click", "INVALID_INPUT", "invalid click button");
107
348
  };
108
349
 
109
350
  const point: c.CGPoint = .{
110
- .x = click_payload.point.x,
111
- .y = click_payload.point.y,
351
+ .x = input.point.x,
352
+ .y = input.point.y,
112
353
  };
113
354
 
114
355
  var index: u32 = 0;
115
356
  while (index < click_count) : (index += 1) {
116
357
  const click_state = @as(i64, @intCast(index + 1));
117
358
  postClickPair(point, button_kind, click_state) catch {
118
- return makeErrorJson(allocator, "failed to post click event");
359
+ return failCommand("click", "EVENT_POST_FAILED", "failed to post click event");
119
360
  };
120
361
 
121
362
  if (index + 1 < click_count) {
@@ -123,299 +364,1035 @@ fn executeClickCommand(allocator: std.mem.Allocator, payload_json: []const u8) !
123
364
  }
124
365
  }
125
366
 
126
- return makeOkJson(allocator, "null");
367
+ return okCommand();
127
368
  }
128
369
 
129
- const MouseMovePayload = struct {
130
- x: f64,
131
- y: f64,
132
- };
370
+ pub fn mouseMove(input: MouseMoveInput) CommandResult {
371
+ if (builtin.target.os.tag != .macos) {
372
+ return failCommand("mouse-move", "UNSUPPORTED_PLATFORM", "mouse-move is only supported on macOS");
373
+ }
133
374
 
134
- const MouseButtonPayload = struct {
135
- button: ?[]const u8 = null,
136
- };
375
+ const point: c.CGPoint = .{
376
+ .x = input.x,
377
+ .y = input.y,
378
+ };
379
+ moveCursorToPoint(point) catch {
380
+ return failCommand("mouse-move", "EVENT_POST_FAILED", "failed to move mouse cursor");
381
+ };
137
382
 
138
- const DragPayload = struct {
139
- from: ClickPoint,
140
- to: ClickPoint,
141
- durationMs: ?u64 = null,
142
- button: ?[]const u8 = null,
143
- };
383
+ return okCommand();
384
+ }
144
385
 
145
- const ScreenshotRegion = struct {
146
- x: f64,
147
- y: f64,
148
- width: f64,
149
- height: f64,
150
- };
386
+ pub fn mouseDown(input: MouseButtonInput) CommandResult {
387
+ return handleMouseButtonInput(.{ .input = input, .is_down = true });
388
+ }
151
389
 
152
- const ScreenshotPayload = struct {
153
- path: ?[]const u8 = null,
154
- display: ?usize = null,
155
- region: ?ScreenshotRegion = null,
156
- };
390
+ pub fn mouseUp(input: MouseButtonInput) CommandResult {
391
+ return handleMouseButtonInput(.{ .input = input, .is_down = false });
392
+ }
157
393
 
158
- fn executeScreenshotCommand(allocator: std.mem.Allocator, payload_json: []const u8) ![]const u8 {
394
+ fn handleMouseButtonInput(args: struct {
395
+ input: MouseButtonInput,
396
+ is_down: bool,
397
+ }) CommandResult {
159
398
  if (builtin.target.os.tag != .macos) {
160
- return makeErrorJson(allocator, "screenshot is only supported on macOS");
399
+ return failCommand("mouse-button", "UNSUPPORTED_PLATFORM", "mouse button events are only supported on macOS");
161
400
  }
162
401
 
163
- var parsed = std.json.parseFromSlice(ScreenshotPayload, allocator, payload_json, .{
164
- .ignore_unknown_fields = true,
165
- }) catch {
166
- return makeErrorJson(allocator, "invalid screenshot payload json");
402
+ const button_kind = resolveMouseButton(args.input.button orelse "left") catch {
403
+ return failCommand("mouse-button", "INVALID_INPUT", "invalid mouse button");
167
404
  };
168
- defer parsed.deinit();
169
405
 
170
- const screenshot_payload = parsed.value;
171
- const output_path = screenshot_payload.path orelse "./screenshot.png";
406
+ const point = currentCursorPoint() catch {
407
+ return failCommand("mouse-button", "CURSOR_READ_FAILED", "failed to read cursor position");
408
+ };
172
409
 
173
- const image = createScreenshotImage(.{
174
- .display_index = screenshot_payload.display,
175
- .region = screenshot_payload.region,
176
- }) catch {
177
- return makeErrorJson(allocator, "failed to capture screenshot image");
410
+ postMouseButtonEvent(point, button_kind, args.is_down, 1) catch {
411
+ return failCommand("mouse-button", "EVENT_POST_FAILED", "failed to post mouse button event");
178
412
  };
179
- defer c.CFRelease(image);
180
413
 
181
- writeScreenshotPng(.{
182
- .image = image,
183
- .output_path = output_path,
184
- }) catch {
185
- return makeErrorJson(allocator, "failed to write screenshot file");
414
+ return okCommand();
415
+ }
416
+
417
+ pub fn mousePosition() DataResult(Point) {
418
+ if (builtin.target.os.tag != .macos) {
419
+ return failData(Point, "mouse-position", "UNSUPPORTED_PLATFORM", "mouse-position is only supported on macOS");
420
+ }
421
+
422
+ const point = currentCursorPoint() catch {
423
+ return failData(Point, "mouse-position", "CURSOR_READ_FAILED", "failed to read cursor position");
186
424
  };
187
425
 
188
- const path_json = try std.fmt.allocPrint(allocator, "\"{s}\"", .{output_path});
189
- const payload_json_response = try std.fmt.allocPrint(allocator, "{{\"path\":{s}}}", .{path_json});
190
- return makeOkJson(allocator, payload_json_response);
426
+ return okData(Point, .{ .x = std.math.round(point.x), .y = std.math.round(point.y) });
191
427
  }
192
428
 
193
- fn createScreenshotImage(input: struct {
194
- display_index: ?usize,
195
- region: ?ScreenshotRegion,
196
- }) !c.CGImageRef {
197
- const display_id = resolveDisplayId(input.display_index) catch {
198
- return error.DisplayResolutionFailed;
429
+ pub fn hover(input: Point) CommandResult {
430
+ return mouseMove(input);
431
+ }
432
+
433
+ pub fn drag(input: DragInput) CommandResult {
434
+ if (builtin.target.os.tag != .macos) {
435
+ return failCommand("drag", "UNSUPPORTED_PLATFORM", "drag is only supported on macOS");
436
+ }
437
+
438
+ const button_kind = resolveMouseButton(input.button orelse "left") catch {
439
+ return failCommand("drag", "INVALID_INPUT", "invalid drag button");
199
440
  };
200
441
 
201
- if (input.region) |region| {
202
- const rect: c.CGRect = .{
203
- .origin = .{ .x = region.x, .y = region.y },
204
- .size = .{ .width = region.width, .height = region.height },
442
+ const from: c.CGPoint = .{ .x = input.from.x, .y = input.from.y };
443
+ const to: c.CGPoint = .{ .x = input.to.x, .y = input.to.y };
444
+
445
+ moveCursorToPoint(from) catch {
446
+ return failCommand("drag", "EVENT_POST_FAILED", "failed to move cursor to drag origin");
447
+ };
448
+
449
+ postMouseButtonEvent(from, button_kind, true, 1) catch {
450
+ return failCommand("drag", "EVENT_POST_FAILED", "failed to post drag mouse-down");
451
+ };
452
+
453
+ const duration_ms = if (input.durationMs) |value| blk: {
454
+ const normalized = @as(i64, @intFromFloat(std.math.round(value)));
455
+ if (normalized <= 0) {
456
+ break :blk 400;
457
+ }
458
+ break :blk normalized;
459
+ } else 400;
460
+ const total_duration_ns = @as(u64, @intCast(duration_ms)) * std.time.ns_per_ms;
461
+ const step_count: u64 = 16;
462
+ const step_duration_ns = if (step_count == 0) 0 else total_duration_ns / step_count;
463
+
464
+ var index: u64 = 1;
465
+ while (index <= step_count) : (index += 1) {
466
+ const fraction = @as(f64, @floatFromInt(index)) / @as(f64, @floatFromInt(step_count));
467
+ const next_point: c.CGPoint = .{
468
+ .x = from.x + (to.x - from.x) * fraction,
469
+ .y = from.y + (to.y - from.y) * fraction,
205
470
  };
206
- const region_image = c.CGDisplayCreateImageForRect(display_id, rect);
207
- if (region_image == null) {
208
- return error.CaptureFailed;
471
+
472
+ moveCursorToPoint(next_point) catch {
473
+ return failCommand("drag", "EVENT_POST_FAILED", "failed during drag cursor movement");
474
+ };
475
+
476
+ if (step_duration_ns > 0 and index < step_count) {
477
+ std.Thread.sleep(step_duration_ns);
209
478
  }
210
- return region_image;
211
479
  }
212
480
 
213
- const full_image = c.CGDisplayCreateImage(display_id);
214
- if (full_image == null) {
215
- return error.CaptureFailed;
216
- }
217
- return full_image;
481
+ postMouseButtonEvent(to, button_kind, false, 1) catch {
482
+ return failCommand("drag", "EVENT_POST_FAILED", "failed to post drag mouse-up");
483
+ };
484
+
485
+ return okCommand();
218
486
  }
219
487
 
220
- fn resolveDisplayId(display_index: ?usize) !c.CGDirectDisplayID {
221
- const selected_index = display_index orelse 0;
488
+ pub fn displayList() DataResult([]const u8) {
489
+ if (builtin.target.os.tag != .macos) {
490
+ return failData([]const u8, "display-list", "UNSUPPORTED_PLATFORM", "display-list is only supported on macOS");
491
+ }
492
+
222
493
  var display_ids: [16]c.CGDirectDisplayID = undefined;
223
494
  var display_count: u32 = 0;
224
495
  const list_result = c.CGGetActiveDisplayList(display_ids.len, &display_ids, &display_count);
225
496
  if (list_result != c.kCGErrorSuccess) {
226
- return error.DisplayQueryFailed;
497
+ return failData([]const u8, "display-list", "DISPLAY_QUERY_FAILED", "failed to query active displays");
227
498
  }
228
- if (selected_index >= display_count) {
229
- return error.InvalidDisplayIndex;
499
+
500
+ var write_buffer: [32 * 1024]u8 = undefined;
501
+ var stream = std.io.fixedBufferStream(&write_buffer);
502
+ const writer = stream.writer();
503
+
504
+ writer.writeByte('[') catch {
505
+ return failData([]const u8, "display-list", "SERIALIZE_FAILED", "failed to serialize display list");
506
+ };
507
+
508
+ var i: usize = 0;
509
+ while (i < display_count) : (i += 1) {
510
+ if (i > 0) {
511
+ writer.writeByte(',') catch {
512
+ return failData([]const u8, "display-list", "SERIALIZE_FAILED", "failed to serialize display list");
513
+ };
514
+ }
515
+
516
+ const display_id = display_ids[i];
517
+ const bounds = c.CGDisplayBounds(display_id);
518
+ var name_buffer: [64]u8 = undefined;
519
+ const fallback_name = std.fmt.bufPrint(&name_buffer, "Display {d}", .{display_id}) catch "Display";
520
+ const item = DisplayInfoOutput{
521
+ .id = display_id,
522
+ .index = @intCast(i),
523
+ .name = fallback_name,
524
+ .x = std.math.round(bounds.origin.x),
525
+ .y = std.math.round(bounds.origin.y),
526
+ .width = std.math.round(bounds.size.width),
527
+ .height = std.math.round(bounds.size.height),
528
+ .scale = 1,
529
+ .isPrimary = c.CGDisplayIsMain(display_id) != 0,
530
+ };
531
+
532
+ writer.print("{f}", .{std.json.fmt(item, .{})}) catch {
533
+ return failData([]const u8, "display-list", "SERIALIZE_FAILED", "failed to serialize display list");
534
+ };
230
535
  }
231
- return display_ids[selected_index];
536
+
537
+ writer.writeByte(']') catch {
538
+ return failData([]const u8, "display-list", "SERIALIZE_FAILED", "failed to serialize display list");
539
+ };
540
+
541
+ // TODO: Add Mission Control desktop/space enumeration via private SkyLight APIs.
542
+ const payload = std.heap.c_allocator.dupe(u8, stream.getWritten()) catch {
543
+ return failData([]const u8, "display-list", "ALLOC_FAILED", "failed to allocate display list response");
544
+ };
545
+ return okData([]const u8, payload);
232
546
  }
233
547
 
234
- fn writeScreenshotPng(input: struct {
235
- image: c.CGImageRef,
236
- output_path: []const u8,
237
- }) !void {
238
- const path_as_u8: [*]const u8 = @ptrCast(input.output_path.ptr);
239
- const file_url = c.CFURLCreateFromFileSystemRepresentation(
240
- null,
241
- path_as_u8,
242
- @as(c_long, @intCast(input.output_path.len)),
243
- 0,
244
- );
245
- if (file_url == null) {
246
- return error.FileUrlCreateFailed;
548
+ pub fn windowList() DataResult([]const u8) {
549
+ if (builtin.target.os.tag != .macos) {
550
+ return failData([]const u8, "window-list", "UNSUPPORTED_PLATFORM", "window-list is only supported on macOS");
247
551
  }
248
- defer c.CFRelease(file_url);
249
552
 
250
- const png_type = c.CFStringCreateWithCString(null, "public.png", c.kCFStringEncodingUTF8);
251
- if (png_type == null) {
252
- return error.PngTypeCreateFailed;
553
+ const payload = serializeWindowListJson() catch {
554
+ return failData([]const u8, "window-list", "WINDOW_QUERY_FAILED", "failed to query visible windows");
555
+ };
556
+ return okData([]const u8, payload);
557
+ }
558
+
559
+ pub fn clipboardGet() DataResult([]const u8) {
560
+ return failData([]const u8, "clipboard-get", "TODO_NOT_IMPLEMENTED", "TODO not implemented: clipboard-get");
561
+ }
562
+
563
+ pub fn clipboardSet(input: ClipboardSetInput) CommandResult {
564
+ _ = input;
565
+ return todoNotImplemented("clipboard-set");
566
+ }
567
+
568
+ pub fn typeText(input: TypeTextInput) CommandResult {
569
+ switch (builtin.target.os.tag) {
570
+ .macos => {
571
+ typeTextMacos(input) catch |err| {
572
+ return failCommand("type-text", "EVENT_POST_FAILED", @errorName(err));
573
+ };
574
+ return okCommand();
575
+ },
576
+ .windows => {
577
+ typeTextWindows(input) catch |err| {
578
+ return failCommand("type-text", "EVENT_POST_FAILED", @errorName(err));
579
+ };
580
+ return okCommand();
581
+ },
582
+ .linux => {
583
+ typeTextX11(input) catch |err| {
584
+ return failCommand("type-text", "EVENT_POST_FAILED", @errorName(err));
585
+ };
586
+ return okCommand();
587
+ },
588
+ else => {
589
+ return failCommand("type-text", "UNSUPPORTED_PLATFORM", "type-text is unsupported on this platform");
590
+ },
253
591
  }
254
- defer c.CFRelease(png_type);
592
+ }
255
593
 
256
- const destination = c.CGImageDestinationCreateWithURL(file_url, png_type, 1, null);
257
- if (destination == null) {
258
- return error.ImageDestinationCreateFailed;
594
+ pub fn press(input: PressInput) CommandResult {
595
+ switch (builtin.target.os.tag) {
596
+ .macos => {
597
+ pressMacos(input) catch |err| {
598
+ return failCommand("press", "EVENT_POST_FAILED", @errorName(err));
599
+ };
600
+ return okCommand();
601
+ },
602
+ .windows => {
603
+ pressWindows(input) catch |err| {
604
+ return failCommand("press", "EVENT_POST_FAILED", @errorName(err));
605
+ };
606
+ return okCommand();
607
+ },
608
+ .linux => {
609
+ pressX11(input) catch |err| {
610
+ return failCommand("press", "EVENT_POST_FAILED", @errorName(err));
611
+ };
612
+ return okCommand();
613
+ },
614
+ else => {
615
+ return failCommand("press", "UNSUPPORTED_PLATFORM", "press is unsupported on this platform");
616
+ },
259
617
  }
260
- defer c.CFRelease(destination);
618
+ }
261
619
 
262
- c.CGImageDestinationAddImage(destination, input.image, null);
263
- const did_finalize = c.CGImageDestinationFinalize(destination);
264
- if (!did_finalize) {
265
- return error.ImageDestinationFinalizeFailed;
620
+ pub fn scroll(input: ScrollInput) CommandResult {
621
+ scroll_impl.scroll(.{
622
+ .direction = input.direction,
623
+ .amount = input.amount,
624
+ .at_x = if (input.at) |point| point.x else null,
625
+ .at_y = if (input.at) |point| point.y else null,
626
+ }) catch |err| {
627
+ const error_name = @errorName(err);
628
+ if (std.mem.eql(u8, error_name, "InvalidDirection") or
629
+ std.mem.eql(u8, error_name, "InvalidAmount") or
630
+ std.mem.eql(u8, error_name, "AmountTooLarge") or
631
+ std.mem.eql(u8, error_name, "InvalidPoint"))
632
+ {
633
+ return failCommand("scroll", "INVALID_INPUT", error_name);
634
+ }
635
+ return failCommand("scroll", "EVENT_POST_FAILED", error_name);
636
+ };
637
+ return okCommand();
638
+ }
639
+
640
+ const ParsedPress = struct {
641
+ key: []const u8,
642
+ cmd: bool = false,
643
+ alt: bool = false,
644
+ ctrl: bool = false,
645
+ shift: bool = false,
646
+ fn_key: bool = false,
647
+ };
648
+
649
+ fn parsePressKey(key_input: []const u8) !ParsedPress {
650
+ var parsed: ParsedPress = .{ .key = "" };
651
+ var saw_key = false;
652
+ var parts = std.mem.splitScalar(u8, key_input, '+');
653
+ while (parts.next()) |part| {
654
+ const trimmed = std.mem.trim(u8, part, " \t\r\n");
655
+ if (trimmed.len == 0) {
656
+ continue;
657
+ }
658
+
659
+ if (std.ascii.eqlIgnoreCase(trimmed, "cmd") or std.ascii.eqlIgnoreCase(trimmed, "command") or std.ascii.eqlIgnoreCase(trimmed, "meta")) {
660
+ parsed.cmd = true;
661
+ continue;
662
+ }
663
+ if (std.ascii.eqlIgnoreCase(trimmed, "alt") or std.ascii.eqlIgnoreCase(trimmed, "option")) {
664
+ parsed.alt = true;
665
+ continue;
666
+ }
667
+ if (std.ascii.eqlIgnoreCase(trimmed, "ctrl") or std.ascii.eqlIgnoreCase(trimmed, "control")) {
668
+ parsed.ctrl = true;
669
+ continue;
670
+ }
671
+ if (std.ascii.eqlIgnoreCase(trimmed, "shift")) {
672
+ parsed.shift = true;
673
+ continue;
674
+ }
675
+ if (std.ascii.eqlIgnoreCase(trimmed, "fn")) {
676
+ parsed.fn_key = true;
677
+ continue;
678
+ }
679
+
680
+ if (saw_key) {
681
+ return error.MultipleMainKeys;
682
+ }
683
+ parsed.key = trimmed;
684
+ saw_key = true;
266
685
  }
686
+
687
+ if (!saw_key) {
688
+ return error.MissingMainKey;
689
+ }
690
+ return parsed;
267
691
  }
268
692
 
269
- fn executeMouseMoveCommand(allocator: std.mem.Allocator, payload_json: []const u8) ![]const u8 {
270
- if (builtin.target.os.tag != .macos) {
271
- return makeErrorJson(allocator, "mouse-move is only supported on macOS");
693
+ fn normalizedCount(value: ?f64) u32 {
694
+ if (value) |count| {
695
+ const rounded = @as(i64, @intFromFloat(std.math.round(count)));
696
+ if (rounded > 0) {
697
+ return @as(u32, @intCast(rounded));
698
+ }
272
699
  }
700
+ return 1;
701
+ }
273
702
 
274
- var parsed = std.json.parseFromSlice(MouseMovePayload, allocator, payload_json, .{}) catch {
275
- return makeErrorJson(allocator, "invalid mouse-move payload json");
276
- };
277
- defer parsed.deinit();
703
+ fn normalizedDelayNs(value: ?f64) u64 {
704
+ if (value) |delay_ms| {
705
+ const rounded = @as(i64, @intFromFloat(std.math.round(delay_ms)));
706
+ if (rounded > 0) {
707
+ return @as(u64, @intCast(rounded)) * std.time.ns_per_ms;
708
+ }
709
+ }
710
+ return 0;
711
+ }
278
712
 
279
- const point: c.CGPoint = .{
280
- .x = parsed.value.x,
281
- .y = parsed.value.y,
282
- };
283
- moveCursorToPoint(point) catch {
284
- return makeErrorJson(allocator, "failed to move mouse cursor");
285
- };
713
+ fn codepointToUtf16(codepoint: u21) !struct { units: [2]u16, len: usize } {
714
+ if (codepoint <= 0xD7FF or (codepoint >= 0xE000 and codepoint <= 0xFFFF)) {
715
+ return .{ .units = .{ @as(u16, @intCast(codepoint)), 0 }, .len = 1 };
716
+ }
717
+ if (codepoint >= 0x10000 and codepoint <= 0x10FFFF) {
718
+ const value = codepoint - 0x10000;
719
+ const high = @as(u16, @intCast(0xD800 + (value >> 10)));
720
+ const low = @as(u16, @intCast(0xDC00 + (value & 0x3FF)));
721
+ return .{ .units = .{ high, low }, .len = 2 };
722
+ }
723
+ return error.InvalidCodepoint;
724
+ }
286
725
 
287
- return makeOkJson(allocator, "null");
726
+ fn typeTextMacos(input: TypeTextInput) !void {
727
+ const delay_ns = normalizedDelayNs(input.delayMs);
728
+ var view = try std.unicode.Utf8View.init(input.text);
729
+ var iterator = view.iterator();
730
+ while (iterator.nextCodepoint()) |codepoint| {
731
+ const utf16 = try codepointToUtf16(codepoint);
732
+ const down = c_macos.CGEventCreateKeyboardEvent(null, 0, true) orelse return error.CGEventCreateFailed;
733
+ defer c_macos.CFRelease(down);
734
+ c_macos.CGEventSetFlags(down, 0);
735
+ c_macos.CGEventKeyboardSetUnicodeString(down, @as(c_macos.UniCharCount, @intCast(utf16.len)), @ptrCast(&utf16.units[0]));
736
+ c_macos.CGEventPost(c_macos.kCGHIDEventTap, down);
737
+
738
+ const up = c_macos.CGEventCreateKeyboardEvent(null, 0, false) orelse return error.CGEventCreateFailed;
739
+ defer c_macos.CFRelease(up);
740
+ c_macos.CGEventSetFlags(up, 0);
741
+ c_macos.CGEventKeyboardSetUnicodeString(up, @as(c_macos.UniCharCount, @intCast(utf16.len)), @ptrCast(&utf16.units[0]));
742
+ c_macos.CGEventPost(c_macos.kCGHIDEventTap, up);
743
+
744
+ if (delay_ns > 0) {
745
+ std.Thread.sleep(delay_ns);
746
+ }
747
+ }
288
748
  }
289
749
 
290
- fn executeMouseDownCommand(allocator: std.mem.Allocator, payload_json: []const u8) ![]const u8 {
291
- return executeMouseButtonCommand(allocator, payload_json, true);
750
+ fn keyCodeForMacosKey(key_name: []const u8) !c_macos.CGKeyCode {
751
+ if (key_name.len == 1) {
752
+ const ch = std.ascii.toLower(key_name[0]);
753
+ return switch (ch) {
754
+ 'a' => mac_keycode.a,
755
+ 'b' => mac_keycode.b,
756
+ 'c' => mac_keycode.c,
757
+ 'd' => mac_keycode.d,
758
+ 'e' => mac_keycode.e,
759
+ 'f' => mac_keycode.f,
760
+ 'g' => mac_keycode.g,
761
+ 'h' => mac_keycode.h,
762
+ 'i' => mac_keycode.i,
763
+ 'j' => mac_keycode.j,
764
+ 'k' => mac_keycode.k,
765
+ 'l' => mac_keycode.l,
766
+ 'm' => mac_keycode.m,
767
+ 'n' => mac_keycode.n,
768
+ 'o' => mac_keycode.o,
769
+ 'p' => mac_keycode.p,
770
+ 'q' => mac_keycode.q,
771
+ 'r' => mac_keycode.r,
772
+ 's' => mac_keycode.s,
773
+ 't' => mac_keycode.t,
774
+ 'u' => mac_keycode.u,
775
+ 'v' => mac_keycode.v,
776
+ 'w' => mac_keycode.w,
777
+ 'x' => mac_keycode.x,
778
+ 'y' => mac_keycode.y,
779
+ 'z' => mac_keycode.z,
780
+ '0' => mac_keycode.zero,
781
+ '1' => mac_keycode.one,
782
+ '2' => mac_keycode.two,
783
+ '3' => mac_keycode.three,
784
+ '4' => mac_keycode.four,
785
+ '5' => mac_keycode.five,
786
+ '6' => mac_keycode.six,
787
+ '7' => mac_keycode.seven,
788
+ '8' => mac_keycode.eight,
789
+ '9' => mac_keycode.nine,
790
+ '=' => mac_keycode.equal,
791
+ '-' => mac_keycode.minus,
792
+ '[' => mac_keycode.left_bracket,
793
+ ']' => mac_keycode.right_bracket,
794
+ ';' => mac_keycode.semicolon,
795
+ '\'' => mac_keycode.quote,
796
+ '\\' => mac_keycode.backslash,
797
+ ',' => mac_keycode.comma,
798
+ '.' => mac_keycode.period,
799
+ '/' => mac_keycode.slash,
800
+ '`' => mac_keycode.grave,
801
+ else => error.UnknownKey,
802
+ };
803
+ }
804
+
805
+ if (std.ascii.eqlIgnoreCase(key_name, "enter") or std.ascii.eqlIgnoreCase(key_name, "return")) return mac_keycode.enter;
806
+ if (std.ascii.eqlIgnoreCase(key_name, "tab")) return mac_keycode.tab;
807
+ if (std.ascii.eqlIgnoreCase(key_name, "space")) return mac_keycode.space;
808
+ if (std.ascii.eqlIgnoreCase(key_name, "escape") or std.ascii.eqlIgnoreCase(key_name, "esc")) return mac_keycode.escape;
809
+ if (std.ascii.eqlIgnoreCase(key_name, "backspace")) return mac_keycode.delete;
810
+ if (std.ascii.eqlIgnoreCase(key_name, "delete")) return mac_keycode.forward_delete;
811
+ if (std.ascii.eqlIgnoreCase(key_name, "left")) return mac_keycode.left_arrow;
812
+ if (std.ascii.eqlIgnoreCase(key_name, "right")) return mac_keycode.right_arrow;
813
+ if (std.ascii.eqlIgnoreCase(key_name, "up")) return mac_keycode.up_arrow;
814
+ if (std.ascii.eqlIgnoreCase(key_name, "down")) return mac_keycode.down_arrow;
815
+ if (std.ascii.eqlIgnoreCase(key_name, "home")) return mac_keycode.home;
816
+ if (std.ascii.eqlIgnoreCase(key_name, "end")) return mac_keycode.end;
817
+ if (std.ascii.eqlIgnoreCase(key_name, "pageup")) return mac_keycode.page_up;
818
+ if (std.ascii.eqlIgnoreCase(key_name, "pagedown")) return mac_keycode.page_down;
819
+ if (std.ascii.eqlIgnoreCase(key_name, "f1")) return mac_keycode.f1;
820
+ if (std.ascii.eqlIgnoreCase(key_name, "f2")) return mac_keycode.f2;
821
+ if (std.ascii.eqlIgnoreCase(key_name, "f3")) return mac_keycode.f3;
822
+ if (std.ascii.eqlIgnoreCase(key_name, "f4")) return mac_keycode.f4;
823
+ if (std.ascii.eqlIgnoreCase(key_name, "f5")) return mac_keycode.f5;
824
+ if (std.ascii.eqlIgnoreCase(key_name, "f6")) return mac_keycode.f6;
825
+ if (std.ascii.eqlIgnoreCase(key_name, "f7")) return mac_keycode.f7;
826
+ if (std.ascii.eqlIgnoreCase(key_name, "f8")) return mac_keycode.f8;
827
+ if (std.ascii.eqlIgnoreCase(key_name, "f9")) return mac_keycode.f9;
828
+ if (std.ascii.eqlIgnoreCase(key_name, "f10")) return mac_keycode.f10;
829
+ if (std.ascii.eqlIgnoreCase(key_name, "f11")) return mac_keycode.f11;
830
+ if (std.ascii.eqlIgnoreCase(key_name, "f12")) return mac_keycode.f12;
831
+
832
+ return error.UnknownKey;
292
833
  }
293
834
 
294
- fn executeMouseUpCommand(allocator: std.mem.Allocator, payload_json: []const u8) ![]const u8 {
295
- return executeMouseButtonCommand(allocator, payload_json, false);
835
+ fn postMacosKey(key_code: c_macos.CGKeyCode, is_down: bool, flags: c_macos.CGEventFlags) !void {
836
+ const event = c_macos.CGEventCreateKeyboardEvent(null, key_code, is_down) orelse return error.CGEventCreateFailed;
837
+ defer c_macos.CFRelease(event);
838
+ c_macos.CGEventSetFlags(event, flags);
839
+ c_macos.CGEventPost(c_macos.kCGHIDEventTap, event);
296
840
  }
297
841
 
298
- fn executeMouseButtonCommand(allocator: std.mem.Allocator, payload_json: []const u8, is_down: bool) ![]const u8 {
299
- if (builtin.target.os.tag != .macos) {
300
- return makeErrorJson(allocator, "mouse button events are only supported on macOS");
842
+ fn pressMacos(input: PressInput) !void {
843
+ const parsed = try parsePressKey(input.key);
844
+ const key_code = try keyCodeForMacosKey(parsed.key);
845
+ const repeat_count = normalizedCount(input.count);
846
+ const delay_ns = normalizedDelayNs(input.delayMs);
847
+
848
+ var flags: c_macos.CGEventFlags = 0;
849
+ if (parsed.cmd) flags |= c_macos.kCGEventFlagMaskCommand;
850
+ if (parsed.alt) flags |= c_macos.kCGEventFlagMaskAlternate;
851
+ if (parsed.ctrl) flags |= c_macos.kCGEventFlagMaskControl;
852
+ if (parsed.shift) flags |= c_macos.kCGEventFlagMaskShift;
853
+ if (parsed.fn_key) flags |= c_macos.kCGEventFlagMaskSecondaryFn;
854
+
855
+ var index: u32 = 0;
856
+ while (index < repeat_count) : (index += 1) {
857
+ if (parsed.cmd) try postMacosKey(mac_keycode.command, true, flags);
858
+ if (parsed.alt) try postMacosKey(mac_keycode.option, true, flags);
859
+ if (parsed.ctrl) try postMacosKey(mac_keycode.control, true, flags);
860
+ if (parsed.shift) try postMacosKey(mac_keycode.shift, true, flags);
861
+ if (parsed.fn_key) try postMacosKey(mac_keycode.fn_key, true, flags);
862
+
863
+ try postMacosKey(key_code, true, flags);
864
+ try postMacosKey(key_code, false, flags);
865
+
866
+ if (parsed.fn_key) try postMacosKey(mac_keycode.fn_key, false, flags);
867
+ if (parsed.shift) try postMacosKey(mac_keycode.shift, false, flags);
868
+ if (parsed.ctrl) try postMacosKey(mac_keycode.control, false, flags);
869
+ if (parsed.alt) try postMacosKey(mac_keycode.option, false, flags);
870
+ if (parsed.cmd) try postMacosKey(mac_keycode.command, false, flags);
871
+
872
+ if (delay_ns > 0 and index + 1 < repeat_count) {
873
+ std.Thread.sleep(delay_ns);
874
+ }
875
+ }
876
+ }
877
+
878
+ fn typeTextWindows(input: TypeTextInput) !void {
879
+ const delay_ns = normalizedDelayNs(input.delayMs);
880
+ var view = try std.unicode.Utf8View.init(input.text);
881
+ var iterator = view.iterator();
882
+ while (iterator.nextCodepoint()) |codepoint| {
883
+ const utf16 = try codepointToUtf16(codepoint);
884
+ var unit_index: usize = 0;
885
+ while (unit_index < utf16.len) : (unit_index += 1) {
886
+ const unit = utf16.units[unit_index];
887
+ var down = std.mem.zeroes(c_windows.INPUT);
888
+ down.type = c_windows.INPUT_KEYBOARD;
889
+ down.Anonymous.ki.wVk = 0;
890
+ down.Anonymous.ki.wScan = unit;
891
+ down.Anonymous.ki.dwFlags = c_windows.KEYEVENTF_UNICODE;
892
+ _ = c_windows.SendInput(1, &down, @sizeOf(c_windows.INPUT));
893
+
894
+ var up = down;
895
+ up.Anonymous.ki.dwFlags = c_windows.KEYEVENTF_UNICODE | c_windows.KEYEVENTF_KEYUP;
896
+ _ = c_windows.SendInput(1, &up, @sizeOf(c_windows.INPUT));
897
+ }
898
+
899
+ if (delay_ns > 0) {
900
+ std.Thread.sleep(delay_ns);
901
+ }
301
902
  }
903
+ }
302
904
 
303
- var parsed = std.json.parseFromSlice(MouseButtonPayload, allocator, payload_json, .{}) catch {
304
- return makeErrorJson(allocator, "invalid mouse button payload json");
305
- };
306
- defer parsed.deinit();
905
+ fn keyCodeForWindowsKey(key_name: []const u8) !u16 {
906
+ if (key_name.len == 1) {
907
+ const ch = std.ascii.toUpper(key_name[0]);
908
+ if ((ch >= 'A' and ch <= 'Z') or (ch >= '0' and ch <= '9')) {
909
+ return ch;
910
+ }
911
+ return switch (key_name[0]) {
912
+ '=' => c_windows.VK_OEM_PLUS,
913
+ '-' => c_windows.VK_OEM_MINUS,
914
+ '[' => c_windows.VK_OEM_4,
915
+ ']' => c_windows.VK_OEM_6,
916
+ ';' => c_windows.VK_OEM_1,
917
+ '\'' => c_windows.VK_OEM_7,
918
+ '\\' => c_windows.VK_OEM_5,
919
+ ',' => c_windows.VK_OEM_COMMA,
920
+ '.' => c_windows.VK_OEM_PERIOD,
921
+ '/' => c_windows.VK_OEM_2,
922
+ '`' => c_windows.VK_OEM_3,
923
+ else => error.UnknownKey,
924
+ };
925
+ }
307
926
 
308
- const button_kind = resolveMouseButton(parsed.value.button orelse "left") catch {
309
- return makeErrorJson(allocator, "invalid mouse button");
310
- };
927
+ if (std.ascii.eqlIgnoreCase(key_name, "enter") or std.ascii.eqlIgnoreCase(key_name, "return")) return c_windows.VK_RETURN;
928
+ if (std.ascii.eqlIgnoreCase(key_name, "tab")) return c_windows.VK_TAB;
929
+ if (std.ascii.eqlIgnoreCase(key_name, "space")) return c_windows.VK_SPACE;
930
+ if (std.ascii.eqlIgnoreCase(key_name, "escape") or std.ascii.eqlIgnoreCase(key_name, "esc")) return c_windows.VK_ESCAPE;
931
+ if (std.ascii.eqlIgnoreCase(key_name, "backspace")) return c_windows.VK_BACK;
932
+ if (std.ascii.eqlIgnoreCase(key_name, "delete")) return c_windows.VK_DELETE;
933
+ if (std.ascii.eqlIgnoreCase(key_name, "left")) return c_windows.VK_LEFT;
934
+ if (std.ascii.eqlIgnoreCase(key_name, "right")) return c_windows.VK_RIGHT;
935
+ if (std.ascii.eqlIgnoreCase(key_name, "up")) return c_windows.VK_UP;
936
+ if (std.ascii.eqlIgnoreCase(key_name, "down")) return c_windows.VK_DOWN;
937
+ if (std.ascii.eqlIgnoreCase(key_name, "home")) return c_windows.VK_HOME;
938
+ if (std.ascii.eqlIgnoreCase(key_name, "end")) return c_windows.VK_END;
939
+ if (std.ascii.eqlIgnoreCase(key_name, "pageup")) return c_windows.VK_PRIOR;
940
+ if (std.ascii.eqlIgnoreCase(key_name, "pagedown")) return c_windows.VK_NEXT;
941
+ if (std.ascii.eqlIgnoreCase(key_name, "f1")) return c_windows.VK_F1;
942
+ if (std.ascii.eqlIgnoreCase(key_name, "f2")) return c_windows.VK_F2;
943
+ if (std.ascii.eqlIgnoreCase(key_name, "f3")) return c_windows.VK_F3;
944
+ if (std.ascii.eqlIgnoreCase(key_name, "f4")) return c_windows.VK_F4;
945
+ if (std.ascii.eqlIgnoreCase(key_name, "f5")) return c_windows.VK_F5;
946
+ if (std.ascii.eqlIgnoreCase(key_name, "f6")) return c_windows.VK_F6;
947
+ if (std.ascii.eqlIgnoreCase(key_name, "f7")) return c_windows.VK_F7;
948
+ if (std.ascii.eqlIgnoreCase(key_name, "f8")) return c_windows.VK_F8;
949
+ if (std.ascii.eqlIgnoreCase(key_name, "f9")) return c_windows.VK_F9;
950
+ if (std.ascii.eqlIgnoreCase(key_name, "f10")) return c_windows.VK_F10;
951
+ if (std.ascii.eqlIgnoreCase(key_name, "f11")) return c_windows.VK_F11;
952
+ if (std.ascii.eqlIgnoreCase(key_name, "f12")) return c_windows.VK_F12;
953
+
954
+ return error.UnknownKey;
955
+ }
311
956
 
312
- const point = currentCursorPoint() catch {
313
- return makeErrorJson(allocator, "failed to read cursor position");
314
- };
957
+ fn postWindowsVirtualKey(virtual_key: u16, is_down: bool) void {
958
+ var event = std.mem.zeroes(c_windows.INPUT);
959
+ event.type = c_windows.INPUT_KEYBOARD;
960
+ event.Anonymous.ki.wVk = virtual_key;
961
+ event.Anonymous.ki.wScan = 0;
962
+ event.Anonymous.ki.dwFlags = if (is_down) 0 else c_windows.KEYEVENTF_KEYUP;
963
+ _ = c_windows.SendInput(1, &event, @sizeOf(c_windows.INPUT));
964
+ }
315
965
 
316
- postMouseButtonEvent(point, button_kind, is_down, 1) catch {
317
- return makeErrorJson(allocator, "failed to post mouse button event");
318
- };
966
+ fn pressWindows(input: PressInput) !void {
967
+ const parsed = try parsePressKey(input.key);
968
+ const key_code = try keyCodeForWindowsKey(parsed.key);
969
+ const repeat_count = normalizedCount(input.count);
970
+ const delay_ns = normalizedDelayNs(input.delayMs);
319
971
 
320
- return makeOkJson(allocator, "null");
972
+ var index: u32 = 0;
973
+ while (index < repeat_count) : (index += 1) {
974
+ if (parsed.cmd) postWindowsVirtualKey(c_windows.VK_LWIN, true);
975
+ if (parsed.alt) postWindowsVirtualKey(c_windows.VK_MENU, true);
976
+ if (parsed.ctrl) postWindowsVirtualKey(c_windows.VK_CONTROL, true);
977
+ if (parsed.shift) postWindowsVirtualKey(c_windows.VK_SHIFT, true);
978
+
979
+ postWindowsVirtualKey(key_code, true);
980
+ postWindowsVirtualKey(key_code, false);
981
+
982
+ if (parsed.shift) postWindowsVirtualKey(c_windows.VK_SHIFT, false);
983
+ if (parsed.ctrl) postWindowsVirtualKey(c_windows.VK_CONTROL, false);
984
+ if (parsed.alt) postWindowsVirtualKey(c_windows.VK_MENU, false);
985
+ if (parsed.cmd) postWindowsVirtualKey(c_windows.VK_LWIN, false);
986
+
987
+ if (delay_ns > 0 and index + 1 < repeat_count) {
988
+ std.Thread.sleep(delay_ns);
989
+ }
990
+ }
321
991
  }
322
992
 
323
- fn executeMousePositionCommand(allocator: std.mem.Allocator) ![]const u8 {
324
- if (builtin.target.os.tag != .macos) {
325
- return makeErrorJson(allocator, "mouse-position is only supported on macOS");
993
+ fn typeTextX11(input: TypeTextInput) !void {
994
+ const delay_ns = normalizedDelayNs(input.delayMs);
995
+ const display = c_x11.XOpenDisplay(null) orelse return error.XOpenDisplayFailed;
996
+ defer _ = c_x11.XCloseDisplay(display);
997
+
998
+ for (input.text) |byte| {
999
+ if (byte >= 0x80) {
1000
+ return error.NonAsciiUnsupported;
1001
+ }
1002
+ var key_name = [_:0]u8{ byte, 0 };
1003
+ const key_sym = c_x11.XStringToKeysym(&key_name);
1004
+ if (key_sym == 0) {
1005
+ return error.UnknownKey;
1006
+ }
1007
+ const key_code = c_x11.XKeysymToKeycode(display, @intCast(key_sym));
1008
+ _ = c_x11.XTestFakeKeyEvent(display, key_code, c_x11.True, c_x11.CurrentTime);
1009
+ _ = c_x11.XTestFakeKeyEvent(display, key_code, c_x11.False, c_x11.CurrentTime);
1010
+ _ = c_x11.XFlush(display);
1011
+ if (delay_ns > 0) {
1012
+ std.Thread.sleep(delay_ns);
1013
+ }
326
1014
  }
1015
+ }
327
1016
 
328
- const point = currentCursorPoint() catch {
329
- return makeErrorJson(allocator, "failed to read cursor position");
330
- };
1017
+ fn keySymForX11Key(key_name: []const u8) !c_ulong {
1018
+ if (key_name.len == 1) {
1019
+ var key_buffer = [_:0]u8{ key_name[0], 0 };
1020
+ const key_sym = c_x11.XStringToKeysym(&key_buffer);
1021
+ if (key_sym == 0) return error.UnknownKey;
1022
+ return @intCast(key_sym);
1023
+ }
331
1024
 
332
- const x = @as(i64, @intFromFloat(std.math.round(point.x)));
333
- const y = @as(i64, @intFromFloat(std.math.round(point.y)));
334
- const point_json = try std.fmt.allocPrint(allocator, "{{\"x\":{d},\"y\":{d}}}", .{ x, y });
335
- return makeOkJson(allocator, point_json);
1025
+ if (std.ascii.eqlIgnoreCase(key_name, "enter") or std.ascii.eqlIgnoreCase(key_name, "return")) return c_x11.XK_Return;
1026
+ if (std.ascii.eqlIgnoreCase(key_name, "tab")) return c_x11.XK_Tab;
1027
+ if (std.ascii.eqlIgnoreCase(key_name, "space")) return c_x11.XK_space;
1028
+ if (std.ascii.eqlIgnoreCase(key_name, "escape") or std.ascii.eqlIgnoreCase(key_name, "esc")) return c_x11.XK_Escape;
1029
+ if (std.ascii.eqlIgnoreCase(key_name, "backspace")) return c_x11.XK_BackSpace;
1030
+ if (std.ascii.eqlIgnoreCase(key_name, "delete")) return c_x11.XK_Delete;
1031
+ if (std.ascii.eqlIgnoreCase(key_name, "left")) return c_x11.XK_Left;
1032
+ if (std.ascii.eqlIgnoreCase(key_name, "right")) return c_x11.XK_Right;
1033
+ if (std.ascii.eqlIgnoreCase(key_name, "up")) return c_x11.XK_Up;
1034
+ if (std.ascii.eqlIgnoreCase(key_name, "down")) return c_x11.XK_Down;
1035
+ if (std.ascii.eqlIgnoreCase(key_name, "home")) return c_x11.XK_Home;
1036
+ if (std.ascii.eqlIgnoreCase(key_name, "end")) return c_x11.XK_End;
1037
+ if (std.ascii.eqlIgnoreCase(key_name, "pageup")) return c_x11.XK_Page_Up;
1038
+ if (std.ascii.eqlIgnoreCase(key_name, "pagedown")) return c_x11.XK_Page_Down;
1039
+ return error.UnknownKey;
336
1040
  }
337
1041
 
338
- fn executeHoverCommand(allocator: std.mem.Allocator, payload_json: []const u8) ![]const u8 {
339
- if (builtin.target.os.tag != .macos) {
340
- return makeErrorJson(allocator, "hover is only supported on macOS");
1042
+ fn postX11Key(display: *c_x11.Display, key_sym: c_ulong, is_down: bool) !void {
1043
+ const key_code = c_x11.XKeysymToKeycode(display, @intCast(key_sym));
1044
+ if (key_code == 0) {
1045
+ return error.UnknownKey;
341
1046
  }
1047
+ _ = c_x11.XTestFakeKeyEvent(display, key_code, if (is_down) c_x11.True else c_x11.False, c_x11.CurrentTime);
1048
+ _ = c_x11.XFlush(display);
1049
+ }
342
1050
 
343
- var parsed = std.json.parseFromSlice(MouseMovePayload, allocator, payload_json, .{}) catch {
344
- return makeErrorJson(allocator, "invalid hover payload json");
345
- };
346
- defer parsed.deinit();
1051
+ fn pressX11(input: PressInput) !void {
1052
+ const parsed = try parsePressKey(input.key);
1053
+ const key_sym = try keySymForX11Key(parsed.key);
1054
+ const repeat_count = normalizedCount(input.count);
1055
+ const delay_ns = normalizedDelayNs(input.delayMs);
347
1056
 
348
- const point: c.CGPoint = .{
349
- .x = parsed.value.x,
350
- .y = parsed.value.y,
351
- };
1057
+ const display = c_x11.XOpenDisplay(null) orelse return error.XOpenDisplayFailed;
1058
+ defer _ = c_x11.XCloseDisplay(display);
352
1059
 
353
- moveCursorToPoint(point) catch {
354
- return makeErrorJson(allocator, "failed to move cursor for hover");
1060
+ var index: u32 = 0;
1061
+ while (index < repeat_count) : (index += 1) {
1062
+ if (parsed.cmd) try postX11Key(display, c_x11.XK_Super_L, true);
1063
+ if (parsed.alt) try postX11Key(display, c_x11.XK_Alt_L, true);
1064
+ if (parsed.ctrl) try postX11Key(display, c_x11.XK_Control_L, true);
1065
+ if (parsed.shift) try postX11Key(display, c_x11.XK_Shift_L, true);
1066
+
1067
+ try postX11Key(display, key_sym, true);
1068
+ try postX11Key(display, key_sym, false);
1069
+
1070
+ if (parsed.shift) try postX11Key(display, c_x11.XK_Shift_L, false);
1071
+ if (parsed.ctrl) try postX11Key(display, c_x11.XK_Control_L, false);
1072
+ if (parsed.alt) try postX11Key(display, c_x11.XK_Alt_L, false);
1073
+ if (parsed.cmd) try postX11Key(display, c_x11.XK_Super_L, false);
1074
+
1075
+ if (delay_ns > 0 and index + 1 < repeat_count) {
1076
+ std.Thread.sleep(delay_ns);
1077
+ }
1078
+ }
1079
+ }
1080
+
1081
+ fn createScreenshotImage(input: struct {
1082
+ display_index: ?f64,
1083
+ window_id: ?f64,
1084
+ region: ?ScreenshotRegion,
1085
+ }) !ScreenshotCapture {
1086
+ if (input.window_id != null and input.region != null) {
1087
+ return error.InvalidScreenshotInput;
1088
+ }
1089
+
1090
+ if (input.window_id) |window_id| {
1091
+ const normalized_window_id = normalizeWindowId(window_id) catch {
1092
+ return error.InvalidWindowId;
1093
+ };
1094
+ const window_bounds = findWindowBoundsById(normalized_window_id) catch {
1095
+ return error.WindowNotFound;
1096
+ };
1097
+ const selected_display = resolveDisplayForRect(window_bounds) catch {
1098
+ return error.DisplayResolutionFailed;
1099
+ };
1100
+
1101
+ const window_image = c.CGDisplayCreateImageForRect(selected_display.id, window_bounds);
1102
+ if (window_image == null) {
1103
+ return error.CaptureFailed;
1104
+ }
1105
+ return .{
1106
+ .image = window_image,
1107
+ .capture_x = window_bounds.origin.x,
1108
+ .capture_y = window_bounds.origin.y,
1109
+ .capture_width = window_bounds.size.width,
1110
+ .capture_height = window_bounds.size.height,
1111
+ .desktop_index = selected_display.index,
1112
+ };
1113
+ }
1114
+
1115
+ const selected_display = resolveDisplayId(input.display_index) catch {
1116
+ return error.DisplayResolutionFailed;
355
1117
  };
356
1118
 
357
- return makeOkJson(allocator, "null");
1119
+ if (input.region) |region| {
1120
+ const rect: c.CGRect = .{
1121
+ .origin = .{
1122
+ .x = selected_display.bounds.origin.x + region.x,
1123
+ .y = selected_display.bounds.origin.y + region.y,
1124
+ },
1125
+ .size = .{ .width = region.width, .height = region.height },
1126
+ };
1127
+ const region_image = c.CGDisplayCreateImageForRect(selected_display.id, rect);
1128
+ if (region_image == null) {
1129
+ return error.CaptureFailed;
1130
+ }
1131
+ return .{
1132
+ .image = region_image,
1133
+ .capture_x = rect.origin.x,
1134
+ .capture_y = rect.origin.y,
1135
+ .capture_width = rect.size.width,
1136
+ .capture_height = rect.size.height,
1137
+ .desktop_index = selected_display.index,
1138
+ };
1139
+ }
1140
+
1141
+ const full_image = c.CGDisplayCreateImage(selected_display.id);
1142
+ if (full_image == null) {
1143
+ return error.CaptureFailed;
1144
+ }
1145
+ return .{
1146
+ .image = full_image,
1147
+ .capture_x = selected_display.bounds.origin.x,
1148
+ .capture_y = selected_display.bounds.origin.y,
1149
+ .capture_width = selected_display.bounds.size.width,
1150
+ .capture_height = selected_display.bounds.size.height,
1151
+ .desktop_index = selected_display.index,
1152
+ };
358
1153
  }
359
1154
 
360
- fn executeDragCommand(allocator: std.mem.Allocator, payload_json: []const u8) ![]const u8 {
361
- if (builtin.target.os.tag != .macos) {
362
- return makeErrorJson(allocator, "drag is only supported on macOS");
1155
+ fn normalizeWindowId(raw_id: f64) !u32 {
1156
+ const normalized = @as(i64, @intFromFloat(std.math.round(raw_id)));
1157
+ if (normalized <= 0) {
1158
+ return error.InvalidWindowId;
363
1159
  }
1160
+ return @intCast(normalized);
1161
+ }
364
1162
 
365
- var parsed = std.json.parseFromSlice(DragPayload, allocator, payload_json, .{}) catch {
366
- return makeErrorJson(allocator, "invalid drag payload json");
1163
+ fn findWindowBoundsById(target_window_id: u32) !c.CGRect {
1164
+ const Context = struct {
1165
+ target_id: u32,
1166
+ bounds: ?c.CGRect = null,
367
1167
  };
368
- defer parsed.deinit();
369
1168
 
370
- const drag_payload = parsed.value;
371
- const button_kind = resolveMouseButton(drag_payload.button orelse "left") catch {
372
- return makeErrorJson(allocator, "invalid drag button");
1169
+ var context = Context{ .target_id = target_window_id };
1170
+ window.forEachVisibleWindow(Context, &context, struct {
1171
+ fn callback(ctx: *Context, info: window.WindowInfo) !void {
1172
+ if (info.id != ctx.target_id) {
1173
+ return;
1174
+ }
1175
+ ctx.bounds = .{
1176
+ .origin = .{ .x = info.bounds.x, .y = info.bounds.y },
1177
+ .size = .{ .width = info.bounds.width, .height = info.bounds.height },
1178
+ };
1179
+ return error.Found;
1180
+ }
1181
+ }.callback) catch |err| {
1182
+ if (err != error.Found) {
1183
+ return err;
1184
+ }
373
1185
  };
374
1186
 
375
- const from: c.CGPoint = .{ .x = drag_payload.from.x, .y = drag_payload.from.y };
376
- const to: c.CGPoint = .{ .x = drag_payload.to.x, .y = drag_payload.to.y };
1187
+ if (context.bounds) |bounds| {
1188
+ return bounds;
1189
+ }
1190
+ return error.WindowNotFound;
1191
+ }
377
1192
 
378
- moveCursorToPoint(from) catch {
379
- return makeErrorJson(allocator, "failed to move cursor to drag origin");
1193
+ fn resolveDisplayForRect(rect: c.CGRect) !SelectedDisplay {
1194
+ var display_ids: [16]c.CGDirectDisplayID = undefined;
1195
+ var display_count: u32 = 0;
1196
+ const list_result = c.CGGetActiveDisplayList(display_ids.len, &display_ids, &display_count);
1197
+ if (list_result != c.kCGErrorSuccess or display_count == 0) {
1198
+ return error.DisplayQueryFailed;
1199
+ }
1200
+
1201
+ var best_index: usize = 0;
1202
+ var best_overlap: f64 = -1;
1203
+ var i: usize = 0;
1204
+ while (i < display_count) : (i += 1) {
1205
+ const bounds = c.CGDisplayBounds(display_ids[i]);
1206
+ const overlap = intersectionArea(rect, bounds);
1207
+ if (overlap > best_overlap) {
1208
+ best_overlap = overlap;
1209
+ best_index = i;
1210
+ }
1211
+ }
1212
+
1213
+ const id = display_ids[best_index];
1214
+ return .{
1215
+ .id = id,
1216
+ .index = best_index,
1217
+ .bounds = c.CGDisplayBounds(id),
380
1218
  };
1219
+ }
381
1220
 
382
- postMouseButtonEvent(from, button_kind, true, 1) catch {
383
- return makeErrorJson(allocator, "failed to post drag mouse-down");
1221
+ fn intersectionArea(a: c.CGRect, b: c.CGRect) f64 {
1222
+ const left = @max(a.origin.x, b.origin.x);
1223
+ const top = @max(a.origin.y, b.origin.y);
1224
+ const right = @min(a.origin.x + a.size.width, b.origin.x + b.size.width);
1225
+ const bottom = @min(a.origin.y + a.size.height, b.origin.y + b.size.height);
1226
+ if (right <= left or bottom <= top) {
1227
+ return 0;
1228
+ }
1229
+ return (right - left) * (bottom - top);
1230
+ }
1231
+
1232
+ fn serializeWindowListJson() ![]u8 {
1233
+ const Context = struct {
1234
+ stream: *std.io.FixedBufferStream([]u8),
1235
+ first: bool,
384
1236
  };
385
1237
 
386
- const total_duration_ns = (drag_payload.durationMs orelse 400) * std.time.ns_per_ms;
387
- const step_count: u64 = 16;
388
- const step_duration_ns = if (step_count == 0) 0 else total_duration_ns / step_count;
1238
+ var write_buffer: [64 * 1024]u8 = undefined;
1239
+ var stream = std.io.fixedBufferStream(&write_buffer);
1240
+
1241
+ try stream.writer().writeByte('[');
1242
+ var context = Context{ .stream = &stream, .first = true };
1243
+
1244
+ try window.forEachVisibleWindow(Context, &context, struct {
1245
+ fn callback(ctx: *Context, info: window.WindowInfo) !void {
1246
+ const rect: c.CGRect = .{
1247
+ .origin = .{ .x = info.bounds.x, .y = info.bounds.y },
1248
+ .size = .{ .width = info.bounds.width, .height = info.bounds.height },
1249
+ };
1250
+ const selected_display = resolveDisplayForRect(rect) catch {
1251
+ return;
1252
+ };
1253
+ const item = WindowInfoOutput{
1254
+ .id = info.id,
1255
+ .ownerPid = info.owner_pid,
1256
+ .ownerName = info.owner_name,
1257
+ .title = info.title,
1258
+ .x = info.bounds.x,
1259
+ .y = info.bounds.y,
1260
+ .width = info.bounds.width,
1261
+ .height = info.bounds.height,
1262
+ .desktopIndex = @intCast(selected_display.index),
1263
+ };
1264
+
1265
+ if (!ctx.first) {
1266
+ try ctx.stream.writer().writeByte(',');
1267
+ }
1268
+ ctx.first = false;
1269
+ try ctx.stream.writer().print("{f}", .{std.json.fmt(item, .{})});
1270
+ }
1271
+ }.callback);
389
1272
 
390
- var index: u64 = 1;
391
- while (index <= step_count) : (index += 1) {
392
- const fraction = @as(f64, @floatFromInt(index)) / @as(f64, @floatFromInt(step_count));
393
- const next_point: c.CGPoint = .{
394
- .x = from.x + (to.x - from.x) * fraction,
395
- .y = from.y + (to.y - from.y) * fraction,
396
- };
1273
+ try stream.writer().writeByte(']');
1274
+ return std.heap.c_allocator.dupe(u8, stream.getWritten());
1275
+ }
397
1276
 
398
- moveCursorToPoint(next_point) catch {
399
- return makeErrorJson(allocator, "failed during drag cursor movement");
1277
+ fn scaleScreenshotImageIfNeeded(image: c.CGImageRef) !ScaledScreenshotImage {
1278
+ const image_width = @as(f64, @floatFromInt(c.CGImageGetWidth(image)));
1279
+ const image_height = @as(f64, @floatFromInt(c.CGImageGetHeight(image)));
1280
+ const long_edge = @max(image_width, image_height);
1281
+ if (long_edge <= screenshot_max_long_edge_px) {
1282
+ _ = c.CFRetain(image);
1283
+ return .{
1284
+ .image = image,
1285
+ .width = image_width,
1286
+ .height = image_height,
400
1287
  };
1288
+ }
401
1289
 
402
- if (step_duration_ns > 0 and index < step_count) {
403
- std.Thread.sleep(step_duration_ns);
404
- }
1290
+ const scale = screenshot_max_long_edge_px / long_edge;
1291
+ const target_width = @max(1, @as(usize, @intFromFloat(std.math.round(image_width * scale))));
1292
+ const target_height = @max(1, @as(usize, @intFromFloat(std.math.round(image_height * scale))));
1293
+
1294
+ const color_space = c.CGColorSpaceCreateDeviceRGB();
1295
+ if (color_space == null) {
1296
+ return error.ScaleFailed;
405
1297
  }
1298
+ defer c.CFRelease(color_space);
406
1299
 
407
- postMouseButtonEvent(to, button_kind, false, 1) catch {
408
- return makeErrorJson(allocator, "failed to post drag mouse-up");
1300
+ const bitmap_info: c.CGBitmapInfo = c.kCGImageAlphaPremultipliedLast;
1301
+ const context = c.CGBitmapContextCreate(
1302
+ null,
1303
+ target_width,
1304
+ target_height,
1305
+ 8,
1306
+ 0,
1307
+ color_space,
1308
+ bitmap_info,
1309
+ );
1310
+ if (context == null) {
1311
+ return error.ScaleFailed;
1312
+ }
1313
+ defer c.CFRelease(context);
1314
+
1315
+ c.CGContextSetInterpolationQuality(context, c.kCGInterpolationHigh);
1316
+ const draw_rect: c.CGRect = .{
1317
+ .origin = .{ .x = 0, .y = 0 },
1318
+ .size = .{
1319
+ .width = @as(c.CGFloat, @floatFromInt(target_width)),
1320
+ .height = @as(c.CGFloat, @floatFromInt(target_height)),
1321
+ },
409
1322
  };
1323
+ c.CGContextDrawImage(context, draw_rect, image);
410
1324
 
411
- return makeOkJson(allocator, "null");
1325
+ const scaled = c.CGBitmapContextCreateImage(context);
1326
+ if (scaled == null) {
1327
+ return error.ScaleFailed;
1328
+ }
1329
+ return .{
1330
+ .image = scaled,
1331
+ .width = @as(f64, @floatFromInt(target_width)),
1332
+ .height = @as(f64, @floatFromInt(target_height)),
1333
+ };
412
1334
  }
413
1335
 
414
- const MouseButtonKind = enum {
415
- left,
416
- right,
417
- middle,
418
- };
1336
+ fn resolveDisplayId(display_index: ?f64) !SelectedDisplay {
1337
+ const selected_index: usize = if (display_index) |value| blk: {
1338
+ const normalized = @as(i64, @intFromFloat(std.math.round(value)));
1339
+ if (normalized < 0) {
1340
+ return error.InvalidDisplayIndex;
1341
+ }
1342
+ break :blk @as(usize, @intCast(normalized));
1343
+ } else 0;
1344
+ var display_ids: [16]c.CGDirectDisplayID = undefined;
1345
+ var display_count: u32 = 0;
1346
+ const list_result = c.CGGetActiveDisplayList(display_ids.len, &display_ids, &display_count);
1347
+ if (list_result != c.kCGErrorSuccess) {
1348
+ return error.DisplayQueryFailed;
1349
+ }
1350
+ if (selected_index >= display_count) {
1351
+ return error.InvalidDisplayIndex;
1352
+ }
1353
+ const selected_id = display_ids[selected_index];
1354
+ const bounds = c.CGDisplayBounds(selected_id);
1355
+ return .{
1356
+ .id = selected_id,
1357
+ .index = selected_index,
1358
+ .bounds = bounds,
1359
+ };
1360
+ }
1361
+
1362
+ fn writeScreenshotPng(input: struct {
1363
+ image: c.CGImageRef,
1364
+ output_path: []const u8,
1365
+ }) !void {
1366
+ const path_as_u8: [*]const u8 = @ptrCast(input.output_path.ptr);
1367
+ const file_url = c.CFURLCreateFromFileSystemRepresentation(
1368
+ null,
1369
+ path_as_u8,
1370
+ @as(c_long, @intCast(input.output_path.len)),
1371
+ 0,
1372
+ );
1373
+ if (file_url == null) {
1374
+ return error.FileUrlCreateFailed;
1375
+ }
1376
+ defer c.CFRelease(file_url);
1377
+
1378
+ const png_type = c.CFStringCreateWithCString(null, "public.png", c.kCFStringEncodingUTF8);
1379
+ if (png_type == null) {
1380
+ return error.PngTypeCreateFailed;
1381
+ }
1382
+ defer c.CFRelease(png_type);
1383
+
1384
+ const destination = c.CGImageDestinationCreateWithURL(file_url, png_type, 1, null);
1385
+ if (destination == null) {
1386
+ return error.ImageDestinationCreateFailed;
1387
+ }
1388
+ defer c.CFRelease(destination);
1389
+
1390
+ c.CGImageDestinationAddImage(destination, input.image, null);
1391
+ const did_finalize = c.CGImageDestinationFinalize(destination);
1392
+ if (!did_finalize) {
1393
+ return error.ImageDestinationFinalizeFailed;
1394
+ }
1395
+ }
419
1396
 
420
1397
  fn resolveMouseButton(button: []const u8) !MouseButtonKind {
421
1398
  if (std.ascii.eqlIgnoreCase(button, "left")) {
@@ -482,7 +1459,21 @@ fn moveCursorToPoint(point: c.CGPoint) !void {
482
1459
  }
483
1460
 
484
1461
  fn initModule(js: *napigen.JsContext, exports: napigen.napi_value) !napigen.napi_value {
485
- try js.setNamedProperty(exports, "execute", try js.createFunction(execute));
1462
+ try js.setNamedProperty(exports, "screenshot", try js.createFunction(screenshot));
1463
+ try js.setNamedProperty(exports, "click", try js.createFunction(click));
1464
+ try js.setNamedProperty(exports, "typeText", try js.createFunction(typeText));
1465
+ try js.setNamedProperty(exports, "press", try js.createFunction(press));
1466
+ try js.setNamedProperty(exports, "scroll", try js.createFunction(scroll));
1467
+ try js.setNamedProperty(exports, "drag", try js.createFunction(drag));
1468
+ try js.setNamedProperty(exports, "hover", try js.createFunction(hover));
1469
+ try js.setNamedProperty(exports, "mouseMove", try js.createFunction(mouseMove));
1470
+ try js.setNamedProperty(exports, "mouseDown", try js.createFunction(mouseDown));
1471
+ try js.setNamedProperty(exports, "mouseUp", try js.createFunction(mouseUp));
1472
+ try js.setNamedProperty(exports, "mousePosition", try js.createFunction(mousePosition));
1473
+ try js.setNamedProperty(exports, "displayList", try js.createFunction(displayList));
1474
+ try js.setNamedProperty(exports, "windowList", try js.createFunction(windowList));
1475
+ try js.setNamedProperty(exports, "clipboardGet", try js.createFunction(clipboardGet));
1476
+ try js.setNamedProperty(exports, "clipboardSet", try js.createFunction(clipboardSet));
486
1477
  return exports;
487
1478
  }
488
1479