@jian-ai/remote-control 0.8.0 → 0.8.2

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,46 @@
1
+ #!/usr/bin/env bash
2
+ # Build a universal (arm64 + x86_64) computer-helper for the npm package.
3
+ # Run on macOS with Xcode CLT. Used by Publish CI and local packaging.
4
+ set -euo pipefail
5
+
6
+ ROOT="$(cd "$(dirname "$0")" && pwd)"
7
+ SRC="$ROOT/computer-helper.swift"
8
+ OUT="${1:-$ROOT/computer-helper}"
9
+ MIN_OS="${MACOSX_DEPLOYMENT_TARGET:-13.0}"
10
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/jian-computer-helper.XXXXXX")"
11
+ cleanup() { rm -rf "$TMP"; }
12
+ trap cleanup EXIT
13
+
14
+ if [[ "$(uname -s)" != "Darwin" ]]; then
15
+ echo "build-helper.sh must run on macOS (swiftc + lipo)." >&2
16
+ exit 1
17
+ fi
18
+ if ! command -v swiftc >/dev/null 2>&1; then
19
+ echo "swiftc not found. Install Xcode Command Line Tools." >&2
20
+ exit 1
21
+ fi
22
+ if [[ ! -f "$SRC" ]]; then
23
+ echo "Missing source: $SRC" >&2
24
+ exit 1
25
+ fi
26
+
27
+ swiftc -O \
28
+ -target "arm64-apple-macosx${MIN_OS}" \
29
+ -framework AppKit -framework Carbon \
30
+ -o "$TMP/computer-helper-arm64" "$SRC"
31
+
32
+ swiftc -O \
33
+ -target "x86_64-apple-macosx${MIN_OS}" \
34
+ -framework AppKit -framework Carbon \
35
+ -o "$TMP/computer-helper-x86_64" "$SRC"
36
+
37
+ lipo -create -output "$OUT" "$TMP/computer-helper-arm64" "$TMP/computer-helper-x86_64"
38
+ chmod 755 "$OUT"
39
+
40
+ # Ad-hoc sign so Gatekeeper is quieter for LaunchAgent children; Developer ID overlays this in CI when secrets exist.
41
+ if command -v codesign >/dev/null 2>&1; then
42
+ codesign --force -s - --identifier com.jian.remote.computer-helper "$OUT" 2>/dev/null || true
43
+ fi
44
+
45
+ echo "Built universal helper: $OUT"
46
+ lipo -info "$OUT"
Binary file
@@ -1,12 +1,19 @@
1
1
  /*
2
2
  Jian Remote Control — macOS computer-use helper
3
3
 
4
- Long-lived AppKit process: overlay pointer + synthetic mouse/keyboard via CoreGraphics.
4
+ Long-lived AppKit process: Jian overlay pointer + synthetic mouse/keyboard via CoreGraphics.
5
5
  Protocol: newline-delimited JSON on stdin → newline-delimited JSON on stdout (flush each).
6
6
  Errors go to stderr in English only.
7
7
 
8
- Build (on a Mac with Xcode CLT):
9
- swiftc -O -framework AppKit -framework Carbon -o computer-helper computer-helper.swift
8
+ Pointer UX (Lucas / v0.8.2 polish):
9
+ - Geometric white arrow (anathema-white style) with a soft brand-blue glow underneath.
10
+ - Agent actions save the user’s cursor, hide it briefly, drive HID at the target, then
11
+ restore — so clicks do not leave the physical mouse stranded at the Jian position.
12
+ - Overlay tip animates (ease-out) between positions; system cursor is not the visible UX.
13
+
14
+ Published npm packages ship a prebuilt universal binary (Publish CI). From source on a Mac:
15
+ ./build-helper.sh
16
+ # or: swiftc -O -framework AppKit -framework Carbon -o computer-helper computer-helper.swift
10
17
 
11
18
  Run as a child of the Node worker; keep stdin/stdout pipes open for the session.
12
19
  */
@@ -72,16 +79,18 @@ private func intValue(_ object: [String: Any], _ key: String) -> Int? {
72
79
  return nil
73
80
  }
