@jian-ai/remote-control 0.8.1 → 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.
@@ -1,10 +1,16 @@
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
+ 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
+
8
14
  Published npm packages ship a prebuilt universal binary (Publish CI). From source on a Mac:
9
15
  ./build-helper.sh
10
16
  # or: swiftc -O -framework AppKit -framework Carbon -o computer-helper computer-helper.swift
@@ -73,16 +79,18 @@ private func intValue(_ object: [String: Any], _ key: String) -> Int? {
73
79
  return nil
74
80
  }
75
81
 
76
- // MARK: - Colors (Jian UI)
82
+ // MARK: - Colors (Jian + geometric white pointer)
77
83
 
78
- private let jianCream = NSColor(srgbRed: 0xF5 / 255, green: 0xF5 / 255, blue: 0xF3 / 255, alpha: 1)
79
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)
80
88
 
81
- private let sessionAlpha: CGFloat = 0.92
89
+ private let sessionAlpha: CGFloat = 0.94
82
90
  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)
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)
86
94
 
87
95
  // MARK: - Overlay view
88
96
 
@@ -98,11 +106,11 @@ private final class JianPointerView: NSView {
98
106
  guard let ctx = NSGraphicsContext.current?.cgContext else { return }
99
107
 
100
108
  if rippleVisible {
101
- let maxRadius = pointerSize * 0.95
102
- let radius = 4 + maxRadius * rippleProgress
109
+ let maxRadius = pointerSize * 0.9
110
+ let radius = 3 + maxRadius * rippleProgress
103
111
  let alpha = max(0, 1 - rippleProgress)
104
112
  ctx.saveGState()
105
- ctx.setStrokeColor(jianInk.withAlphaComponent(0.55 * alpha).cgColor)
113
+ ctx.setStrokeColor(jianGlow.withAlphaComponent(0.45 * alpha).cgColor)
106
114
  ctx.setLineWidth(1.5)
107
115
  ctx.strokeEllipse(in: CGRect(
108
116
  x: hotspot.x - radius,
@@ -113,57 +121,55 @@ private final class JianPointerView: NSView {
113
121
  ctx.restoreGState()
114
122
  }
115
123
 
116
- 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
+
117
137
  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()
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()
121
145
  path.fill()
122
146
  ctx.restoreGState()
123
147
 
124
- jianInk.setStroke()
125
- path.lineWidth = 2
126
- path.lineJoinStyle = .round
127
- 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
128
153
  path.stroke()
129
154
  }
130
155
 
131
- /// Ascending wing mark inspired by the Jian brand path (favicon / mark.tsx).
132
- 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 {
133
158
  let s = min(bounds.width, bounds.height)
134
159
  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
- )
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))
162
168
  path.close()
163
169
  return path
164
170
  }
165
171
 
