@forsion/tangu-computer-use 0.5.8

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 (120) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/LICENSE +26 -0
  3. package/LICENSE.upstream +21 -0
  4. package/README.md +169 -0
  5. package/UPSTREAM.md +277 -0
  6. package/check.mjs +369 -0
  7. package/icon.png +0 -0
  8. package/install.sh +112 -0
  9. package/main.js +541 -0
  10. package/manifest.json +47 -0
  11. package/native/linux/bridge-rs/Cargo.lock +1204 -0
  12. package/native/linux/bridge-rs/Cargo.toml +18 -0
  13. package/native/linux/bridge-rs/src/atspi.rs +640 -0
  14. package/native/linux/bridge-rs/src/error.rs +65 -0
  15. package/native/linux/bridge-rs/src/lib.rs +11 -0
  16. package/native/linux/bridge-rs/src/main.rs +1074 -0
  17. package/native/linux/bridge-rs/src/protocol.rs +49 -0
  18. package/native/linux/bridge-rs/src/state.rs +293 -0
  19. package/native/linux/bridge-rs/src/wayland.rs +89 -0
  20. package/native/linux/bridge-rs/src/x11.rs +909 -0
  21. package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
  22. package/native/macos/agent_cursor.swift +184 -0
  23. package/native/macos/agent_cursor_motion.swift +252 -0
  24. package/native/macos/agent_cursor_tests.swift +128 -0
  25. package/native/macos/agent_highlight.swift +229 -0
  26. package/native/macos/agent_highlight_tests.swift +99 -0
  27. package/native/macos/bridge.swift +3852 -0
  28. package/native/macos/foreground_activity.swift +57 -0
  29. package/native/macos/foreground_activity_tests.swift +31 -0
  30. package/native/macos/live_stream.swift +289 -0
  31. package/native/windows/bridge-rs/Cargo.lock +396 -0
  32. package/native/windows/bridge-rs/Cargo.toml +30 -0
  33. package/native/windows/bridge-rs/src/capture.rs +518 -0
  34. package/native/windows/bridge-rs/src/error.rs +81 -0
  35. package/native/windows/bridge-rs/src/input.rs +501 -0
  36. package/native/windows/bridge-rs/src/lib.rs +16 -0
  37. package/native/windows/bridge-rs/src/main.rs +1683 -0
  38. package/native/windows/bridge-rs/src/protocol.rs +53 -0
  39. package/native/windows/bridge-rs/src/refs.rs +237 -0
  40. package/native/windows/bridge-rs/src/state.rs +55 -0
  41. package/native/windows/bridge-rs/src/uia.rs +1252 -0
  42. package/native/windows/bridge-rs/src/window.rs +718 -0
  43. package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
  44. package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
  45. package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
  46. package/package.json +74 -0
  47. package/prebuilt/linux/arm64/linux-bridge +0 -0
  48. package/prebuilt/linux/x64/linux-bridge +0 -0
  49. package/prebuilt/macos/arm64/bridge +0 -0
  50. package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
  51. package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
  52. package/prebuilt/macos/x64/bridge +0 -0
  53. package/prebuilt/macos/x64/tangu-computer-use.app.json +10 -0
  54. package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
  55. package/prebuilt/windows/windows-bridge.exe +0 -0
  56. package/scripts/blind-click.check.mjs +128 -0
  57. package/scripts/build-native.mjs +302 -0
  58. package/scripts/build.mjs +84 -0
  59. package/scripts/calc-fixture.mjs +78 -0
  60. package/scripts/cli-exit.check.mjs +49 -0
  61. package/scripts/helper-path.check.mjs +82 -0
  62. package/scripts/helper-refresh.check.mjs +62 -0
  63. package/scripts/helper-signal.check.mjs +59 -0
  64. package/scripts/highlight.check.mjs +32 -0
  65. package/scripts/keychain-free-install.check.mjs +140 -0
  66. package/scripts/live-view.check.mjs +124 -0
  67. package/scripts/macos-bundle.d.mts +1 -0
  68. package/scripts/macos-bundle.mjs +119 -0
  69. package/scripts/make-signing-cert.sh +57 -0
  70. package/scripts/mini-foreground.check.mjs +11 -0
  71. package/scripts/no-foreground.check.mjs +81 -0
  72. package/scripts/overlay-visible.check.mjs +221 -0
  73. package/scripts/package-macos-app.mjs +39 -0
  74. package/scripts/permissions.check.mjs +199 -0
  75. package/scripts/platform-contract.check.mjs +30 -0
  76. package/scripts/setup-helper.mjs +154 -0
  77. package/scripts/tangu-computer-use.entitlements +10 -0
  78. package/scripts/verify-macos-bundles.mjs +25 -0
  79. package/scripts/verify-package.mjs +74 -0
  80. package/skills/computer-use/SKILL.md +110 -0
  81. package/src/foregroundNote.ts +43 -0
  82. package/src/helperState.ts +31 -0
  83. package/src/index.ts +63 -0
  84. package/src/onboarding.ts +198 -0
  85. package/src/pi-compat.ts +49 -0
  86. package/src/settings.ts +11 -0
  87. package/src/setup.ts +97 -0
  88. package/src/tools.ts +289 -0
  89. package/src/vendor/actions.ts +130 -0
  90. package/src/vendor/bridge.ts +2405 -0
  91. package/src/vendor/cdp.ts +658 -0
  92. package/src/vendor/config.ts +113 -0
  93. package/src/vendor/contract.ts +104 -0
  94. package/src/vendor/note.ts +195 -0
  95. package/src/vendor/outline.ts +651 -0
  96. package/src/vendor/output.ts +134 -0
  97. package/src/vendor/permissions.ts +111 -0
  98. package/src/vendor/platform/architecture.ts +23 -0
  99. package/src/vendor/platform/coerce.ts +16 -0
  100. package/src/vendor/platform/index.ts +59 -0
  101. package/src/vendor/platform/linux/backend.ts +186 -0
  102. package/src/vendor/platform/linux/helper.ts +238 -0
  103. package/src/vendor/platform/macos/backend.ts +131 -0
  104. package/src/vendor/platform/macos/browser.ts +110 -0
  105. package/src/vendor/platform/macos/helper-path.d.mts +9 -0
  106. package/src/vendor/platform/macos/helper-path.mjs +34 -0
  107. package/src/vendor/platform/macos/helper.ts +291 -0
  108. package/src/vendor/platform/macos/permissions.ts +146 -0
  109. package/src/vendor/platform/types.ts +222 -0
  110. package/src/vendor/platform/windows/backend.ts +142 -0
  111. package/src/vendor/platform/windows/helper.ts +140 -0
  112. package/src/vendor/root-selection.ts +25 -0
  113. package/src/vendor/runtime.ts +129 -0
  114. package/src/vendor/state.ts +146 -0
  115. package/src/vendor/view.ts +147 -0
  116. package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
  117. package/tangu-plugins/computer-use/dist/index.js +5672 -0
  118. package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
  119. package/tsconfig.json +22 -0
  120. package/types/tangu-agent.d.ts +142 -0