74
81
 
75
- // MARK: - Colors (Jian UI)
82
+ // MARK: - Colors (Jian + geometric white pointer)
76
83
 
77
- private let jianCream = NSColor(srgbRed: 0xF5 / 255, green: 0xF5 / 255, blue: 0xF3 / 255, alpha: 1)
78
84
  private let jianInk = NSColor(srgbRed: 0x17 / 255, green: 0x17 / 255, blue: 0x16 / 255, alpha: 1)
85
+ /// Soft brand blue under the pointer — visible, not neon.
86
+ private let jianGlow = NSColor(srgbRed: 0x3A / 255, green: 0x7A / 255, blue: 0xD6 / 255, alpha: 1)
87
+ private let pointerWhite = NSColor(srgbRed: 0xFA / 255, green: 0xFA / 255, blue: 0xF8 / 255, alpha: 1)
79
88
 
80
- private let sessionAlpha: CGFloat = 0.92
89
+ private let sessionAlpha: CGFloat = 0.94
81
90
  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)
91
+ private let pointerSize: CGFloat = 36
92
+ /// Tip of the geometric arrow (hotspot) near the view's top-left.
93
+ private let hotspot = CGPoint(x: 2, y: 2)
85
94
 
86
95
  // MARK: - Overlay view
87
96
 