166
- func flashClick(duration: TimeInterval = 0.2) {
172
+ func flashClick(duration: TimeInterval = 0.16) {
167
173
  rippleTimer?.invalidate()
168
174
  rippleVisible = true
169
175
  rippleProgress = 0
@@ -207,6 +213,7 @@ private final class OverlayController {
207
213
  private var pointerView: JianPointerView?
208
214
  /// Current tip/hotspot in global top-left Quartz coordinates.
209
215
  private var tipPoint = CGPoint.zero
216
+ private var hasTip = false
210
217
 
211
218
  private init() {}
212
219
 
@@ -241,6 +248,10 @@ private final class OverlayController {
241
248
  ensureWindow()
242
249
  guard let window else { return }
243
250
  window.alphaValue = sessionAlpha
251
+ if !hasTip {
252
+ tipPoint = quartzMouseLocation()
253
+ hasTip = true
254
+ }
244
255
  positionWindow()
245
256
  window.orderFrontRegardless()
246
257
  }
@@ -257,10 +268,12 @@ private final class OverlayController {
257
268
  guard let window else { return }
258
269
  window.orderOut(nil)
259
270
  window.alphaValue = 0
271
+ hasTip = false
260
272
  }
261
273
 
262
274
  func moveTip(to globalTopLeft: CGPoint) {
263
275
  tipPoint = globalTopLeft
276
+ hasTip = true
264
277
  ensureWindow()
265
278
  positionWindow()
266
279
  }
@@ -277,7 +290,6 @@ private final class OverlayController {
277
290
  // Quartz global: origin top-left of primary, Y down. Cocoa: origin bottom-left, Y up.
278
291
  let mainHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
279
292
  let tipCocoaY = mainHeight - tipPoint.y
280
- // Window origin is bottom-left; hotspot is hotspot.y down from the top of the view.
281
293
  let origin = NSPoint(
282
294
  x: tipPoint.x - hotspot.x,
283
295
  y: tipCocoaY - (pointerSize - hotspot.y)
@@ -286,105 +298,175 @@ private final class OverlayController {
286
298
  }
287
299
  }
288
300
 
289
- // MARK: - Coordinate helpers
301
+ // MARK: - Cursor location / preserve
290
302
 
291
- private func cgPointFromTopLeft(_ x: Double, _ y: Double) -> CGPoint {
292
- 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)
293
308
  }
294
309
 
295
- // MARK: - Mouse / keyboard
296
-
297
- private func postMouseMove(to point: CGPoint) {
298
- OverlayController.shared.moveTip(to: point)
310
+ /// Warp HID cursor without moving the Jian overlay tip.
311
+ private func warpSystemCursor(to point: CGPoint) {
299
312
  guard let event = CGEvent(
300
313
  mouseEventSource: nil,
301
314
  mouseType: .mouseMoved,
302
315
  mouseCursorPosition: point,
303
316
  mouseButton: .left
304
317
  ) else {
305
- logError("could not create mouseMoved event")
318
+ logError("could not create mouseMoved event for warp")
306
319
  return
307
320
  }
308
321
  event.post(tap: .cghidEventTap)
309
322
  }
310
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
+
311
379
  private func postMouseClick(button: CGMouseButton, count: Int64) {
312
380
  OverlayController.shared.flashClick()
313
381
  let point = OverlayController.shared.currentTip()
314
382
  let downType: CGEventType = button == .right ? .rightMouseDown : .leftMouseDown
315
383
  let upType: CGEventType = button == .right ? .rightMouseUp : .leftMouseUp
316
384
 
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)
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
+ }
338
409
  }
339
410
  }
340
411
  }
341
412
 
413
+ private func postClickAt(point: CGPoint, button: CGMouseButton, count: Int64) {
414
+ animateOverlay(to: point)
415
+ postMouseClick(button: button, count: count)
416
+ }
417
+
342
418
  private func postDrag(from: CGPoint, to: CGPoint) {
343
- postMouseMove(to: from)
419
+ OverlayController.shared.ensureSession()
420
+ animateOverlay(to: from)
344
421
  OverlayController.shared.flashClick()
345
- Thread.sleep(forTimeInterval: 0.02)
346
422
 
347
- if let down = CGEvent(
348
- mouseEventSource: nil,
349
- mouseType: .leftMouseDown,
350
- mouseCursorPosition: from,
351
- mouseButton: .left
352
- ) {
353
- down.post(tap: .cghidEventTap)
354
- }
423
+ withPreservedUserCursor {
424
+ warpSystemCursor(to: from)
425
+ Thread.sleep(forTimeInterval: 0.012)
355
426
 
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(
427
+ if let down = CGEvent(
365
428
  mouseEventSource: nil,
366
- mouseType: .leftMouseDragged,
367
- mouseCursorPosition: p,
429
+ mouseType: .leftMouseDown,
430
+ mouseCursorPosition: from,
368
431
  mouseButton: .left
369
432
  ) {
370
- drag.post(tap: .cghidEventTap)
433
+ down.post(tap: .cghidEventTap)
371
434
  }
372
- Thread.sleep(forTimeInterval: 0.008)
373
- }
374
435
 
375
- if let up = CGEvent(
376
- mouseEventSource: nil,
377
- mouseType: .leftMouseUp,
378
- mouseCursorPosition: to,
379
- mouseButton: .left
380
- ) {
381
- 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
+ }
382
464
  }
383
465
  OverlayController.shared.moveTip(to: to)
384
466
  }
385
467
 
386
468
  private func postScroll(at point: CGPoint, direction: String, amount: Int) {
387
- postMouseMove(to: point)
469
+ animateOverlay(to: point)
388
470
  let n = max(1, amount)
389
471
  var dx = 0
390
472
  var dy = 0
@@ -397,18 +479,21 @@ private func postScroll(at point: CGPoint, direction: String, amount: Int) {
397
479
  logError("unknown scroll direction: \(direction)")
398
480
  return
399
481
  }
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)
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)
410
496
  }
411
- Thread.sleep(forTimeInterval: 0.02)
412
497
  }
413
498
  }
414
499
 
@@ -480,7 +565,6 @@ private func postUnicodeChar(_ scalar: UniChar) {
480
565
  }
481
566
 
