@jian-ai/remote-control 0.7.1 → 0.8.0

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