@anionex/dsh-computer-use 0.2.0 → 0.3.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.
Files changed (53) hide show
  1. package/README.md +13 -7
  2. package/README.zh.md +13 -7
  3. package/cordis.patch.yml +3 -0
  4. package/docs/interaction-policy.md +6 -4
  5. package/docs/interaction-policy.zh.md +7 -5
  6. package/lib/client.js +23 -11
  7. package/lib/client.js.map +1 -1
  8. package/lib/config.js +13 -3
  9. package/lib/config.js.map +1 -1
  10. package/lib/providers/macos.js +79 -14
  11. package/lib/providers/macos.js.map +1 -1
  12. package/lib/providers/native-helper.js +247 -22
  13. package/lib/providers/native-helper.js.map +1 -1
  14. package/lib/providers/unsupported.js +6 -0
  15. package/lib/providers/unsupported.js.map +1 -1
  16. package/lib/service.js +74 -32
  17. package/lib/service.js.map +1 -1
  18. package/lib/types/backend.d.ts +18 -3
  19. package/lib/types/backend.d.ts.map +1 -1
  20. package/lib/types/client/index.d.ts +5 -2
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/settings-validation.d.ts +3 -0
  23. package/lib/types/client/settings-validation.d.ts.map +1 -0
  24. package/lib/types/config.d.ts +7 -1
  25. package/lib/types/config.d.ts.map +1 -1
  26. package/lib/types/providers/macos.d.ts +8 -2
  27. package/lib/types/providers/macos.d.ts.map +1 -1
  28. package/lib/types/providers/native-helper.d.ts +9 -1
  29. package/lib/types/providers/native-helper.d.ts.map +1 -1
  30. package/lib/types/providers/unsupported.d.ts +6 -2
  31. package/lib/types/providers/unsupported.d.ts.map +1 -1
  32. package/lib/types/service.d.ts.map +1 -1
  33. package/native/macos/Sources/Fixture/main.swift +46 -0
  34. package/native/macos/Sources/Helper/CursorMotion.swift +67 -0
  35. package/native/macos/Sources/Helper/CursorOverlay.swift +334 -60
  36. package/native/macos/Sources/Helper/TargetedPointer.swift +23 -10
  37. package/native/macos/Sources/Helper/main.swift +80 -5
  38. package/native/macos/Sources/Monitor/main.swift +15 -1
  39. package/native/macos/bin/dsh-computer-use-helper +0 -0
  40. package/native/macos/manifest.json +3 -3
  41. package/package.json +16 -18
  42. package/scripts/build-client.mjs +0 -0
  43. package/scripts/build-native.mjs +0 -0
  44. package/scripts/model-e2e.mjs +0 -0
  45. package/scripts/validate.mjs +0 -0
  46. package/src/backend.ts +18 -3
  47. package/src/client/index.tsx +36 -13
  48. package/src/client/settings-validation.ts +14 -0
  49. package/src/config.ts +20 -4
  50. package/src/providers/macos.ts +102 -14
  51. package/src/providers/native-helper.ts +241 -22
  52. package/src/providers/unsupported.ts +11 -1
  53. package/src/service.ts +100 -37