482
567
  private func typeText(_ text: String) {
483
- // Iterate UTF-16 code units; map Return/Tab to virtual keys when present as characters.
484
568
  let utf16 = Array(text.utf16)
485
569
  var i = 0
486
570
  while i < utf16.count {
@@ -496,7 +580,7 @@ private func typeText(_ text: String) {
496
580
  postUnicodeChar(unit)
497
581
  }
498
582
  i += 1
499
- Thread.sleep(forTimeInterval: 0.005)
583
+ Thread.sleep(forTimeInterval: 0.002)
500
584
  }
501
585
  }
502
586
 
@@ -547,7 +631,6 @@ private func parseAndPostKey(_ keySpec: String) -> Bool {
547
631
  }
548
632
  }
549
633
 
550
- // Function keys F1–F12
551
634
  if token.hasPrefix("f"), let n = Int(token.dropFirst()), (1...12).contains(n) {
552
635
  let base = kVK_F1
553
636
  postKeyCode(CGKeyCode(base + (n - 1)), flags: flags)
@@ -567,7 +650,6 @@ private func geometryResponse() -> [String: Any] {
567
650
  }
568
651
  let frame = screen.frame
569
652
  let scale = screen.backingScaleFactor
570
- // Primary display: Cocoa origin is bottom-left of main screen; Quartz global top-left origin is (0,0).
571
653
  return [
572
654
  "ok": true,
573
655
  "originX": 0,
@@ -592,6 +674,33 @@ private func handleCommand(_ object: [String: Any]) {
592
674
  case "ping":
593
675
  writeOk()
594
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
+
595
704
  case "geometry":
596
705
  writeResponse(geometryResponse())
597
706
 
@@ -612,7 +721,8 @@ private func handleCommand(_ object: [String: Any]) {
612
721
  writeError("move requires pointX and pointY")
613
722
  return
614
723
  }
615
- postMouseMove(to: cgPointFromTopLeft(x, y))
724
+ // Overlay-only: do not warp the user’s system cursor.
725
+ postOverlayMove(to: CGPoint(x: x, y: y))
616
726
  writeOk()
617
727
 
618
728
  case "click":
@@ -633,6 +743,28 @@ private func handleCommand(_ object: [String: Any]) {
633
743
  postMouseClick(button: button, count: count)
634
744
  writeOk()
635
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
+
636
768
  case "drag":
637
769
  guard let fromX = doubleValue(object, "fromX"),
638
770
  let fromY = doubleValue(object, "fromY"),
@@ -642,7 +774,7 @@ private func handleCommand(_ object: [String: Any]) {
642
774
  writeError("drag requires fromX, fromY, toX, toY")
643
775
  return
644
776
  }
645
- postDrag(from: cgPointFromTopLeft(fromX, fromY), to: cgPointFromTopLeft(toX, toY))
777
+ postDrag(from: CGPoint(x: fromX, y: fromY), to: CGPoint(x: toX, y: toY))
646
778
  writeOk()
647
779
 
648
780
  case "type":
@@ -678,7 +810,7 @@ private func handleCommand(_ object: [String: Any]) {
678
810
  writeError("direction must be up, down, left, or right")
679
811
  return
680
812
  }
681
- postScroll(at: cgPointFromTopLeft(x, y), direction: dir, amount: amount)
813
+ postScroll(at: CGPoint(x: x, y: y), direction: dir, amount: amount)
682
814
  writeOk()
683
815
 
684
816
  default:
@@ -698,7 +830,6 @@ private final class StdinReader {
698
830
  while true {
699
831
  let chunk = handle.availableData
700
832
  if chunk.isEmpty {
701
- // EOF — exit the process cleanly.
702
833
  DispatchQueue.main.async {
703
834
  NSApp.terminate(nil)
704
835
  }
@@ -726,8 +857,45 @@ private final class StdinReader {
726
857
  }
727
858
  }
728
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
+
729
893
  private let stdinReader = StdinReader()
730
894
 
895
+ if CommandLine.arguments.contains("--session-status") {
896
+ printSessionStatusAndExit()
897
+ }
898
+
731
899
  autoreleasepool {
732
900
  let app = NSApplication.shared
733
901
  app.setActivationPolicy(.accessory)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jian-ai/remote-control",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Jian Remote Control worker — connect a computer to a Jian gateway over HTTPS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,8 @@
13
13
  "files": [
14
14
  "bin",
15
15
  "macos",
16
+ "linux",
17
+ "windows",
16
18
  "README.md"
17
19
  ],
18
20
  "engines": {