@@ -0,0 +1,73 @@
1
+ use linux_bridge::protocol::PROTOCOL_VERSION;
2
+ use linux_bridge::{ErrorCode, ProtocolError, Request, Response};
3
+ use serde_json::json;
4
+ use std::io::Write;
5
+ use std::process::{Command, Stdio};
6
+
7
+ #[test]
8
+ fn protocol_round_trip_matches_other_helpers() {
9
+ let request: Request = serde_json::from_value(json!({
10
+ "protocolVersion": PROTOCOL_VERSION,
11
+ "id": "linux-1",
12
+ "cmd": "diagnostics",
13
+ "args": {}
14
+ }))
15
+ .unwrap();
16
+ let response = Response::ok(&request.id, json!({"backend":"at-spi2"}));
17
+ let value = serde_json::to_value(response).unwrap();
18
+ assert_eq!(value["protocolVersion"], 4);
19
+ assert_eq!(value["id"], "linux-1");
20
+ assert_eq!(value["result"]["backend"], "at-spi2");
21
+ }
22
+
23
+ #[test]
24
+ fn errors_have_stable_machine_codes() {
25
+ let response = Response::err(
26
+ "linux-2",
27
+ ProtocolError::new("no screenshot", ErrorCode::CaptureFailed),
28
+ );
29
+ let value = serde_json::to_value(response).unwrap();
30
+ assert_eq!(value["ok"], false);
31
+ assert_eq!(value["error"]["code"], "capture_failed");
32
+ }
33
+
34
+ #[test]
35
+ fn diagnostics_response_is_flushed_after_stdin_eof() {
36
+ let mut child = Command::new(env!("CARGO_BIN_EXE_linux-bridge"))
37
+ .stdin(Stdio::piped())
38
+ .stdout(Stdio::piped())
39
+ .stderr(Stdio::piped())
40
+ .spawn()
41
+ .expect("spawn linux helper");
42
+ let request = json!({
43
+ "protocolVersion": PROTOCOL_VERSION,
44
+ "id": "eof-1",
45
+ "cmd": "diagnostics",
46
+ "args": {}
47
+ });
48
+ writeln!(
49
+ child.stdin.as_mut().expect("piped stdin"),
50
+ "{}",
51
+ serde_json::to_string(&request).unwrap()
52
+ )
53
+ .expect("write diagnostics request");
54
+ drop(child.stdin.take());
55
+
56
+ let output = child.wait_with_output().expect("helper exits after EOF");
57
+ let stderr = String::from_utf8_lossy(&output.stderr);
58
+ assert!(output.status.success(), "helper failed: {stderr}");
59
+ assert!(
60
+ !stderr.contains("panicked"),
61
+ "helper panicked during shutdown: {stderr}"
62
+ );
63
+ let lines = String::from_utf8(output.stdout)
64
+ .expect("UTF-8 stdout")
65
+ .lines()
66
+ .map(str::to_owned)
67
+ .collect::<Vec<_>>();
68
+ assert_eq!(lines.len(), 1, "expected one response: {lines:?}");
69
+ let response: serde_json::Value = serde_json::from_str(&lines[0]).expect("valid JSON response");
70
+ assert_eq!(response["id"], "eof-1");
71
+ assert_eq!(response["ok"], true);
72
+ assert_eq!(response["result"]["protocolVersion"], PROTOCOL_VERSION);
73
+ }
@@ -0,0 +1,184 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// Visual-only cursor; native action delivery remains authoritative.
5
+ @MainActor
6
+ final class AgentCursor {
7
+ typealias IdleHideScheduler = @MainActor (@escaping @MainActor () -> Void) -> Task<Void, Never>
8
+
9
+ static let shared = AgentCursor(scheduleIdleHide: scheduleDefaultIdleHide)
10
+
11
+ private var overlay: AgentCursorOverlayWindow?
12
+ private var idleHideTask: Task<Void, Never>?
13
+ private var idleGeneration: UInt = 0
14
+ private let scheduleIdleHide: IdleHideScheduler
15
+
16
+ init(scheduleIdleHide: @escaping IdleHideScheduler) {
17
+ self.scheduleIdleHide = scheduleIdleHide
18
+ }
19
+
20
+ /// `above` 这个标签是上游的,但**跨进程的相对排序已经做不到了**(见下面被删掉的那行),
21
+ /// 现在完全靠 window level 置顶。参数名留成 `_` 以免有人以为传进来的窗口号还起作用。
22
+ func animate(to point: CGPoint, above _: UInt32) {
23
+ idleHideTask?.cancel()
24
+ idleHideTask = nil
25
+ idleGeneration &+= 1
26
+
27
+ let window = ensureWindow()
28
+ // Tangu 补丁:覆盖层必须横跨**所有**屏幕,并且每次都重新对齐 —— 显示器可能中途插拔/改摆位。
29
+ // 上游只开主屏那么大(见 ensureWindow),于是副屏上的点击把光标画到了覆盖层之外 = 用户看不见,
30
+ // 报上来就是"辅助鼠标还是没有出现"。渲染器吃的是画布局部坐标,所以点也要跟着换算。
31
+ let bounds = Self.overlayBounds()
32
+ if window.frame != bounds.union { window.setFrame(bounds.union, display: false) }
33
+ if !window.isVisible { window.orderFrontRegardless() }
34
+ // ⚠️上游这里是 window.order(.above, relativeTo: Int(windowId)) —— 对**别的进程**的 window number
35
+ // 是空操作(实测:窗口既不上移也不消失),它只在同一 App 自己的窗口之间有意义。
36
+ // 跨进程置顶只能靠 window level,见 AgentCursorOverlayWindow.init。
37
+
38
+ let local = OverlayGeometry.canvasPoint(point, union: bounds.union, primaryHeight: bounds.primaryHeight)
39
+ let renderer = AgentCursorRenderer.shared
40
+ if renderer.position.x < -100 {
41
+ let frame = CGRect(origin: .zero, size: bounds.union.size)
42
+ renderer.setInitialPosition(CGPoint(
43
+ x: min(max(local.x - 140, frame.minX + 2), frame.maxX - 2),
44
+ y: min(max(local.y - 140, frame.minY + 2), frame.maxY - 2)
45
+ ))
46
+ }
47
+ renderer.moveTo(point: local)
48
+
49
+ let generation = idleGeneration
50
+ idleHideTask = scheduleIdleHide { [weak self, weak window] in
51
+ guard let self, let window else { return }
52
+ guard generation == idleGeneration, self.overlay === window else { return }
53
+
54
+ renderer.cancelAnimation()
55
+ window.orderOut(nil)
56
+ window.contentView = nil
57
+ window.close()
58
+ overlay = nil
59
+ idleHideTask = nil
60
+ }
61
+ }
62
+
63
+ private static func scheduleDefaultIdleHide(_ hide: @escaping @MainActor () -> Void) -> Task<Void, Never> {
64
+ Task { @MainActor in
65
+ try? await Task.sleep(for: .seconds(8))
66
+ guard !Task.isCancelled else { return }
67
+ hide()
68
+ }
69
+ }
70
+
71
+ /// 所有屏幕的并集 + 主屏高度(全局 CG 坐标换算要用)。没有屏幕时退化成零矩形。
72
+ static func overlayBounds() -> (union: CGRect, primaryHeight: CGFloat) {
73
+ let frames = NSScreen.screens.map(\.frame)
74
+ let union = OverlayGeometry.union(of: frames)
75
+ // ⚠️主屏 = frame 原点在 (0,0) 的那块(全局 CG 坐标的原点就定在它的左上角),**不是** NSScreen.main
76
+ // (那是"当前有 key window 的那块",会跟着焦点跑)、也不保证是 screens.first。
77
+ let primary = NSScreen.screens.first { $0.frame.origin == .zero } ?? NSScreen.screens.first
78
+ return (union.isNull ? .zero : union, primary?.frame.height ?? union.height)
79
+ }
80
+
81
+ private func ensureWindow() -> AgentCursorOverlayWindow {
82
+ if let overlay { return overlay }
83
+ let window = AgentCursorOverlayWindow(
84
+ contentRect: Self.overlayBounds().union,
85
+ styleMask: .borderless,
86
+ backing: .buffered,
87
+ defer: false
88
+ )
89
+ window.contentView = NSHostingView(rootView: AgentCursorView())
90
+ overlay = window
91
+ return window
92
+ }
93
+ }
94
+
95
+ /// Click-through overlay spanning every display; can never take focus.
96
+ private final class AgentCursorOverlayWindow: NSWindow {
97
+ override var canBecomeKey: Bool { false }
98
+ override var canBecomeMain: Bool { false }
99
+
100
+ override init(contentRect: NSRect, styleMask: NSWindow.StyleMask, backing: NSWindow.BackingStoreType, defer flag: Bool) {
101
+ super.init(contentRect: contentRect, styleMask: styleMask, backing: backing, defer: flag)
102
+ isOpaque = false
103
+ backgroundColor = .clear
104
+ hasShadow = false
105
+ ignoresMouseEvents = true
106
+ // Tangu 补丁:跨进程置顶只能靠 level(order(.above, relativeTo: 外部窗口号) 是空操作)。
107
+ // 取「能盖住 agent 会碰到的一切」的**最低**档:比光效(.floating=3)高,比目标 App 自己弹的
108
+ // 菜单(.popUpMenu=101)高一档 —— 指针指到哪儿是最关键的信息,菜单项被点时尤其不能被挡。
109
+ // ⚠️别调到 .screenSaver(1000):那会连屏保、系统警告一起盖住,而光标最长要停留 8 秒。
110
+ level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue + 1)
111
+ collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
112
+ isReleasedWhenClosed = false
113
+ hidesOnDeactivate = false
114
+ }
115
+ }
116
+
117
+ @MainActor
118
+ private struct AgentCursorView: View {
119
+ @Bindable private var renderer = AgentCursorRenderer.shared
120
+
121
+ var body: some View {
122
+ TimelineView(.animation(minimumInterval: 1.0 / 120.0, paused: !renderer.isAnimating)) { context in
123
+ Canvas { graphics, _ in
124
+ renderer.tick(now: context.date.timeIntervalSinceReferenceDate)
125
+ drawCursor(in: graphics)
126
+ }
127
+ .ignoresSafeArea()
128
+ .allowsHitTesting(false)
129
+ }
130
+ }
131
+
132
+ private func drawCursor(in graphics: GraphicsContext) {
133
+ let point = renderer.position
134
+ guard point.x > -100 else { return }
135
+
136
+ let bloom = Color(nsColor: NSColor(red: 1, green: 0x78 / 255, blue: 0x18 / 255, alpha: 1))
137
+ let radius: CGFloat = 22
138
+ graphics.fill(
139
+ Path(ellipseIn: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2)),
140
+ with: .radialGradient(
141
+ Gradient(colors: [bloom.opacity(0.55), bloom.opacity(0.15), bloom.opacity(0)]),
142
+ center: point,
143
+ startRadius: 0,
144
+ endRadius: radius
145
+ )
146
+ )
147
+
148
+ let points = [
149
+ CGPoint(x: 14, y: 0),
150
+ CGPoint(x: -8, y: -9),
151
+ CGPoint(x: -3, y: 0),
152
+ CGPoint(x: -8, y: 9),
153
+ ]
154
+ var shape = Path()
155
+ for index in points.indices {
156
+ let previous = points[(index + points.count - 1) % points.count]
157
+ let current = points[index]
158
+ let next = points[(index + 1) % points.count]
159
+ let entry = CGPoint(x: current.x + (previous.x - current.x) * 0.16, y: current.y + (previous.y - current.y) * 0.16)
160
+ let exit = CGPoint(x: current.x + (next.x - current.x) * 0.16, y: current.y + (next.y - current.y) * 0.16)
161
+ if index == points.startIndex { shape.move(to: entry) } else { shape.addLine(to: entry) }
162
+ shape.addQuadCurve(to: exit, control: current)
163
+ }
164
+ shape.closeSubpath()
165
+
166
+ let transformed = shape.applying(
167
+ CGAffineTransform(translationX: point.x, y: point.y)
168
+ .rotated(by: CGFloat(renderer.heading + .pi))
169
+ )
170
+ graphics.fill(
171
+ transformed,
172
+ with: .linearGradient(
173
+ Gradient(colors: [
174
+ Color(red: 1, green: 0xD0 / 255, blue: 0x76 / 255),
175
+ Color(red: 1, green: 0x78 / 255, blue: 0x18 / 255),
176
+ Color(red: 0xE8 / 255, green: 0x4A / 255, blue: 0x0C / 255),
177
+ ]),
178
+ startPoint: CGPoint(x: point.x + 14, y: point.y - 9),
179
+ endPoint: CGPoint(x: point.x - 8, y: point.y + 9)
180
+ )
181
+ )
182
+ graphics.stroke(transformed, with: .color(.white), lineWidth: 2)
183
+ }
184
+ }
@@ -0,0 +1,252 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+ import Observation
4
+
5
+ /// Main-actor Dubins-path animation state consumed by the SwiftUI overlay.
6
+ @Observable
7
+ @MainActor
8
+ final class AgentCursorRenderer {
9
+ static let shared = AgentCursorRenderer()
10
+
11
+ private(set) var position = CGPoint(x: -200, y: -200)
12
+ private(set) var heading: Double = .pi / 4
13
+ private(set) var isAnimating = false
14
+
15
+ private var path: PlannedPath?
16
+ private var spring: Spring?
17
+ private var distanceSoFar: Double = 0
18
+ private var lastFrameTime: CFTimeInterval?
19
+ private var springTarget: (point: CGPoint, heading: Double)?
20
+
21
+ func moveTo(point clickPoint: CGPoint) {
22
+ let endAngle = Double.pi / 4
23
+ let targetPoint = CGPoint(
24
+ x: clickPoint.x + CGFloat(cos(endAngle) * 16),
25
+ y: clickPoint.y + CGFloat(sin(endAngle) * 16)
26
+ )
27
+ path = planPath(
28
+ x0: Double(position.x), y0: Double(position.y), th0: heading + .pi,
29
+ x1: Double(targetPoint.x), y1: Double(targetPoint.y), th1: endAngle + .pi,
30
+ R: 80, endVisualHeading: endAngle, targetPoint: targetPoint)
31
+ spring = nil
32
+ springTarget = nil
33
+ distanceSoFar = 0
34
+ lastFrameTime = nil
35
+ isAnimating = true
36
+ }
37
+
38
+ func setInitialPosition(_ point: CGPoint) {
39
+ position = point
40
+ cancelAnimation()
41
+ }
42
+
43
+ func cancelAnimation() {
44
+ path = nil
45
+ spring = nil
46
+ springTarget = nil
47
+ distanceSoFar = 0
48
+ lastFrameTime = nil
49
+ isAnimating = false
50
+ }
51
+
52
+ func tick(now: CFTimeInterval) {
53
+ guard isAnimating else { return }
54
+ let previous = lastFrameTime ?? now
55
+ let dt = min(0.05, now - previous)
56
+ lastFrameTime = now
57
+
58
+ if let path {
59
+ let progress = min(1.0, distanceSoFar / max(path.length, 1))
60
+ let profile = 16 * progress * progress * (1 - progress) * (1 - progress)
61
+ let minimumSpeed = progress < 0.5 ? 300.0 : 200.0
62
+ let speed = minimumSpeed + (900 - minimumSpeed) * profile
63
+ distanceSoFar += speed * dt
64
+
65
+ if distanceSoFar >= path.length {
66
+ let endState = path.sample(at: path.length)
67
+ spring = Spring(
68
+ ox: 0,
69
+ oy: 0,
70
+ vx: cos(endState.heading) * speed * 0.8,
71
+ vy: sin(endState.heading) * speed * 0.8
72
+ )
73
+ springTarget = (path.targetPoint, path.endVisualHeading)
74
+ position = path.targetPoint
75
+ heading = path.endVisualHeading
76
+ self.path = nil
77
+ distanceSoFar = 0
78
+ } else {
79
+ let state = path.sample(at: distanceSoFar)
80
+ position = CGPoint(x: state.x, y: state.y)
81
+ heading = rotateToward(current: heading, desired: state.heading + .pi, maxStep: 14 * dt)
82
+ }
83
+ } else if var spring, let target = springTarget {
84
+ let substep = dt / 4
85
+ for _ in 0..<4 {
86
+ spring.vx += (-400 * spring.ox - 17 * spring.vx) * substep
87
+ spring.vy += (-400 * spring.oy - 17 * spring.vy) * substep
88
+ spring.ox += spring.vx * substep
89
+ spring.oy += spring.vy * substep
90
+ }
91
+ position = CGPoint(x: target.point.x + CGFloat(spring.ox), y: target.point.y + CGFloat(spring.oy))
92
+ heading = target.heading
93
+ if hypot(spring.ox, spring.oy) < 0.3 && hypot(spring.vx, spring.vy) < 2 {
94
+ position = target.point
95
+ self.spring = nil
96
+ springTarget = nil
97
+ lastFrameTime = nil
98
+ isAnimating = false
99
+ } else {
100
+ self.spring = spring
101
+ }
102
+ }
103
+ }
104
+
105
+ private func rotateToward(current: Double, desired: Double, maxStep: Double) -> Double {
106
+ var difference = desired - current
107
+ while difference > .pi { difference -= 2 * .pi }
108
+ while difference < -.pi { difference += 2 * .pi }
109
+ return current + max(-maxStep, min(maxStep, difference))
110
+ }
111
+ }
112
+
113
+ private struct Spring { var ox, oy, vx, vy: Double }
114
+
115
+ struct DubinsPlannedPath {
116
+ enum Kind { case dubins, linear }
117
+ let kind: Kind
118
+ let length: Double
119
+ let endVisualHeading: Double
120
+ let targetPoint: CGPoint
121
+ let x0, y0, th0, R, seg1, seg2, seg3: Double
122
+ let types: [Character]
123
+ let x1, y1, th1: Double
124
+
125
+ struct State { let x, y, heading: Double }
126
+
127
+ func sample(at s: Double) -> State {
128
+ switch kind { case .linear: return sampleLinear(s); case .dubins: return sampleDubins(s) }
129
+ }
130
+
131
+ private func sampleLinear(_ s: Double) -> State {
132
+ let u = max(0, min(1, s / length))
133
+ var diff = th1 - th0
134
+ while diff > .pi { diff -= 2 * .pi }
135
+ while diff < -.pi { diff += 2 * .pi }
136
+ return State(x: x0 + (x1 - x0) * u, y: y0 + (y1 - y0) * u, heading: th0 + diff * u)
137
+ }
138
+
139
+ private func sampleDubins(_ sIn: Double) -> State {
140
+ guard sIn > 0 else { return State(x: x0, y: y0, heading: th0) }
141
+ let L1 = seg1 * R, L2 = seg2 * R, L3 = seg3 * R
142
+ let s = min(sIn, L1 + L2 + L3)
143
+ var x = x0, y = y0, th = th0
144
+
145
+ func advance(length L: Double, type: Character) {
146
+ if type == "S" { x += cos(th) * L; y += sin(th) * L }
147
+ else {
148
+ let dth = L / R * (type == "L" ? 1.0 : -1.0)
149
+ let perp: Double = (type == "L") ? .pi / 2 : -.pi / 2
150
+ let cx = x + cos(th + perp) * R, cy = y + sin(th + perp) * R
151
+ let ang = atan2(y - cy, x - cx)
152
+ x = cx + cos(ang + dth) * R; y = cy + sin(ang + dth) * R; th += dth
153
+ }
154
+ }
155
+ if s <= L1 { advance(length: s, type: types[0]); return State(x: x, y: y, heading: th) }
156
+ advance(length: L1, type: types[0])
157
+ if s <= L1 + L2 { advance(length: s - L1, type: types[1]); return State(x: x, y: y, heading: th) }
158
+ advance(length: L2, type: types[1])
159
+ advance(length: s - L1 - L2, type: types[2])
160
+ return State(x: x, y: y, heading: th)
161
+ }
162
+ }
163
+
164
+ private typealias PlannedPath = DubinsPlannedPath
165
+
166
+ private func mod2pi(_ x: Double) -> Double {
167
+ let tau = 2 * Double.pi; let r = x - tau * floor(x / tau); return r < 0 ? r + tau : r
168
+ }
169
+
170
+ private struct DubinsSolution { let t, p, q: Double; let types: [Character]; var length: Double { t + p + q } }
171
+
172
+ private func dubinsLSL(_ d: Double, _ a: Double, _ b: Double) -> DubinsSolution? {
173
+ let tmp0 = d + sin(a) - sin(b)
174
+ let p2 = 2 + d * d - 2 * cos(a - b) + 2 * d * (sin(a) - sin(b))
175
+ guard p2 >= 0 else { return nil }
176
+ let tmp1 = atan2(cos(b) - cos(a), tmp0)
177
+ return DubinsSolution(t: mod2pi(-a + tmp1), p: sqrt(p2), q: mod2pi(b - tmp1), types: ["L","S","L"])
178
+ }
179
+ private func dubinsRSR(_ d: Double, _ a: Double, _ b: Double) -> DubinsSolution? {
180
+ let tmp0 = d - sin(a) + sin(b)
181
+ let p2 = 2 + d * d - 2 * cos(a - b) + 2 * d * (sin(b) - sin(a))
182
+ guard p2 >= 0 else { return nil }
183
+ let tmp1 = atan2(cos(a) - cos(b), tmp0)
184
+ return DubinsSolution(t: mod2pi(a - tmp1), p: sqrt(p2), q: mod2pi(-b + tmp1), types: ["R","S","R"])
185
+ }
186
+ private func dubinsLSR(_ d: Double, _ a: Double, _ b: Double) -> DubinsSolution? {
187
+ let p2 = -2 + d * d + 2 * cos(a - b) + 2 * d * (sin(a) + sin(b))
188
+ guard p2 >= 0 else { return nil }
189
+ let p = sqrt(p2)
190
+ let tmp1 = atan2(-cos(a) - cos(b), d + sin(a) + sin(b)) - atan2(-2, p)
191
+ return DubinsSolution(t: mod2pi(-a + tmp1), p: p, q: mod2pi(-mod2pi(b) + tmp1), types: ["L","S","R"])
192
+ }
193
+ private func dubinsRSL(_ d: Double, _ a: Double, _ b: Double) -> DubinsSolution? {
194
+ let p2 = d * d - 2 + 2 * cos(a - b) - 2 * d * (sin(a) + sin(b))
195
+ guard p2 >= 0 else { return nil }
196
+ let p = sqrt(p2)
197
+ let tmp1 = atan2(cos(a) + cos(b), d - sin(a) - sin(b)) - atan2(2, p)
198
+ return DubinsSolution(t: mod2pi(a - tmp1), p: p, q: mod2pi(b - tmp1), types: ["R","S","L"])
199
+ }
200
+ private func dubinsRLR(_ d: Double, _ a: Double, _ b: Double) -> DubinsSolution? {
201
+ let tmp = (6 - d * d + 2 * cos(a - b) + 2 * d * (sin(a) - sin(b))) / 8
202
+ guard abs(tmp) <= 1 else { return nil }
203
+ let p = mod2pi(2 * .pi - acos(tmp))
204
+ let t = mod2pi(a - atan2(cos(a) - cos(b), d - sin(a) + sin(b)) + p / 2)
205
+ return DubinsSolution(t: t, p: p, q: mod2pi(a - b - t + p), types: ["R","L","R"])
206
+ }
207
+ private func dubinsLRL(_ d: Double, _ a: Double, _ b: Double) -> DubinsSolution? {
208
+ let tmp = (6 - d * d + 2 * cos(a - b) + 2 * d * (sin(b) - sin(a))) / 8
209
+ guard abs(tmp) <= 1 else { return nil }
210
+ let p = mod2pi(2 * .pi - acos(tmp))
211
+ let t = mod2pi(-a + atan2(-cos(a) + cos(b), d + sin(a) - sin(b)) + p / 2)
212
+ return DubinsSolution(t: t, p: p, q: mod2pi(mod2pi(b) - a - t + p), types: ["L","R","L"])
213
+ }
214
+
215
+ private func planPath(x0: Double, y0: Double, th0: Double,
216
+ x1: Double, y1: Double, th1: Double,
217
+ R: Double, endVisualHeading: Double,
218
+ targetPoint: CGPoint) -> PlannedPath {
219
+ if let p = planDubins(x0: x0, y0: y0, th0: th0, x1: x1, y1: y1, th1: th1,
220
+ R: R, endVisualHeading: endVisualHeading, targetPoint: targetPoint) {
221
+ return p
222
+ }
223
+ let D = max(1, hypot(x1 - x0, y1 - y0))
224
+ return PlannedPath(kind: .linear, length: D, endVisualHeading: endVisualHeading,
225
+ targetPoint: targetPoint, x0: x0, y0: y0, th0: th0, R: R,
226
+ seg1: 0, seg2: 0, seg3: 0, types: [], x1: x1, y1: y1, th1: th1)
227
+ }
228
+
229
+ private func planDubins(x0: Double, y0: Double, th0: Double,
230
+ x1: Double, y1: Double, th1: Double,
231
+ R: Double, endVisualHeading: Double,
232
+ targetPoint: CGPoint) -> PlannedPath? {
233
+ let dx = x1 - x0, dy = y1 - y0, D = hypot(dx, dy)
234
+ guard D > 0.5 else { return nil }
235
+ let d = D / R, theta = mod2pi(atan2(dy, dx))
236
+ let a = mod2pi(th0 - theta), b = mod2pi(th1 - theta)
237
+ let solvers: [(Double, Double, Double) -> DubinsSolution?] = [
238
+ dubinsLSL, dubinsRSR, dubinsLSR, dubinsRSL, dubinsRLR, dubinsLRL,
239
+ ]
240
+ var best: DubinsSolution?; var bestLen = Double.infinity
241
+ for s in solvers {
242
+ if let sol = s(d, a, b), sol.length.isFinite, sol.length >= 0, sol.length < bestLen {
243
+ bestLen = sol.length; best = sol
244
+ }
245
+ }
246
+ guard let b = best else { return nil }
247
+ return PlannedPath(kind: .dubins, length: (b.t + b.p + b.q) * R,
248
+ endVisualHeading: endVisualHeading, targetPoint: targetPoint,
249
+ x0: x0, y0: y0, th0: th0, R: R,
250
+ seg1: b.t, seg2: b.p, seg3: b.q, types: b.types,
251
+ x1: x1, y1: y1, th1: th1)
252
+ }
@@ -0,0 +1,128 @@
1
+ import AppKit
2
+ import CoreGraphics
3
+ import Foundation
4
+
5
+ @main
6
+ struct AgentCursorTests {
7
+ @MainActor
8
+ static func main() {
9
+ testMotionLifecycle()
10
+ testOverlayLifecycle()
11
+ }
12
+
13
+ @MainActor
14
+ private static func testMotionLifecycle() {
15
+ let renderer = AgentCursorRenderer()
16
+ renderer.setInitialPosition(CGPoint(x: 100, y: 100))
17
+
18
+ expect(!renderer.isAnimating, "renderer should start idle")
19
+
20
+ renderer.moveTo(point: CGPoint(x: 500, y: 400))
21
+ expect(renderer.isAnimating, "renderer should become active when motion starts")
22
+
23
+ var now: CFTimeInterval = 0
24
+ for _ in 0..<12_000 where renderer.isAnimating {
25
+ now += 1.0 / 120.0
26
+ renderer.tick(now: now)
27
+ }
28
+
29
+ expect(!renderer.isAnimating, "renderer should become idle after motion settles")
30
+
31
+ renderer.moveTo(point: CGPoint(x: 800, y: 600))
32
+ renderer.tick(now: now + 1.0 / 120.0)
33
+ let stoppedPosition = renderer.position
34
+
35
+ renderer.cancelAnimation()
36
+ expect(!renderer.isAnimating, "cancelled motion should become idle")
37
+
38
+ renderer.tick(now: now + 1)
39
+ expect(renderer.position == stoppedPosition, "idle ticks should not change the cancelled position")
40
+
41
+ renderer.setInitialPosition(CGPoint(x: 100, y: 100))
42
+ renderer.moveTo(point: CGPoint(x: 300, y: 300))
43
+ for _ in 0..<12 {
44
+ now += 1.0 / 120.0
45
+ renderer.tick(now: now)
46
+ }
47
+ let latestTarget = CGPoint(x: 900, y: 700)
48
+ renderer.moveTo(point: latestTarget)
49
+ for _ in 0..<12_000 where renderer.isAnimating {
50
+ now += 1.0 / 120.0
51
+ renderer.tick(now: now)
52
+ }
53
+
54
+ let endpointOffset = CGFloat(cos(Double.pi / 4) * 16)
55
+ let expectedPosition = CGPoint(
56
+ x: latestTarget.x + endpointOffset,
57
+ y: latestTarget.y + endpointOffset
58
+ )
59
+ expect(distance(renderer.position, expectedPosition) < 0.001, "latest target should supersede in-flight motion")
60
+ }
61
+
62
+ @MainActor
63
+ private static func testOverlayLifecycle() {
64
+ let application = NSApplication.shared
65
+ let existingWindows = Set(application.windows.map(ObjectIdentifier.init))
66
+ let scheduler = ManualIdleHideScheduler()
67
+ let cursor = AgentCursor(scheduleIdleHide: scheduler.schedule)
68
+
69
+ cursor.animate(to: CGPoint(x: 300, y: 300), above: 0)
70
+ guard let firstWindow = application.windows.first(where: {
71
+ !existingWindows.contains(ObjectIdentifier($0)) && $0.isVisible
72
+ }) else {
73
+ fail("first action should create a visible overlay")
74
+ }
75
+
76
+ cursor.animate(to: CGPoint(x: 500, y: 500), above: 0)
77
+ expect(scheduler.count == 2, "each action should replace the idle timeout")
78
+
79
+ scheduler.fire(0)
80
+ expect(firstWindow.isVisible, "a stale timeout should not hide the current overlay")
81
+ expect(firstWindow.contentView != nil, "a stale timeout should not release the current view")
82
+
83
+ scheduler.fire(1)
84
+ expect(!AgentCursorRenderer.shared.isAnimating, "the current timeout should stop rendering")
85
+ expect(!firstWindow.isVisible, "the current timeout should hide the overlay")
86
+ expect(firstWindow.contentView == nil, "the current timeout should release the view tree")
87
+
88
+ cursor.animate(to: CGPoint(x: 700, y: 700), above: 0)
89
+ guard let recreatedWindow = application.windows.first(where: {
90
+ !existingWindows.contains(ObjectIdentifier($0)) && $0 !== firstWindow && $0.isVisible
91
+ }) else {
92
+ fail("a later action should recreate the overlay")
93
+ }
94
+
95
+ scheduler.fire(2)
96
+ expect(!recreatedWindow.isVisible, "the recreated overlay should retain the idle lifecycle")
97
+ expect(recreatedWindow.contentView == nil, "the recreated overlay should release its view tree")
98
+ }
99
+
100
+ private static func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
101
+ guard condition() else { fail(message) }
102
+ }
103
+
104
+ private static func fail(_ message: String) -> Never {
105
+ FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
106
+ exit(1)
107
+ }
108
+
109
+ private static func distance(_ lhs: CGPoint, _ rhs: CGPoint) -> CGFloat {
110
+ hypot(lhs.x - rhs.x, lhs.y - rhs.y)
111
+ }
112
+ }
113
+
114
+ @MainActor
115
+ private final class ManualIdleHideScheduler {
116
+ private var actions: [@MainActor () -> Void] = []
117
+
118
+ var count: Int { actions.count }
119
+
120
+ func schedule(_ action: @escaping @MainActor () -> Void) -> Task<Void, Never> {
121
+ actions.append(action)
122
+ return Task {}
123
+ }
124
+
125
+ func fire(_ index: Int) {
126
+ actions[index]()
127
+ }
128
+ }