@jian-ai/remote-control 0.7.2 → 0.8.1

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.
@@ -0,0 +1,736 @@
1
+ /*
2
+ Jian Remote Control — macOS computer-use helper
3
+
4
+ Long-lived AppKit process: overlay pointer + synthetic mouse/keyboard via CoreGraphics.
5
+ Protocol: newline-delimited JSON on stdin → newline-delimited JSON on stdout (flush each).
6
+ Errors go to stderr in English only.
7
+
8
+ Published npm packages ship a prebuilt universal binary (Publish CI). From source on a Mac:
9
+ ./build-helper.sh
10
+ # or: swiftc -O -framework AppKit -framework Carbon -o computer-helper computer-helper.swift
11
+
12
+ Run as a child of the Node worker; keep stdin/stdout pipes open for the session.
13
+ */
14
+
15
+ import AppKit
16
+ import Carbon.HIToolbox
17
+ import CoreGraphics
18
+ import Foundation
19
+
20
+ // MARK: - JSON I/O
21
+
22
+ private func writeResponse(_ object: [String: Any]) {
23
+ guard JSONSerialization.isValidJSONObject(object),
24
+ let data = try? JSONSerialization.data(withJSONObject: object, options: []),
25
+ var line = String(data: data, encoding: .utf8)
26
+ else {
27
+ fputs("computer-helper: failed to encode response JSON\n", stderr)
28
+ fflush(stderr)
29
+ return
30
+ }
31
+ line.append("\n")
32
+ FileHandle.standardOutput.write(Data(line.utf8))
33
+ fflush(stdout)
34
+ }
35
+
36
+ private func writeOk(_ extra: [String: Any] = [:]) {
37
+ var body: [String: Any] = ["ok": true]
38
+ for (key, value) in extra {
39
+ body[key] = value
40
+ }
41
+ writeResponse(body)
42
+ }
43
+
44
+ private func writeError(_ message: String) {
45
+ writeResponse(["ok": false, "error": message])
46
+ }
47
+
48
+ private func logError(_ message: String) {
49
+ fputs("computer-helper: \(message)\n", stderr)
50
+ fflush(stderr)
51
+ }
52
+
53
+ private func parseJSONLine(_ line: String) -> [String: Any]? {
54
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
55
+ guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil }
56
+ guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
57
+ return nil
58
+ }
59
+ return object
60
+ }
61
+
62
+ private func doubleValue(_ object: [String: Any], _ key: String) -> Double? {
63
+ if let n = object[key] as? Double { return n }
64
+ if let n = object[key] as? Int { return Double(n) }
65
+ if let n = object[key] as? NSNumber { return n.doubleValue }
66
+ return nil
67
+ }
68
+
69
+ private func intValue(_ object: [String: Any], _ key: String) -> Int? {
70
+ if let n = object[key] as? Int { return n }
71
+ if let n = object[key] as? Double { return Int(n) }
72
+ if let n = object[key] as? NSNumber { return n.intValue }
73
+ return nil
74
+ }
75
+
76
+ // MARK: - Colors (Jian UI)
77
+
78
+ private let jianCream = NSColor(srgbRed: 0xF5 / 255, green: 0xF5 / 255, blue: 0xF3 / 255, alpha: 1)
79
+ private let jianInk = NSColor(srgbRed: 0x17 / 255, green: 0x17 / 255, blue: 0x16 / 255, alpha: 1)
80
+
81
+ private let sessionAlpha: CGFloat = 0.92
82
+ private let idleAlpha: CGFloat = 0.18
83
+ private let pointerSize: CGFloat = 32
84
+ /// Tip of the wing relative to the view's top-left (hotspot for cursor placement).
85
+ private let hotspot = CGPoint(x: 4, y: 4)
86
+
87
+ // MARK: - Overlay view
88
+
89
+ private final class JianPointerView: NSView {
90
+ private var rippleProgress: CGFloat = 0
91
+ private var rippleVisible = false
92
+ private var fillPulse: CGFloat = 0
93
+ private var rippleTimer: Timer?
94
+
95
+ override var isFlipped: Bool { true }
96
+
97
+ override func draw(_ dirtyRect: NSRect) {
98
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
99
+
100
+ if rippleVisible {
101
+ let maxRadius = pointerSize * 0.95
102
+ let radius = 4 + maxRadius * rippleProgress
103
+ let alpha = max(0, 1 - rippleProgress)
104
+ ctx.saveGState()
105
+ ctx.setStrokeColor(jianInk.withAlphaComponent(0.55 * alpha).cgColor)
106
+ ctx.setLineWidth(1.5)
107
+ ctx.strokeEllipse(in: CGRect(
108
+ x: hotspot.x - radius,
109
+ y: hotspot.y - radius,
110
+ width: radius * 2,
111
+ height: radius * 2
112
+ ))
113
+ ctx.restoreGState()
114
+ }
115
+
116
+ let path = Self.wingPath(in: bounds)
117
+ ctx.saveGState()
118
+ ctx.setShadow(offset: CGSize(width: 0, height: 1.5), blur: 4, color: NSColor.black.withAlphaComponent(0.28).cgColor)
119
+ let fillAlpha = 1 - fillPulse * 0.35
120
+ jianCream.withAlphaComponent(fillAlpha).setFill()
121
+ path.fill()
122
+ ctx.restoreGState()
123
+
124
+ jianInk.setStroke()
125
+ path.lineWidth = 2
126
+ path.lineJoinStyle = .round
127
+ path.lineCapStyle = .round
128
+ path.stroke()
129
+ }
130
+
131
+ /// Ascending wing mark inspired by the Jian brand path (favicon / mark.tsx).
132
+ private static func wingPath(in bounds: NSRect) -> NSBezierPath {
133
+ let s = min(bounds.width, bounds.height)
134
+ let path = NSBezierPath()
135
+ // Tip near top-left (hotspot), body sweeping down-right then curling back — one ascending wing.
136
+ path.move(to: CGPoint(x: s * 0.12, y: s * 0.10))
137
+ path.curve(
138
+ to: CGPoint(x: s * 0.72, y: s * 0.18),
139
+ controlPoint1: CGPoint(x: s * 0.28, y: s * 0.02),
140
+ controlPoint2: CGPoint(x: s * 0.52, y: s * 0.06)
141
+ )
142
+ path.curve(
143
+ to: CGPoint(x: s * 0.88, y: s * 0.55),
144
+ controlPoint1: CGPoint(x: s * 0.88, y: s * 0.28),
145
+ controlPoint2: CGPoint(x: s * 0.94, y: s * 0.42)
146
+ )
147
+ path.curve(
148
+ to: CGPoint(x: s * 0.42, y: s * 0.88),
149
+ controlPoint1: CGPoint(x: s * 0.80, y: s * 0.72),
150
+ controlPoint2: CGPoint(x: s * 0.58, y: s * 0.90)
151
+ )
152
+ path.curve(
153
+ to: CGPoint(x: s * 0.18, y: s * 0.48),
154
+ controlPoint1: CGPoint(x: s * 0.26, y: s * 0.86),
155
+ controlPoint2: CGPoint(x: s * 0.12, y: s * 0.68)
156
+ )
157
+ path.curve(
158
+ to: CGPoint(x: s * 0.12, y: s * 0.10),
159
+ controlPoint1: CGPoint(x: s * 0.22, y: s * 0.32),
160
+ controlPoint2: CGPoint(x: s * 0.08, y: s * 0.20)
161
+ )
162
+ path.close()
163
+ return path
164
+ }
165
+
166
+ func flashClick(duration: TimeInterval = 0.2) {
167
+ rippleTimer?.invalidate()
168
+ rippleVisible = true
169
+ rippleProgress = 0
170
+ fillPulse = 1
171
+ needsDisplay = true
172
+
173
+ let start = Date()
174
+ rippleTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
175
+ guard let self else {
176
+ timer.invalidate()
177
+ return
178
+ }
179
+ let t = min(1, Date().timeIntervalSince(start) / duration)
180
+ self.rippleProgress = CGFloat(t)
181
+ self.fillPulse = CGFloat(1 - t)
182
+ self.needsDisplay = true
183
+ if t >= 1 {
184
+ timer.invalidate()
185
+ self.rippleVisible = false
186
+ self.fillPulse = 0
187
+ self.needsDisplay = true
188
+ }
189
+ }
190
+ if let rippleTimer {
191
+ RunLoop.main.add(rippleTimer, forMode: .common)
192
+ }
193
+ }
194
+ }
195
+
196
+ // MARK: - Overlay window
197
+
198
+ private final class NonActivatingWindow: NSWindow {
199
+ override var canBecomeKey: Bool { false }
200
+ override var canBecomeMain: Bool { false }
201
+ }
202
+
203
+ private final class OverlayController {
204
+ static let shared = OverlayController()
205
+
206
+ private var window: NonActivatingWindow?
207
+ private var pointerView: JianPointerView?
208
+ /// Current tip/hotspot in global top-left Quartz coordinates.
209
+ private var tipPoint = CGPoint.zero
210
+
211
+ private init() {}
212
+
213
+ private func ensureWindow() {
214
+ if window != nil { return }
215
+
216
+ let view = JianPointerView(frame: NSRect(x: 0, y: 0, width: pointerSize, height: pointerSize))
217
+ view.wantsLayer = true
218
+
219
+ let win = NonActivatingWindow(
220
+ contentRect: NSRect(x: 0, y: 0, width: pointerSize, height: pointerSize),
221
+ styleMask: .borderless,
222
+ backing: .buffered,
223
+ defer: false
224
+ )
225
+ win.isOpaque = false
226
+ win.backgroundColor = .clear
227
+ win.hasShadow = false
228
+ win.level = .statusBar
229
+ win.ignoresMouseEvents = true
230
+ win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
231
+ win.isReleasedWhenClosed = false
232
+ win.contentView = view
233
+ win.alphaValue = 0
234
+ win.orderOut(nil)
235
+
236
+ pointerView = view
237
+ window = win
238
+ }
239
+
240
+ func ensureSession() {
241
+ ensureWindow()
242
+ guard let window else { return }
243
+ window.alphaValue = sessionAlpha
244
+ positionWindow()
245
+ window.orderFrontRegardless()
246
+ }
247
+
248
+ func setIdle() {
249
+ ensureWindow()
250
+ guard let window else { return }
251
+ window.alphaValue = idleAlpha
252
+ positionWindow()
253
+ window.orderFrontRegardless()
254
+ }
255
+
256
+ func endSession() {
257
+ guard let window else { return }
258
+ window.orderOut(nil)
259
+ window.alphaValue = 0
260
+ }
261
+
262
+ func moveTip(to globalTopLeft: CGPoint) {
263
+ tipPoint = globalTopLeft
264
+ ensureWindow()
265
+ positionWindow()
266
+ }
267
+
268
+ func flashClick() {
269
+ ensureWindow()
270
+ pointerView?.flashClick()
271
+ }
272
+
273
+ func currentTip() -> CGPoint { tipPoint }
274
+
275
+ private func positionWindow() {
276
+ guard let window else { return }
277
+ // Quartz global: origin top-left of primary, Y down. Cocoa: origin bottom-left, Y up.
278
+ let mainHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
279
+ let tipCocoaY = mainHeight - tipPoint.y
280
+ // Window origin is bottom-left; hotspot is hotspot.y down from the top of the view.
281
+ let origin = NSPoint(
282
+ x: tipPoint.x - hotspot.x,
283
+ y: tipCocoaY - (pointerSize - hotspot.y)
284
+ )
285
+ window.setFrameOrigin(origin)
286
+ }
287
+ }
288
+
289
+ // MARK: - Coordinate helpers
290
+
291
+ private func cgPointFromTopLeft(_ x: Double, _ y: Double) -> CGPoint {
292
+ CGPoint(x: x, y: y)
293
+ }
294
+
295
+ // MARK: - Mouse / keyboard
296
+
297
+ private func postMouseMove(to point: CGPoint) {
298
+ OverlayController.shared.moveTip(to: point)
299
+ guard let event = CGEvent(
300
+ mouseEventSource: nil,
301
+ mouseType: .mouseMoved,
302
+ mouseCursorPosition: point,
303
+ mouseButton: .left
304
+ ) else {
305
+ logError("could not create mouseMoved event")
306
+ return
307
+ }
308
+ event.post(tap: .cghidEventTap)
309
+ }
310
+
311
+ private func postMouseClick(button: CGMouseButton, count: Int64) {
312
+ OverlayController.shared.flashClick()
313
+ let point = OverlayController.shared.currentTip()
314
+ let downType: CGEventType = button == .right ? .rightMouseDown : .leftMouseDown
315
+ let upType: CGEventType = button == .right ? .rightMouseUp : .leftMouseUp
316
+
317
+ for clickIndex in 1...max(1, count) {
318
+ if let down = CGEvent(
319
+ mouseEventSource: nil,
320
+ mouseType: downType,
321
+ mouseCursorPosition: point,
322
+ mouseButton: button
323
+ ) {
324
+ down.setIntegerValueField(.mouseEventClickState, value: clickIndex)
325
+ down.post(tap: .cghidEventTap)
326
+ }
327
+ if let up = CGEvent(
328
+ mouseEventSource: nil,
329
+ mouseType: upType,
330
+ mouseCursorPosition: point,
331
+ mouseButton: button
332
+ ) {
333
+ up.setIntegerValueField(.mouseEventClickState, value: clickIndex)
334
+ up.post(tap: .cghidEventTap)
335
+ }
336
+ if clickIndex < count {
337
+ Thread.sleep(forTimeInterval: 0.05)
338
+ }
339
+ }
340
+ }
341
+
342
+ private func postDrag(from: CGPoint, to: CGPoint) {
343
+ postMouseMove(to: from)
344
+ OverlayController.shared.flashClick()
345
+ Thread.sleep(forTimeInterval: 0.02)
346
+
347
+ if let down = CGEvent(
348
+ mouseEventSource: nil,
349
+ mouseType: .leftMouseDown,
350
+ mouseCursorPosition: from,
351
+ mouseButton: .left
352
+ ) {
353
+ down.post(tap: .cghidEventTap)
354
+ }
355
+
356
+ let steps = 12
357
+ for i in 1...steps {
358
+ let t = CGFloat(i) / CGFloat(steps)
359
+ let p = CGPoint(
360
+ x: from.x + (to.x - from.x) * t,
361
+ y: from.y + (to.y - from.y) * t
362
+ )
363
+ OverlayController.shared.moveTip(to: p)
364
+ if let drag = CGEvent(
365
+ mouseEventSource: nil,
366
+ mouseType: .leftMouseDragged,
367
+ mouseCursorPosition: p,
368
+ mouseButton: .left
369
+ ) {
370
+ drag.post(tap: .cghidEventTap)
371
+ }
372
+ Thread.sleep(forTimeInterval: 0.008)
373
+ }
374
+
375
+ if let up = CGEvent(
376
+ mouseEventSource: nil,
377
+ mouseType: .leftMouseUp,
378
+ mouseCursorPosition: to,
379
+ mouseButton: .left
380
+ ) {
381
+ up.post(tap: .cghidEventTap)
382
+ }
383
+ OverlayController.shared.moveTip(to: to)
384
+ }
385
+
386
+ private func postScroll(at point: CGPoint, direction: String, amount: Int) {
387
+ postMouseMove(to: point)
388
+ let n = max(1, amount)
389
+ var dx = 0
390
+ var dy = 0
391
+ switch direction {
392
+ case "up": dy = 1
393
+ case "down": dy = -1
394
+ case "left": dx = 1
395
+ case "right": dx = -1
396
+ default:
397
+ logError("unknown scroll direction: \(direction)")
398
+ return
399
+ }
400
+ for _ in 0..<n {
401
+ if let event = CGEvent(
402
+ scrollWheelEvent2Source: nil,
403
+ units: .line,
404
+ wheelCount: 2,
405
+ wheel1: Int32(dy),
406
+ wheel2: Int32(dx),
407
+ wheel3: 0
408
+ ) {
409
+ event.post(tap: .cghidEventTap)
410
+ }
411
+ Thread.sleep(forTimeInterval: 0.02)
412
+ }
413
+ }
414
+
415
+ // MARK: - Keycodes (US ANSI)
416
+
417
+ private let letterKeyCodes: [Character: CGKeyCode] = [
418
+ "a": CGKeyCode(kVK_ANSI_A), "b": CGKeyCode(kVK_ANSI_B), "c": CGKeyCode(kVK_ANSI_C),
419
+ "d": CGKeyCode(kVK_ANSI_D), "e": CGKeyCode(kVK_ANSI_E), "f": CGKeyCode(kVK_ANSI_F),
420
+ "g": CGKeyCode(kVK_ANSI_G), "h": CGKeyCode(kVK_ANSI_H), "i": CGKeyCode(kVK_ANSI_I),
421
+ "j": CGKeyCode(kVK_ANSI_J), "k": CGKeyCode(kVK_ANSI_K), "l": CGKeyCode(kVK_ANSI_L),
422
+ "m": CGKeyCode(kVK_ANSI_M), "n": CGKeyCode(kVK_ANSI_N), "o": CGKeyCode(kVK_ANSI_O),
423
+ "p": CGKeyCode(kVK_ANSI_P), "q": CGKeyCode(kVK_ANSI_Q), "r": CGKeyCode(kVK_ANSI_R),
424
+ "s": CGKeyCode(kVK_ANSI_S), "t": CGKeyCode(kVK_ANSI_T), "u": CGKeyCode(kVK_ANSI_U),
425
+ "v": CGKeyCode(kVK_ANSI_V), "w": CGKeyCode(kVK_ANSI_W), "x": CGKeyCode(kVK_ANSI_X),
426
+ "y": CGKeyCode(kVK_ANSI_Y), "z": CGKeyCode(kVK_ANSI_Z),
427
+ ]
428
+
429
+ private let digitKeyCodes: [Character: CGKeyCode] = [
430
+ "0": CGKeyCode(kVK_ANSI_0), "1": CGKeyCode(kVK_ANSI_1), "2": CGKeyCode(kVK_ANSI_2),
431
+ "3": CGKeyCode(kVK_ANSI_3), "4": CGKeyCode(kVK_ANSI_4), "5": CGKeyCode(kVK_ANSI_5),
432
+ "6": CGKeyCode(kVK_ANSI_6), "7": CGKeyCode(kVK_ANSI_7), "8": CGKeyCode(kVK_ANSI_8),
433
+ "9": CGKeyCode(kVK_ANSI_9),
434
+ ]
435
+
436
+ private let namedKeyCodes: [String: CGKeyCode] = [
437
+ "return": CGKeyCode(kVK_Return),
438
+ "enter": CGKeyCode(kVK_Return),
439
+ "tab": CGKeyCode(kVK_Tab),
440
+ "escape": CGKeyCode(kVK_Escape),
441
+ "esc": CGKeyCode(kVK_Escape),
442
+ "delete": CGKeyCode(kVK_ForwardDelete),
443
+ "backspace": CGKeyCode(kVK_Delete),
444
+ "space": CGKeyCode(kVK_Space),
445
+ "up": CGKeyCode(kVK_UpArrow),
446
+ "down": CGKeyCode(kVK_DownArrow),
447
+ "left": CGKeyCode(kVK_LeftArrow),
448
+ "right": CGKeyCode(kVK_RightArrow),
449
+ "home": CGKeyCode(kVK_Home),
450
+ "end": CGKeyCode(kVK_End),
451
+ "pageup": CGKeyCode(kVK_PageUp),
452
+ "pagedown": CGKeyCode(kVK_PageDown),
453
+ ]
454
+
455
+ private func postKeyCode(_ keyCode: CGKeyCode, flags: CGEventFlags = []) {
456
+ guard let down = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true),
457
+ let up = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false)
458
+ else {
459
+ logError("could not create keyboard event for keyCode \(keyCode)")
460
+ return
461
+ }
462
+ down.flags = flags
463
+ up.flags = flags
464
+ down.post(tap: .cghidEventTap)
465
+ up.post(tap: .cghidEventTap)
466
+ }
467
+
468
+ private func postUnicodeChar(_ scalar: UniChar) {
469
+ var chars = [scalar]
470
+ guard let down = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true),
471
+ let up = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: false)
472
+ else {
473
+ logError("could not create unicode keyboard event")
474
+ return
475
+ }
476
+ down.keyboardSetUnicodeString(stringLength: 1, unicodeString: &chars)
477
+ up.keyboardSetUnicodeString(stringLength: 1, unicodeString: &chars)
478
+ down.post(tap: .cghidEventTap)
479
+ up.post(tap: .cghidEventTap)
480
+ }
481
+
482
+ private func typeText(_ text: String) {
483
+ // Iterate UTF-16 code units; map Return/Tab to virtual keys when present as characters.
484
+ let utf16 = Array(text.utf16)
485
+ var i = 0
486
+ while i < utf16.count {
487
+ let unit = utf16[i]
488
+ if unit == 0x000A || unit == 0x000D {
489
+ postKeyCode(CGKeyCode(kVK_Return))
490
+ if unit == 0x000D, i + 1 < utf16.count, utf16[i + 1] == 0x000A {
491
+ i += 1
492
+ }
493
+ } else if unit == 0x0009 {
494
+ postKeyCode(CGKeyCode(kVK_Tab))
495
+ } else {
496
+ postUnicodeChar(unit)
497
+ }
498
+ i += 1
499
+ Thread.sleep(forTimeInterval: 0.005)
500
+ }
501
+ }
502
+
503
+ private func parseAndPostKey(_ keySpec: String) -> Bool {
504
+ let parts = keySpec.split(separator: "+").map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
505
+ guard !parts.isEmpty else { return false }
506
+
507
+ var flags: CGEventFlags = []
508
+ var keyToken: String?
509
+
510
+ for part in parts {
511
+ switch part {
512
+ case "cmd", "command", "meta", "super":
513
+ flags.insert(.maskCommand)
514
+ case "ctrl", "control":
515
+ flags.insert(.maskControl)
516
+ case "alt", "option", "opt":
517
+ flags.insert(.maskAlternate)
518
+ case "shift":
519
+ flags.insert(.maskShift)
520
+ default:
521
+ if keyToken != nil {
522
+ logError("ambiguous key chord: \(keySpec)")
523
+ return false
524
+ }
525
+ keyToken = part
526
+ }
527
+ }
528
+
529
+ guard let token = keyToken else {
530
+ logError("key chord has no key: \(keySpec)")
531
+ return false
532
+ }
533
+
534
+ if let named = namedKeyCodes[token] {
535
+ postKeyCode(named, flags: flags)
536
+ return true
537
+ }
538
+
539
+ if token.count == 1, let ch = token.first {
540
+ if let code = letterKeyCodes[ch] {
541
+ postKeyCode(code, flags: flags)
542
+ return true
543
+ }
544
+ if let code = digitKeyCodes[ch] {
545
+ postKeyCode(code, flags: flags)
546
+ return true
547
+ }
548
+ }
549
+
550
+ // Function keys F1–F12
551
+ if token.hasPrefix("f"), let n = Int(token.dropFirst()), (1...12).contains(n) {
552
+ let base = kVK_F1
553
+ postKeyCode(CGKeyCode(base + (n - 1)), flags: flags)
554
+ return true
555
+ }
556
+
557
+ logError("unknown key: \(keySpec)")
558
+ return false
559
+ }
560
+
561
+ // MARK: - Geometry
562
+
563
+ private func geometryResponse() -> [String: Any] {
564
+ let screen = NSScreen.screens.first ?? NSScreen.main
565
+ guard let screen else {
566
+ return ["ok": false, "error": "no display available"]
567
+ }
568
+ let frame = screen.frame
569
+ let scale = screen.backingScaleFactor
570
+ // Primary display: Cocoa origin is bottom-left of main screen; Quartz global top-left origin is (0,0).
571
+ return [
572
+ "ok": true,
573
+ "originX": 0,
574
+ "originY": 0,
575
+ "pointWidth": frame.width,
576
+ "pointHeight": frame.height,
577
+ "pixelWidth": frame.width * scale,
578
+ "pixelHeight": frame.height * scale,
579
+ "scaleFactor": scale,
580
+ ]
581
+ }
582
+
583
+ // MARK: - Command dispatch
584
+
585
+ private func handleCommand(_ object: [String: Any]) {
586
+ guard let cmd = object["cmd"] as? String else {
587
+ writeError("missing cmd")
588
+ return
589
+ }
590
+
591
+ switch cmd {
592
+ case "ping":
593
+ writeOk()
594
+
595
+ case "geometry":
596
+ writeResponse(geometryResponse())
597
+
598
+ case "ensureSession":
599
+ OverlayController.shared.ensureSession()
600
+ writeOk()
601
+
602
+ case "setIdle":
603
+ OverlayController.shared.setIdle()
604
+ writeOk()
605
+
606
+ case "endSession":
607
+ OverlayController.shared.endSession()
608
+ writeOk()
609
+
610
+ case "move":
611
+ guard let x = doubleValue(object, "pointX"), let y = doubleValue(object, "pointY") else {
612
+ writeError("move requires pointX and pointY")
613
+ return
614
+ }
615
+ postMouseMove(to: cgPointFromTopLeft(x, y))
616
+ writeOk()
617
+
618
+ case "click":
619
+ let buttonName = (object["button"] as? String ?? "left").lowercased()
620
+ let count = Int64(intValue(object, "count") ?? 1)
621
+ let button: CGMouseButton
622
+ switch buttonName {
623
+ case "left": button = .left
624
+ case "right": button = .right
625
+ default:
626
+ writeError("button must be left or right")
627
+ return
628
+ }
629
+ if count != 1 && count != 2 {
630
+ writeError("count must be 1 or 2")
631
+ return
632
+ }
633
+ postMouseClick(button: button, count: count)
634
+ writeOk()
635
+
636
+ case "drag":
637
+ guard let fromX = doubleValue(object, "fromX"),
638
+ let fromY = doubleValue(object, "fromY"),
639
+ let toX = doubleValue(object, "toX"),
640
+ let toY = doubleValue(object, "toY")
641
+ else {
642
+ writeError("drag requires fromX, fromY, toX, toY")
643
+ return
644
+ }
645
+ postDrag(from: cgPointFromTopLeft(fromX, fromY), to: cgPointFromTopLeft(toX, toY))
646
+ writeOk()
647
+
648
+ case "type":
649
+ guard let text = object["text"] as? String else {
650
+ writeError("type requires text")
651
+ return
652
+ }
653
+ typeText(text)
654
+ writeOk()
655
+
656
+ case "key":
657
+ guard let key = object["key"] as? String else {
658
+ writeError("key requires key")
659
+ return
660
+ }
661
+ if parseAndPostKey(key) {
662
+ writeOk()
663
+ } else {
664
+ writeError("unrecognized key: \(key)")
665
+ }
666
+
667
+ case "scroll":
668
+ guard let x = doubleValue(object, "pointX"),
669
+ let y = doubleValue(object, "pointY"),
670
+ let direction = object["direction"] as? String,
671
+ let amount = intValue(object, "amount")
672
+ else {
673
+ writeError("scroll requires pointX, pointY, direction, amount")
674
+ return
675
+ }
676
+ let dir = direction.lowercased()
677
+ guard ["up", "down", "left", "right"].contains(dir) else {
678
+ writeError("direction must be up, down, left, or right")
679
+ return
680
+ }
681
+ postScroll(at: cgPointFromTopLeft(x, y), direction: dir, amount: amount)
682
+ writeOk()
683
+
684
+ default:
685
+ writeError("unknown cmd: \(cmd)")
686
+ }
687
+ }
688
+
689
+ // MARK: - Stdin reader + main
690
+
691
+ private final class StdinReader {
692
+ private let queue = DispatchQueue(label: "com.jian.computer-helper.stdin")
693
+
694
+ func start() {
695
+ queue.async {
696
+ let handle = FileHandle.standardInput
697
+ var buffer = Data()
698
+ while true {
699
+ let chunk = handle.availableData
700
+ if chunk.isEmpty {
701
+ // EOF — exit the process cleanly.
702
+ DispatchQueue.main.async {
703
+ NSApp.terminate(nil)
704
+ }
705
+ return
706
+ }
707
+ buffer.append(chunk)
708
+ while let range = buffer.range(of: Data([0x0A])) {
709
+ let lineData = buffer.subdata(in: buffer.startIndex..<range.lowerBound)
710
+ buffer.removeSubrange(buffer.startIndex...range.lowerBound)
711
+ if let line = String(data: lineData, encoding: .utf8) {
712
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
713
+ if trimmed.isEmpty { continue }
714
+ DispatchQueue.main.async {
715
+ if let object = parseJSONLine(trimmed) {
716
+ handleCommand(object)
717
+ } else {
718
+ logError("invalid JSON line")
719
+ writeError("invalid JSON")
720
+ }
721
+ }
722
+ }
723
+ }
724
+ }
725
+ }
726
+ }
727
+ }
728
+
729
+ private let stdinReader = StdinReader()
730
+
731
+ autoreleasepool {
732
+ let app = NSApplication.shared
733
+ app.setActivationPolicy(.accessory)
734
+ stdinReader.start()
735
+ app.run()
736
+ }