@@ -7,7 +7,9 @@ import QuartzCore
7
7
  private struct CursorOverlayCommand {
8
8
  let operation: String
9
9
  let point: CGPoint?
10
- let durationMs: Int
10
+ let durationMs: Int?
11
+ let speedPxPerSecond: Double?
12
+ let accelerationPxPerSecondSquared: Double?
11
13
  let autoHideMs: Int
12
14
  let targetPid: pid_t?
13
15
  let targetWindowNumber: Int64?
@@ -16,6 +18,8 @@ private struct CursorOverlayCommand {
16
18
 
17
19
  private static let maximumCoordinateMagnitude = 100_000.0
18
20
  private static let maximumDurationMs = 2_000
21
+ private static let maximumSpeedPxPerSecond = 50_000.0
22
+ private static let maximumAccelerationPxPerSecondSquared = 500_000.0
19
23
  private static let maximumAutoHideMs = 30_000
20
24
 
21
25
  private static func number(_ object: Any?, field: String) throws -> NSNumber? {
@@ -56,14 +60,31 @@ private struct CursorOverlayCommand {
56
60
  } else {
57
61
  self.point = nil
58
62
  }
59
- let durationMs = try Self.integer(object["durationMs"], field: "durationMs", fallback: 180)
63
+ let durationMs = object["durationMs"] == nil
64
+ ? nil
65
+ : try Self.integer(object["durationMs"], field: "durationMs", fallback: 180)
60
66
  let autoHideMs = try Self.integer(object["autoHideMs"], field: "autoHideMs", fallback: 0)
61
- guard durationMs >= 0, durationMs <= Self.maximumDurationMs,
67
+ guard durationMs == nil || (durationMs! >= 0 && durationMs! <= Self.maximumDurationMs),
62
68
  autoHideMs >= 0, autoHideMs <= Self.maximumAutoHideMs else {
63
69
  throw CursorOverlayError(message: "cursor overlay timing is outside the supported range")
64
70
  }
65
71
  self.durationMs = durationMs
66
72
  self.autoHideMs = autoHideMs
73
+ let speed = try Self.number(object["speedPxPerSecond"], field: "speedPxPerSecond")?.doubleValue
74
+ let acceleration = try Self.number(
75
+ object["accelerationPxPerSecondSquared"],
76
+ field: "accelerationPxPerSecondSquared"
77
+ )?.doubleValue
78
+ guard (speed == nil) == (acceleration == nil) else {
79
+ throw CursorOverlayError(message: "cursor overlay speed and acceleration must be provided together")
80
+ }
81
+ guard speed == nil || (speed!.isFinite && speed! >= 100 && speed! <= Self.maximumSpeedPxPerSecond),
82
+ acceleration == nil || (acceleration!.isFinite && acceleration! >= 100
83
+ && acceleration! <= Self.maximumAccelerationPxPerSecondSquared) else {
84
+ throw CursorOverlayError(message: "cursor overlay physical motion is outside the supported range")
85
+ }
86
+ self.speedPxPerSecond = speed
87
+ self.accelerationPxPerSecondSquared = acceleration
67
88
 
68
89
  if let rawPid = try Self.number(object["targetPid"], field: "targetPid") {
69
90
  let pidValue = rawPid.doubleValue
@@ -162,6 +183,9 @@ private final class CursorOverlayController: NSObject {
162
183
  private var releaseWork: DispatchWorkItem?
163
184
  private var targetCheckTimer: Timer?
164
185
  private var glideTimer: Timer?
186
+ private var glideCompletion: ((Bool) -> Void)?
187
+ private var hasPosition = false
188
+ private var currentQuartzPoint: CGPoint?
165
189
  private var targetPid: pid_t?
166
190
  private var targetWindowNumber: Int64?
167
191
  private var targetWindowFrame: CGRect?
@@ -187,6 +211,16 @@ private final class CursorOverlayController: NSObject {
187
211
  window.collectionBehavior = [.fullScreenAuxiliary, .stationary, .ignoresCycle]
188
212
  window.isReleasedWhenClosed = false
189
213
  window.sharingType = .readOnly
214
+ NSWorkspace.shared.notificationCenter.addObserver(
215
+ self,
216
+ selector: #selector(frontmostApplicationChanged(_:)),
217
+ name: NSWorkspace.didActivateApplicationNotification,
218
+ object: nil
219
+ )
220
+ }
221
+
222
+ deinit {
223
+ NSWorkspace.shared.notificationCenter.removeObserver(self)
190
224
  }
191
225
 
192
226
  /// Outcome of a placement request, so the caller can distinguish a cursor
@@ -195,35 +229,69 @@ private final class CursorOverlayController: NSObject {
195
229
  /// ends up with a frozen agent cursor and no error anywhere.
196
230
  enum Placement {
197
231
  case shown
232
+ case targetNotFrontmost
198
233
  case targetUnavailable
199
234
  }
200
235
 
201
- @discardableResult
202
236
  func show(
203
237
  at quartzPoint: CGPoint,
204
- durationMs: Int,
238
+ durationMs: Int?,
239
+ speedPxPerSecond: Double?,
240
+ accelerationPxPerSecondSquared: Double?,
205
241
  autoHideMs: Int,
206
242
  targetPid: pid_t?,
207
243
  targetWindowNumber: Int64?,
208
- targetWindowFrame: CGRect?
209
- ) -> Placement {
244
+ targetWindowFrame: CGRect?,
245
+ completion: @escaping (Placement) -> Void
246
+ ) {
210
247
  hideWork?.cancel()
211
- guard targetWindowIsCurrent(pid: targetPid, windowNumber: targetWindowNumber, expectedFrame: targetWindowFrame) else {
248
+ let placement = targetPlacement(pid: targetPid, windowNumber: targetWindowNumber, expectedFrame: targetWindowFrame)
249
+ guard placement == .shown else {
212
250
  hide()
213
- return .targetUnavailable
251
+ completion(placement)
252
+ return
214
253
  }
215
254
  self.targetPid = targetPid
216
255
  self.targetWindowNumber = targetWindowNumber
217
256
  self.targetWindowFrame = targetWindowFrame
218
257
  scheduleTargetChecks()
219
- let point = appKitPoint(fromQuartz: quartzPoint)
220
- let targetOrigin = NSPoint(
221
- x: point.x,
222
- y: point.y - Self.size.height
223
- )
224
- glide(to: targetOrigin, durationMs: durationMs)
225
- scheduleHide(after: autoHideMs)
226
- return .shown
258
+ if !hasPosition {
259
+ let mouse = NSEvent.mouseLocation
260
+ window.setFrameOrigin(NSPoint(x: mouse.x, y: mouse.y - Self.size.height))
261
+ currentQuartzPoint = self.quartzPoint(fromAppKit: mouse)
262
+ hasPosition = true
263
+ }
264
+ window.orderFrontRegardless()
265
+ let shownPlacement = targetPlacement(pid: targetPid, windowNumber: targetWindowNumber, expectedFrame: targetWindowFrame)
266
+ guard shownPlacement == .shown else {
267
+ hide()
268
+ completion(shownPlacement)
269
+ return
270
+ }
271
+ glide(
272
+ from: currentQuartzPoint ?? quartzPoint,
273
+ to: quartzPoint,
274
+ durationMs: durationMs,
275
+ speedPxPerSecond: speedPxPerSecond,
276
+ accelerationPxPerSecondSquared: accelerationPxPerSecondSquared
277
+ ) { [weak self] reached in
278
+ guard let self else {
279
+ completion(.targetUnavailable)
280
+ return
281
+ }
282
+ let finalPlacement = self.targetPlacement(
283
+ pid: targetPid,
284
+ windowNumber: targetWindowNumber,
285
+ expectedFrame: targetWindowFrame
286
+ )
287
+ guard reached, finalPlacement == .shown else {
288
+ self.hide()
289
+ completion(finalPlacement == .shown ? .targetUnavailable : finalPlacement)
290
+ return
291
+ }
292
+ self.scheduleHide(after: autoHideMs)
293
+ completion(.shown)
294
+ }
227
295
  }
228
296
 
229
297
  /// Travel to `targetOrigin`, interpolated on the main run loop.
@@ -237,39 +305,98 @@ private final class CursorOverlayController: NSObject {
237
305
  ///
238
306
  /// Stepping the origin directly is the same call the first placement
239
307
  /// already proved works, so the glide is both visible and correct.
240
- private func glide(to targetOrigin: NSPoint, durationMs: Int) {
241
- glideTimer?.invalidate()
242
- glideTimer = nil
243
- let origin = window.frame.origin
244
- guard window.isVisible, durationMs > 0, origin != targetOrigin else {
245
- window.setFrameOrigin(targetOrigin)
308
+ private func glide(
309
+ from origin: CGPoint,
310
+ to target: CGPoint,
311
+ durationMs: Int?,
312
+ speedPxPerSecond: Double?,
313
+ accelerationPxPerSecondSquared: Double?,
314
+ completion: @escaping (Bool) -> Void
315
+ ) {
316
+ finishGlide(reached: false)
317
+ guard origin != target else {
318
+ currentQuartzPoint = target
319
+ window.setFrameOrigin(panelOrigin(fromQuartz: target))
320
+ window.orderFrontRegardless()
321
+ completion(true)
322
+ return
323
+ }
324
+ let distance = hypot(target.x - origin.x, target.y - origin.y)
325
+ let duration = motionDuration(
326
+ distance: distance,
327
+ explicitDurationMs: durationMs,
328
+ speedPxPerSecond: speedPxPerSecond,
329
+ accelerationPxPerSecondSquared: accelerationPxPerSecondSquared
330
+ )
331
+ guard duration > 0 else {
332
+ currentQuartzPoint = target
333
+ window.setFrameOrigin(panelOrigin(fromQuartz: target))
246
334
  window.orderFrontRegardless()
335
+ completion(true)
247
336
  return
248
337
  }
249
- let started = Date()
250
- let duration = Double(durationMs) / 1000
251
- let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
338
+ let started = CACurrentMediaTime()
339
+ glideCompletion = completion
340
+ let timer = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] timer in
252
341
  MainActor.assumeIsolated {
253
342
  guard let self else { timer.invalidate(); return }
254
- let elapsed = Date().timeIntervalSince(started)
343
+ if let targetPid = self.targetPid,
344
+ NSWorkspace.shared.frontmostApplication?.processIdentifier != targetPid {
345
+ self.hide()
346
+ return
347
+ }
348
+ let elapsed = CACurrentMediaTime() - started
255
349
  if elapsed >= duration {
256
- timer.invalidate()
257
- self.glideTimer = nil
258
- self.window.setFrameOrigin(targetOrigin)
350
+ self.currentQuartzPoint = target
351
+ self.window.setFrameOrigin(self.panelOrigin(fromQuartz: target))
352
+ self.hasPosition = true
353
+ self.finishGlide(reached: true)
259
354
  return
260
355
  }
261
- // Ease out, matching the intent of the animation this replaces.
262
- let fraction = 1 - pow(1 - elapsed / duration, 3)
263
- self.window.setFrameOrigin(NSPoint(
264
- x: origin.x + (targetOrigin.x - origin.x) * fraction,
265
- y: origin.y + (targetOrigin.y - origin.y) * fraction
266
- ))
356
+ let linear = elapsed / duration
357
+ let fraction: Double
358
+ if let speedPxPerSecond, let accelerationPxPerSecondSquared {
359
+ fraction = cursorMotionFraction(
360
+ progress: linear,
361
+ distance: distance,
362
+ speed: speedPxPerSecond,
363
+ acceleration: accelerationPxPerSecondSquared
364
+ )
365
+ } else {
366
+ fraction = linear * linear * (3 - 2 * linear)
367
+ }
368
+ let point = cursorMotionPoint(from: origin, to: target, fraction: fraction)
369
+ self.currentQuartzPoint = point
370
+ self.window.setFrameOrigin(self.panelOrigin(fromQuartz: point))
267
371
  }
268
372
  }
269
373
  glideTimer = timer
270
374
  RunLoop.main.add(timer, forMode: .common)
271
375
  }
272
376
 
377
+ private func motionDuration(
378
+ distance: Double,
379
+ explicitDurationMs: Int?,
380
+ speedPxPerSecond: Double?,
381
+ accelerationPxPerSecondSquared: Double?
382
+ ) -> Double {
383
+ if let explicitDurationMs { return Double(explicitDurationMs) / 1000 }
384
+ guard let speedPxPerSecond, let accelerationPxPerSecondSquared else { return 0.18 }
385
+ return cursorMotionDuration(
386
+ distance: distance,
387
+ speed: speedPxPerSecond,
388
+ acceleration: accelerationPxPerSecondSquared
389
+ )
390
+ }
391
+
392
+ private func finishGlide(reached: Bool) {
393
+ glideTimer?.invalidate()
394
+ glideTimer = nil
395
+ let completion = glideCompletion
396
+ glideCompletion = nil
397
+ completion?(reached)
398
+ }
399
+
273
400
  func press(autoHideMs: Int, sustained: Bool) {
274
401
  guard window.isVisible else { return }
275
402
  releaseWork?.cancel()
@@ -297,16 +424,16 @@ private final class CursorOverlayController: NSObject {
297
424
  @discardableResult
298
425
  func validateTarget(pid: pid_t?, windowNumber: Int64?, expectedFrame: CGRect?) -> Placement {
299
426
  guard window.isVisible else { return .targetUnavailable }
300
- if !targetWindowIsCurrent(pid: pid, windowNumber: windowNumber, expectedFrame: expectedFrame) {
427
+ let placement = targetPlacement(pid: pid, windowNumber: windowNumber, expectedFrame: expectedFrame)
428
+ if placement != .shown {
301
429
  hide()
302
- return .targetUnavailable
430
+ return placement
303
431
  }
304
432
  return .shown
305
433
  }
306
434
 
307
435
  func hide() {
308
- glideTimer?.invalidate()
309
- glideTimer = nil
436
+ finishGlide(reached: false)
310
437
  hideWork?.cancel()
311
438
  hideWork = nil
312
439
  releaseWork?.cancel()
@@ -351,17 +478,24 @@ private final class CursorOverlayController: NSObject {
351
478
  targetCheckTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
352
479
  Task { @MainActor [weak self] in
353
480
  guard let self, self.window.isVisible else { return }
354
- if !self.targetWindowIsCurrent(
481
+ if self.targetPlacement(
355
482
  pid: self.targetPid,
356
483
  windowNumber: self.targetWindowNumber,
357
484
  expectedFrame: self.targetWindowFrame
358
- ) {
485
+ ) != .shown {
359
486
  self.hide()
360
487
  }
361
488
  }
362
489
  }
363
490
  }
364
491
 
492
+ @objc private func frontmostApplicationChanged(_ notification: Notification) {
493
+ guard window.isVisible, let targetPid else { return }
494
+ if NSWorkspace.shared.frontmostApplication?.processIdentifier != targetPid {
495
+ hide()
496
+ }
497
+ }
498
+
365
499
  private func appKitPoint(fromQuartz point: CGPoint) -> NSPoint {
366
500
  for screen in NSScreen.screens {
367
501
  guard let screenNumber = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value else {
@@ -387,15 +521,47 @@ private final class CursorOverlayController: NSObject {
387
521
  )
388
522
  }
389
523
 
390
- private func targetWindowIsCurrent(pid: pid_t?, windowNumber: Int64?, expectedFrame: CGRect?) -> Bool {
391
- guard let pid, let windowNumber, let expectedFrame else { return false }
524
+ private func quartzPoint(fromAppKit point: NSPoint) -> CGPoint {
525
+ for screen in NSScreen.screens {
526
+ guard screen.frame.contains(point),
527
+ let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else {
528
+ continue
529
+ }
530
+ let quartzFrame = CGDisplayBounds(CGDirectDisplayID(screenNumber.uint32Value))
531
+ return CGPoint(
532
+ x: quartzFrame.origin.x + (point.x - screen.frame.origin.x),
533
+ y: quartzFrame.origin.y + (screen.frame.maxY - point.y)
534
+ )
535
+ }
536
+ let mainDisplay = CGMainDisplayID()
537
+ let quartzFrame = CGDisplayBounds(mainDisplay)
538
+ let appKitFrame = NSScreen.main?.frame ?? NSRect(
539
+ x: quartzFrame.origin.x,
540
+ y: quartzFrame.origin.y,
541
+ width: quartzFrame.width,
542
+ height: quartzFrame.height
543
+ )
544
+ return CGPoint(
545
+ x: quartzFrame.origin.x + (point.x - appKitFrame.origin.x),
546
+ y: quartzFrame.origin.y + (appKitFrame.maxY - point.y)
547
+ )
548
+ }
549
+
550
+ private func panelOrigin(fromQuartz point: CGPoint) -> NSPoint {
551
+ let appKit = appKitPoint(fromQuartz: point)
552
+ return NSPoint(x: appKit.x, y: appKit.y - Self.size.height)
553
+ }
554
+
555
+ private func targetPlacement(pid: pid_t?, windowNumber: Int64?, expectedFrame: CGRect?) -> Placement {
556
+ guard let pid, let windowNumber, let expectedFrame else { return .targetUnavailable }
557
+ guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return .targetNotFrontmost }
392
558
  guard let rawWindows = CGWindowListCopyWindowInfo(
393
559
  [.optionOnScreenOnly, .excludeDesktopElements],
394
560
  kCGNullWindowID
395
561
  ) as? [[String: Any]] else {
396
- return false
562
+ return .targetUnavailable
397
563
  }
398
- return rawWindows.contains { window in
564
+ let matches = rawWindows.contains { window in
399
565
  guard (window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == pid,
400
566
  (window[kCGWindowNumber as String] as? NSNumber)?.int64Value == windowNumber,
401
567
  let bounds = window[kCGWindowBounds as String] as? [String: Any],
@@ -406,6 +572,7 @@ private final class CursorOverlayController: NSObject {
406
572
  && abs(currentFrame.width - expectedFrame.width) <= tolerance
407
573
  && abs(currentFrame.height - expectedFrame.height) <= tolerance
408
574
  }
575
+ return matches ? .shown : .targetUnavailable
409
576
  }
410
577
  }
411
578
 
@@ -415,17 +582,17 @@ enum CursorOverlayRuntime {
415
582
  let app = NSApplication.shared
416
583
  app.setActivationPolicy(.prohibited)
417
584
  let controller = CursorOverlayController()
585
+ let dispatcher = CursorCommandDispatcher(controller: controller)
418
586
  let input = FileHandle.standardInput
419
587
  let parser = CursorCommandParser { object in
420
588
  DispatchQueue.main.async {
421
- handleCursorCommand(object, controller: controller)
589
+ dispatcher.enqueue(object)
422
590
  }
423
591
  } onEnd: {
424
- DispatchQueue.main.async { controller.stop() }
592
+ DispatchQueue.main.async { dispatcher.endInput() }
425
593
  } onInvalid: { message in
426
594
  DispatchQueue.main.async {
427
- emitCursorResponse(["ok": false, "error": message])
428
- controller.stop()
595
+ dispatcher.invalidate(message)
429
596
  }
430
597
  }
431
598
  input.readabilityHandler = { handle in parser.consume(handle.availableData) }
@@ -435,6 +602,68 @@ enum CursorOverlayRuntime {
435
602
  }
436
603
  }
437
604
 
605
+ @MainActor
606
+ private final class CursorCommandDispatcher {
607
+ private let controller: CursorOverlayController
608
+ private var commands: [[String: Any]] = []
609
+ private var processing = false
610
+ private var inputEnded = false
611
+ private var terminated = false
612
+
613
+ init(controller: CursorOverlayController) {
614
+ self.controller = controller
615
+ }
616
+
617
+ func enqueue(_ object: [String: Any]) {
618
+ guard !inputEnded, !terminated else { return }
619
+ commands.append(object)
620
+ if object["op"] as? String == "stop", processing {
621
+ controller.hide()
622
+ }
623
+ drain()
624
+ }
625
+
626
+ func endInput() {
627
+ guard !inputEnded, !terminated else { return }
628
+ inputEnded = true
629
+ if commands.contains(where: { $0["op"] as? String == "stop" }) {
630
+ if processing { controller.hide() }
631
+ drain()
632
+ } else {
633
+ terminated = true
634
+ commands.removeAll()
635
+ controller.stop()
636
+ }
637
+ }
638
+
639
+ func invalidate(_ message: String) {
640
+ guard !terminated else { return }
641
+ inputEnded = true
642
+ terminated = true
643
+ commands.removeAll()
644
+ controller.hide()
645
+ emitCursorResponse(["ok": false, "error": message])
646
+ controller.stop()
647
+ }
648
+
649
+ private func drain() {
650
+ guard !terminated, !processing, !commands.isEmpty else { return }
651
+ processing = true
652
+ let command = commands.removeFirst()
653
+ let stopsRuntime = command["op"] as? String == "stop"
654
+ handleCursorCommand(command, controller: controller) { [weak self] in
655
+ guard let self else { return }
656
+ self.processing = false
657
+ if stopsRuntime {
658
+ self.terminated = true
659
+ self.commands.removeAll()
660
+ return
661
+ }
662
+ DispatchQueue.main.async { [weak self] in self?.drain() }
663
+ }
664
+ }
665
+ }
666
+
438
667
  private final class CursorCommandParser: @unchecked Sendable {
439
668
  private static let maximumLineBytes = 16 * 1024
440
669
  private let queue = DispatchQueue(label: "dsh-computer-use.cursor-protocol")
@@ -490,7 +719,11 @@ private final class CursorCommandParser: @unchecked Sendable {
490
719
  }
491
720
 
492
721
  @MainActor
493
- private func handleCursorCommand(_ object: [String: Any], controller: CursorOverlayController) {
722
+ private func handleCursorCommand(
723
+ _ object: [String: Any],
724
+ controller: CursorOverlayController,
725
+ completion: @escaping () -> Void
726
+ ) {
494
727
  do {
495
728
  let command = try CursorOverlayCommand(object)
496
729
  // Whether the panel is actually on screen after this command. A hidden
@@ -498,39 +731,58 @@ private func handleCursorCommand(_ object: [String: Any], controller: CursorOver
498
731
  // error -- but the caller must be able to tell, or the user silently
499
732
  // loses sight of where the agent is acting.
500
733
  var visible = true
734
+ var reasonCode: String?
501
735
  switch command.operation {
502
736
  case "show", "move":
503
737
  guard let point = command.point else {
504
738
  throw CursorOverlayError(message: "cursor overlay command needs x and y")
505
739
  }
506
- visible = controller.show(
740
+ controller.show(
507
741
  at: point,
508
742
  durationMs: command.durationMs,
743
+ speedPxPerSecond: command.speedPxPerSecond,
744
+ accelerationPxPerSecondSquared: command.accelerationPxPerSecondSquared,
509
745
  autoHideMs: command.autoHideMs,
510
746
  targetPid: command.targetPid,
511
747
  targetWindowNumber: command.targetWindowNumber,
512
748
  targetWindowFrame: command.targetWindowFrame
513
- ) == .shown
749
+ ) { placement in
750
+ let shown = placement == .shown
751
+ var response: [String: Any] = ["ok": true, "op": command.operation, "visible": shown]
752
+ if !shown {
753
+ response["reasonCode"] = cursorReasonCode(placement)
754
+ response["reason"] = cursorReason(placement)
755
+ }
756
+ emitCursorResponse(response)
757
+ completion()
758
+ }
759
+ return
514
760
  case "press":
515
- visible = controller.validateTarget(
761
+ let placement = controller.validateTarget(
516
762
  pid: command.targetPid,
517
763
  windowNumber: command.targetWindowNumber,
518
764
  expectedFrame: command.targetWindowFrame
519
- ) == .shown
765
+ )
766
+ visible = placement == .shown
767
+ reasonCode = cursorReasonCode(placement)
520
768
  controller.press(autoHideMs: command.autoHideMs, sustained: command.sustainedPress)
521
769
  case "release":
522
- visible = controller.validateTarget(
770
+ let placement = controller.validateTarget(
523
771
  pid: command.targetPid,
524
772
  windowNumber: command.targetWindowNumber,
525
773
  expectedFrame: command.targetWindowFrame
526
- ) == .shown
774
+ )
775
+ visible = placement == .shown
776
+ reasonCode = cursorReasonCode(placement)
527
777
  controller.release(autoHideMs: command.autoHideMs)
528
778
  case "validate":
529
- visible = controller.validateTarget(
779
+ let placement = controller.validateTarget(
530
780
  pid: command.targetPid,
531
781
  windowNumber: command.targetWindowNumber,
532
782
  expectedFrame: command.targetWindowFrame
533
- ) == .shown
783
+ )
784
+ visible = placement == .shown
785
+ reasonCode = cursorReasonCode(placement)
534
786
  case "hide":
535
787
  controller.hide()
536
788
  visible = false
@@ -546,13 +798,35 @@ private func handleCursorCommand(_ object: [String: Any], controller: CursorOver
546
798
  if !visible && (command.operation == "show" || command.operation == "move"
547
799
  || command.operation == "press" || command.operation == "release"
548
800
  || command.operation == "validate") {
549
- response["reason"] = "the bound target window is no longer at its observed frame; the agent cursor is hidden"
801
+ response["reasonCode"] = reasonCode
802
+ response["reason"] = reasonCode == "target-not-frontmost"
803
+ ? "the bound target application is not frontmost; the agent cursor is hidden"
804
+ : "the bound target window no longer matches; the agent cursor is hidden"
550
805
  }
551
806
  emitCursorResponse(response)
807
+ completion()
552
808
  } catch let error as CursorOverlayError {
553
809
  emitCursorResponse(["ok": false, "error": error.message])
810
+ completion()
554
811
  } catch {
555
812
  emitCursorResponse(["ok": false, "error": String(describing: error)])
813
+ completion()
814
+ }
815
+ }
816
+
817
+ private func cursorReasonCode(_ placement: CursorOverlayController.Placement) -> String? {
818
+ switch placement {
819
+ case .shown: return nil
820
+ case .targetNotFrontmost: return "target-not-frontmost"
821
+ case .targetUnavailable: return "target-invalid"
822
+ }
823
+ }
824
+
825
+ private func cursorReason(_ placement: CursorOverlayController.Placement) -> String? {
826
+ switch placement {
827
+ case .shown: return nil
828
+ case .targetNotFrontmost: return "the bound target application is not frontmost; the agent cursor is hidden"
829
+ case .targetUnavailable: return "the bound target window no longer matches; the agent cursor is hidden"
556
830
  }
557
831
  }
558
832
 
@@ -246,7 +246,9 @@ func targetedScroll(
246
246
  func targetedDrag(
247
247
  from: CGPoint,
248
248
  to: CGPoint,
249
- target: TargetedPointerTarget
249
+ target: TargetedPointerTarget,
250
+ speedPxPerSecond: Double,
251
+ accelerationPxPerSecondSquared: Double
250
252
  ) throws {
251
253
  let source = try targetedPointerSource()
252
254
  let group = clickGroup()
@@ -261,7 +263,7 @@ func targetedDrag(
261
263
  gesturePhase: 2,
262
264
  clickGroup: group
263
265
  )
264
- usleep(12_000)
266
+ usleep(2_000)
265
267
 
266
268
  let down = try pointerEvent(source: source, type: .leftMouseDown, button: .left)
267
269
  down.setDoubleValueField(.mouseEventPressure, value: 1)
@@ -275,14 +277,25 @@ func targetedDrag(
275
277
  gesturePhase: 3,
276
278
  clickGroup: group
277
279
  )
278
- usleep(16_000)
280
+ usleep(2_000)
279
281
 
280
- for step in 1...12 {
281
- let fraction = CGFloat(step) / 12
282
- let point = CGPoint(
283
- x: from.x + (to.x - from.x) * fraction,
284
- y: from.y + (to.y - from.y) * fraction
282
+ let distance = hypot(to.x - from.x, to.y - from.y)
283
+ let duration = distance == 0 ? 0 : cursorMotionDuration(
284
+ distance: distance,
285
+ speed: speedPxPerSecond,
286
+ acceleration: accelerationPxPerSecondSquared
287
+ )
288
+ let started = ProcessInfo.processInfo.systemUptime
289
+ while true {
290
+ let elapsed = ProcessInfo.processInfo.systemUptime - started
291
+ let progress = duration == 0 ? 1 : min(1, elapsed / duration)
292
+ let fraction = cursorMotionFraction(
293
+ progress: progress,
294
+ distance: distance,
295
+ speed: speedPxPerSecond,
296
+ acceleration: accelerationPxPerSecondSquared
285
297
  )
298
+ let point = cursorMotionPoint(from: from, to: to, fraction: fraction)
286
299
  let dragged = try pointerEvent(source: source, type: .leftMouseDragged, button: .left)
287
300
  dragged.setDoubleValueField(.mouseEventPressure, value: 1)
288
301
  try postPointerEvent(
@@ -295,9 +308,9 @@ func targetedDrag(
295
308
  gesturePhase: 3,
296
309
  clickGroup: group
297
310
  )
298
- usleep(12_000)
311
+ if progress >= 1 { break }
312
+ usleep(8_333)
299
313
  }
300
- usleep(50_000)
301
314
 
302
315
  let up = try pointerEvent(source: source, type: .leftMouseUp, button: .left)
303
316
  up.setDoubleValueField(.mouseEventPressure, value: 0)