@@ -97,11 +106,11 @@ private final class JianPointerView: NSView {
97
106
  guard let ctx = NSGraphicsContext.current?.cgContext else { return }
98
107
 
99
108
  if rippleVisible {
100
- let maxRadius = pointerSize * 0.95
101
- let radius = 4 + maxRadius * rippleProgress
109
+ let maxRadius = pointerSize * 0.9
110
+ let radius = 3 + maxRadius * rippleProgress
102
111
  let alpha = max(0, 1 - rippleProgress)
103
112
  ctx.saveGState()
104
- ctx.setStrokeColor(jianInk.withAlphaComponent(0.55 * alpha).cgColor)
113
+ ctx.setStrokeColor(jianGlow.withAlphaComponent(0.45 * alpha).cgColor)
105
114
  ctx.setLineWidth(1.5)
106
115
  ctx.strokeEllipse(in: CGRect(
107
116
  x: hotspot.x - radius,
@@ -112,57 +121,55 @@ private final class JianPointerView: NSView {
112
121
  ctx.restoreGState()
113
122
  }
114
123
 
115
- let path = Self.wingPath(in: bounds)
124
+ let path = Self.arrowPath(in: bounds)
125
+
126
+ // Soft blue glow under the white blade (brand cue without neon spam).
127
+ ctx.saveGState()
128
+ ctx.setShadow(
129
+ offset: CGSize(width: 0, height: 2),
130
+ blur: 10,
131
+ color: jianGlow.withAlphaComponent(0.55).cgColor
132
+ )
133
+ jianGlow.withAlphaComponent(0.35).setFill()
134
+ path.fill()
135
+ ctx.restoreGState()
136
+
116
137
  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()
138
+ ctx.setShadow(
139
+ offset: CGSize(width: 0.5, height: 1.2),
140
+ blur: 2.5,
141
+ color: NSColor.black.withAlphaComponent(0.22).cgColor
142
+ )
143
+ let fillAlpha = 1 - fillPulse * 0.28
144
+ pointerWhite.withAlphaComponent(fillAlpha).setFill()
120
145
  path.fill()
121
146
  ctx.restoreGState()
122
147
 
123
- jianInk.setStroke()
124
- path.lineWidth = 2
125
- path.lineJoinStyle = .round
126
- path.lineCapStyle = .round
148
+ jianInk.withAlphaComponent(0.85).setStroke()
149
+ path.lineWidth = 1.15
150
+ path.lineJoinStyle = .miter
151
+ path.lineCapStyle = .butt
152
+ path.miterLimit = 8
127
153
  path.stroke()
128
154
  }
129
155
 
130
- /// Ascending wing mark inspired by the Jian brand path (favicon / mark.tsx).
131
- private static func wingPath(in bounds: NSRect) -> NSBezierPath {
156
+ /// Sharp geometric pointer (anathema-white style): tip → shaft → barb.
157
+ private static func arrowPath(in bounds: NSRect) -> NSBezierPath {
132
158
  let s = min(bounds.width, bounds.height)
133
159
  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
- )
160
+ // Coordinates in view space (flipped Y): tip near top-left hotspot.
161
+ path.move(to: CGPoint(x: s * 0.06, y: s * 0.06))
162
+ path.line(to: CGPoint(x: s * 0.06, y: s * 0.72))
163
+ path.line(to: CGPoint(x: s * 0.28, y: s * 0.55))
164
+ path.line(to: CGPoint(x: s * 0.42, y: s * 0.88))
165
+ path.line(to: CGPoint(x: s * 0.55, y: s * 0.82))
166
+ path.line(to: CGPoint(x: s * 0.36, y: s * 0.48))
167
+ path.line(to: CGPoint(x: s * 0.62, y: s * 0.48))
161
168
  path.close()
162
169
  return path
163
170
  }
164
171
 
165
- func flashClick(duration: TimeInterval = 0.2) {
172
+ func flashClick(duration: TimeInterval = 0.16) {
166
173
  rippleTimer?.invalidate()
167
174
  rippleVisible = true
168
175
  rippleProgress = 0
@@ -206,6 +213,7 @@ private final class OverlayController {
206
213
  private var pointerView: JianPointerView?
207
214
  /// Current tip/hotspot in global top-left Quartz coordinates.
208
215
  private var tipPoint = CGPoint.zero
216
+ private var hasTip = false
209
217
 
210
218
  private init() {}
211
219
 
@@ -240,6 +248,10 @@ private final class OverlayController {
240
248
  ensureWindow()
241
249
  guard let window else { return }
242
250
  window.alphaValue = sessionAlpha
251
+ if !hasTip {
252
+ tipPoint = quartzMouseLocation()
253
+ hasTip = true
254
+ }
243
255
  positionWindow()
244
256
  window.orderFrontRegardless()
245
257
  }
@@ -256,10 +268,12 @@ private final class OverlayController {
256
268
  guard let window else { return }
257
269
  window.orderOut(nil)
258
270
  window.alphaValue = 0
271
+ hasTip = false
259
272
  }
260
273
 
261
274
  func moveTip(to globalTopLeft: CGPoint) {
262
275
  tipPoint = globalTopLeft
276
+ hasTip = true
263
277
  ensureWindow()
264
278
  positionWindow()
265
279
  }
@@ -276,7 +290,6 @@ private final class OverlayController {
276
290
  // Quartz global: origin top-left of primary, Y down. Cocoa: origin bottom-left, Y up.
277
291
  let mainHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
278
292
  let tipCocoaY = mainHeight - tipPoint.y
279
- // Window origin is bottom-left; hotspot is hotspot.y down from the top of the view.
280
293
  let origin = NSPoint(
281
294
  x: tipPoint.x - hotspot.x,
282
295
  y: tipCocoaY - (pointerSize - hotspot.y)
@@ -285,105 +298,175 @@ private final class OverlayController {
285
298
  }
286
299
  }
287
300
 
288
- // MARK: - Coordinate helpers
301
+ // MARK: - Cursor location / preserve
289
302
 
290
- private func cgPointFromTopLeft(_ x: Double, _ y: Double) -> CGPoint {
291
- CGPoint(x: x, y: y)
303
+ /// Quartz global top-left coordinates for the live system cursor.
304
+ private func quartzMouseLocation() -> CGPoint {
305
+ let cocoa = NSEvent.mouseLocation
306
+ let mainHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
307
+ return CGPoint(x: cocoa.x, y: mainHeight - cocoa.y)
292
308
  }
293
309
 
294
- // MARK: - Mouse / keyboard
295
-
296
- private func postMouseMove(to point: CGPoint) {
297
- OverlayController.shared.moveTip(to: point)
310
+ /// Warp HID cursor without moving the Jian overlay tip.
311
+ private func warpSystemCursor(to point: CGPoint) {
298
312
  guard let event = CGEvent(
299
313
  mouseEventSource: nil,
300
314
  mouseType: .mouseMoved,
301
315
  mouseCursorPosition: point,
302
316
  mouseButton: .left
303
317
  ) else {
304
- logError("could not create mouseMoved event")
318
+ logError("could not create mouseMoved event for warp")
305
319
  return
306
320
  }
307
321
  event.post(tap: .cghidEventTap)
308
322
  }
309
323
 
324
+ /**
325
+ Save → hide → act → restore → show.
326
+
327
+ HID clicks must be posted at the target (macOS delivers them at the event location),
328
+ but we restore immediately so Lucas’s physical mouse is not left at the Jian point.
329
+ */
330
+ private func withPreservedUserCursor(_ body: () -> Void) {
331
+ let saved = quartzMouseLocation()
332
+ CGDisplayHideCursor(CGMainDisplayID())
333
+ body()
334
+ warpSystemCursor(to: saved)
335
+ // Tiny yield so the restore event lands before unhide.
336
+ Thread.sleep(forTimeInterval: 0.008)
337
+ CGDisplayShowCursor(CGMainDisplayID())
338
+ }
339
+
340
+ // MARK: - Smooth overlay motion
341
+
342
+ private func easeOutCubic(_ t: Double) -> Double {
343
+ let x = min(1, max(0, t))
344
+ return 1 - pow(1 - x, 3)
345
+ }
346
+
347
+ private func animateOverlay(to target: CGPoint) {
348
+ OverlayController.shared.ensureSession()
349
+ let start = OverlayController.shared.currentTip()
350
+ let distance = hypot(target.x - start.x, target.y - start.y)
351
+ if distance < 1.5 {
352
+ OverlayController.shared.moveTip(to: target)
353
+ return
354
+ }
355
+ // ~0.25 ms/px, clamped — snappy for agent loops.
356
+ let duration = min(0.11, max(0.028, distance * 0.00025))
357
+ let steps = max(1, Int(ceil(duration * 60)))
358
+ let stepSleep = duration / Double(steps)
359
+ for i in 1...steps {
360
+ let t = easeOutCubic(Double(i) / Double(steps))
361
+ let p = CGPoint(
362
+ x: start.x + (target.x - start.x) * t,
363
+ y: start.y + (target.y - start.y) * t
364
+ )
365
+ OverlayController.shared.moveTip(to: p)
366
+ if i < steps {
367
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: stepSleep))
368
+ }
369
+ }
370
+ OverlayController.shared.moveTip(to: target)
371
+ }
372
+
373
+ // MARK: - Mouse / keyboard
374
+
375
+ private func postOverlayMove(to point: CGPoint) {
376
+ animateOverlay(to: point)
377
+ }
378
+
310
379
  private func postMouseClick(button: CGMouseButton, count: Int64) {
311
380
  OverlayController.shared.flashClick()
312
381
  let point = OverlayController.shared.currentTip()
313
382
  let downType: CGEventType = button == .right ? .rightMouseDown : .leftMouseDown
314
383
  let upType: CGEventType = button == .right ? .rightMouseUp : .leftMouseUp
315
384
 
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)
385
+ withPreservedUserCursor {
386
+ warpSystemCursor(to: point)
387
+ for clickIndex in 1...max(1, count) {
388
+ if let down = CGEvent(
389
+ mouseEventSource: nil,
390
+ mouseType: downType,
391
+ mouseCursorPosition: point,
392
+ mouseButton: button
393
+ ) {
394
+ down.setIntegerValueField(.mouseEventClickState, value: clickIndex)
395
+ down.post(tap: .cghidEventTap)
396
+ }
397
+ if let up = CGEvent(
398
+ mouseEventSource: nil,
399
+ mouseType: upType,
400
+ mouseCursorPosition: point,
401
+ mouseButton: button
402
+ ) {
403
+ up.setIntegerValueField(.mouseEventClickState, value: clickIndex)
404
+ up.post(tap: .cghidEventTap)
405
+ }
406
+ if clickIndex < count {
407
+ Thread.sleep(forTimeInterval: 0.04)
408
+ }
337
409
  }
338
410
  }
339
411
  }
340
412
 
413
+ private func postClickAt(point: CGPoint, button: CGMouseButton, count: Int64) {
414
+ animateOverlay(to: point)
415
+ postMouseClick(button: button, count: count)
416
+ }
417
+
341
418
  private func postDrag(from: CGPoint, to: CGPoint) {
342
- postMouseMove(to: from)
419
+ OverlayController.shared.ensureSession()
420
+ animateOverlay(to: from)
343
421
  OverlayController.shared.flashClick()
344
- Thread.sleep(forTimeInterval: 0.02)
345
422
 
346
- if let down = CGEvent(
347
- mouseEventSource: nil,
348
- mouseType: .leftMouseDown,
349
- mouseCursorPosition: from,
350
- mouseButton: .left
351
- ) {
352
- down.post(tap: .cghidEventTap)
353
- }
423
+ withPreservedUserCursor {
424
+ warpSystemCursor(to: from)
425
+ Thread.sleep(forTimeInterval: 0.012)
354
426
 
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(
427
+ if let down = CGEvent(
364
428
  mouseEventSource: nil,
365
- mouseType: .leftMouseDragged,
366
- mouseCursorPosition: p,
429
+ mouseType: .leftMouseDown,
430
+ mouseCursorPosition: from,
367
431
  mouseButton: .left
368
432
  ) {
369
- drag.post(tap: .cghidEventTap)
433
+ down.post(tap: .cghidEventTap)
370
434
  }
371
- Thread.sleep(forTimeInterval: 0.008)
372
- }
373
435
 
374
- if let up = CGEvent(
375
- mouseEventSource: nil,
376
- mouseType: .leftMouseUp,
377
- mouseCursorPosition: to,
378
- mouseButton: .left
379
- ) {
380
- up.post(tap: .cghidEventTap)
436
+ let distance = hypot(to.x - from.x, to.y - from.y)
437
+ let steps = max(6, min(14, Int(distance / 40) + 6))
438
+ for i in 1...steps {
439
+ let t = easeOutCubic(Double(i) / Double(steps))
440
+ let p = CGPoint(
441
+ x: from.x + (to.x - from.x) * t,
442
+ y: from.y + (to.y - from.y) * t
443
+ )
444
+ OverlayController.shared.moveTip(to: p)
445
+ if let drag = CGEvent(
446
+ mouseEventSource: nil,
447
+ mouseType: .leftMouseDragged,
448
+ mouseCursorPosition: p,
449
+ mouseButton: .left
450
+ ) {
451
+ drag.post(tap: .cghidEventTap)
452
+ }
453
+ Thread.sleep(forTimeInterval: 0.006)
454
+ }
455
+
456
+ if let up = CGEvent(
457
+ mouseEventSource: nil,
458
+ mouseType: .leftMouseUp,
459
+ mouseCursorPosition: to,
460
+ mouseButton: .left
461
+ ) {
462
+ up.post(tap: .cghidEventTap)
463
+ }
381
464
  }
382
465
  OverlayController.shared.moveTip(to: to)
383
466
  }
384
467
 
385
468
  private func postScroll(at point: CGPoint, direction: String, amount: Int) {
386
- postMouseMove(to: point)
469
+ animateOverlay(to: point)
387
470
  let n = max(1, amount)
388
471
  var dx = 0
389
472
  var dy = 0
@@ -396,18 +479,21 @@ private func postScroll(at point: CGPoint, direction: String, amount: Int) {
396
479
  logError("unknown scroll direction: \(direction)")
397
480
  return
398
481
  }
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)
482
+ withPreservedUserCursor {
483
+ warpSystemCursor(to: point)
484
+ for _ in 0..<n {
485
+ if let event = CGEvent(
486
+ scrollWheelEvent2Source: nil,
487
+ units: .line,
488
+ wheelCount: 2,
489
+ wheel1: Int32(dy),
490
+ wheel2: Int32(dx),
491
+ wheel3: 0
492
+ ) {
493
+ event.post(tap: .cghidEventTap)
494
+ }
495
+ Thread.sleep(forTimeInterval: 0.012)
409
496
  }
410
- Thread.sleep(forTimeInterval: 0.02)
411
497
  }
412
498
  }
413
499
 
@@ -479,7 +565,6 @@ private func postUnicodeChar(_ scalar: UniChar) {
479
565
  }
480
566
 
481
567
  private func typeText(_ text: String) {
482
- // Iterate UTF-16 code units; map Return/Tab to virtual keys when present as characters.
483
568
  let utf16 = Array(text.utf16)
484
569
  var i = 0
485
570
  while i < utf16.count {
@@ -495,7 +580,7 @@ private func typeText(_ text: String) {
495
580
  postUnicodeChar(unit)
496
581
  }
497
582
  i += 1
498
- Thread.sleep(forTimeInterval: 0.005)
583
+ Thread.sleep(forTimeInterval: 0.002)
499
584
  }
500
585
  }
501
586
 
@@ -546,7 +631,6 @@ private func parseAndPostKey(_ keySpec: String) -> Bool {
546
631
  }
547
632
  }
548
633
 
549
- // Function keys F1–F12
550
634
  if token.hasPrefix("f"), let n = Int(token.dropFirst()), (1...12).contains(n) {
551
635
  let base = kVK_F1
552
636
  postKeyCode(CGKeyCode(base + (n - 1)), flags: flags)
@@ -566,7 +650,6 @@ private func geometryResponse() -> [String: Any] {
566
650
  }
567
651
  let frame = screen.frame
568
652
  let scale = screen.backingScaleFactor
569
- // Primary display: Cocoa origin is bottom-left of main screen; Quartz global top-left origin is (0,0).
570
653
  return [
571
654
  "ok": true,
572
655
  "originX": 0,
@@ -591,6 +674,33 @@ private func handleCommand(_ object: [String: Any]) {
591
674
  case "ping":
592
675
  writeOk()
593
676
 
677
+ case "sessionStatus":
678
+ var locked = false
679
+ if let raw = CGSessionCopyCurrentDictionary() {
680
+ let dict = raw as NSDictionary
681
+ if let number = dict["CGSSessionScreenIsLocked"] as? NSNumber {
682
+ locked = number.boolValue
683
+ } else if let flag = dict["CGSSessionScreenIsLocked"] as? Bool {
684
+ locked = flag
685
+ }
686
+ }
687
+ // CGDisplayIsAsleep returns boolean_t (Int32); Swift will not coerce it in `if`.
688
+ let displayAsleep = CGDisplayIsAsleep(CGMainDisplayID()) != 0
689
+ let state: String
690
+ if locked {
691
+ state = "locked"
692
+ } else if displayAsleep {
693
+ state = "display_asleep"
694
+ } else {
695
+ state = "unlocked"
696
+ }
697
+ writeResponse([
698
+ "ok": true,
699
+ "state": state,
700
+ "locked": locked,
701
+ "displayAsleep": displayAsleep,
702
+ ])
703
+
594
704
  case "geometry":
595
705
  writeResponse(geometryResponse())
596
706
 
@@ -611,7 +721,8 @@ private func handleCommand(_ object: [String: Any]) {
611
721
  writeError("move requires pointX and pointY")
612
722
  return
613
723
  }
614
- postMouseMove(to: cgPointFromTopLeft(x, y))
724
+ // Overlay-only: do not warp the user’s system cursor.
725
+ postOverlayMove(to: CGPoint(x: x, y: y))
615
726
  writeOk()
616
727
 
617
728
  case "click":
@@ -632,6 +743,28 @@ private func handleCommand(_ object: [String: Any]) {
632
743
  postMouseClick(button: button, count: count)
633
744
  writeOk()
634
745
 
746
+ case "clickAt":
747
+ guard let x = doubleValue(object, "pointX"), let y = doubleValue(object, "pointY") else {
748
+ writeError("clickAt requires pointX and pointY")
749
+ return
750
+ }
751
+ let buttonName = (object["button"] as? String ?? "left").lowercased()
752
+ let count = Int64(intValue(object, "count") ?? 1)
753
+ let button: CGMouseButton
754
+ switch buttonName {
755
+ case "left": button = .left
756
+ case "right": button = .right
757
+ default:
758
+ writeError("button must be left or right")
759
+ return
760
+ }
761
+ if count != 1 && count != 2 {
762
+ writeError("count must be 1 or 2")
763
+ return
764
+ }
765
+ postClickAt(point: CGPoint(x: x, y: y), button: button, count: count)
766
+ writeOk()
767
+
635
768
  case "drag":
636
769
  guard let fromX = doubleValue(object, "fromX"),
637
770
  let fromY = doubleValue(object, "fromY"),
@@ -641,7 +774,7 @@ private func handleCommand(_ object: [String: Any]) {
641
774
  writeError("drag requires fromX, fromY, toX, toY")
642
775
  return
643
776
  }
644
- postDrag(from: cgPointFromTopLeft(fromX, fromY), to: cgPointFromTopLeft(toX, toY))
777
+ postDrag(from: CGPoint(x: fromX, y: fromY), to: CGPoint(x: toX, y: toY))
645
778
  writeOk()
646
779
 
647
780
  case "type":
@@ -677,7 +810,7 @@ private func handleCommand(_ object: [String: Any]) {
677
810
  writeError("direction must be up, down, left, or right")
678
811
  return
679
812
  }
680
- postScroll(at: cgPointFromTopLeft(x, y), direction: dir, amount: amount)
813
+ postScroll(at: CGPoint(x: x, y: y), direction: dir, amount: amount)
681
814
  writeOk()
682
815
 
683
816
  default:
@@ -697,7 +830,6 @@ private final class StdinReader {
697
830
  while true {
698
831
  let chunk = handle.availableData
699
832
  if chunk.isEmpty {
700
- // EOF — exit the process cleanly.
701
833
  DispatchQueue.main.async {
702
834
  NSApp.terminate(nil)
703
835
  }
@@ -725,8 +857,45 @@ private final class StdinReader {
725
857
  }
726
858
  }
727
859
 
860
+ // MARK: - Session status (one-shot, no AppKit run loop)
861
+
862
+ /// Reports lock / display-asleep without starting the overlay. Used by the Node worker poll
863
+ /// so a locked Mac stays "online" with an explicit uiState instead of looking dead.
864
+ private func printSessionStatusAndExit() -> Never {
865
+ var locked = false
866
+ if let raw = CGSessionCopyCurrentDictionary() {
867
+ let dict = raw as NSDictionary
868
+ if let number = dict["CGSSessionScreenIsLocked"] as? NSNumber {
869
+ locked = number.boolValue
870
+ } else if let flag = dict["CGSSessionScreenIsLocked"] as? Bool {
871
+ locked = flag
872
+ }
873
+ }
874
+ // CGDisplayIsAsleep returns boolean_t (Int32); Swift will not coerce it in `if`.
875
+ let displayAsleep = CGDisplayIsAsleep(CGMainDisplayID()) != 0
876
+ let state: String
877
+ if locked {
878
+ state = "locked"
879
+ } else if displayAsleep {
880
+ state = "display_asleep"
881
+ } else {
882
+ state = "unlocked"
883
+ }
884
+ writeResponse([
885
+ "ok": true,
886
+ "state": state,
887
+ "locked": locked,
888
+ "displayAsleep": displayAsleep,
889
+ ])
890
+ exit(0)
891
+ }
892
+
728
893
  private let stdinReader = StdinReader()
729
894
 
895
+ if CommandLine.arguments.contains("--session-status") {
896
+ printSessionStatusAndExit()
897
+ }
898
+
730
899
  autoreleasepool {
731
900
  let app = NSApplication.shared
732
901
  app.setActivationPolicy(.accessory)