@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,229 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// 被操控窗口的边缘光效(Tangu 新增,非上游)。
5
+ ///
6
+ /// 为什么放在 helper 里:光效必须画在**别人的 App 窗口**上,只有这个常驻的辅助进程能建一个悬在目标
7
+ /// 窗口之上的覆盖层。Forsion 自己的窗口画不到别人身上。
8
+ ///
9
+ /// 三条纪律:
10
+ /// 1. **绝不抢焦点、绝不吃事件** —— canBecomeKey=false + ignoresMouseEvents,与 AgentCursor 同款。
11
+ /// 否则「后台操控」这个前提就没了。
12
+ /// 2. **静态绘制,不做动画** —— 上游刚修过「光标空闲期一直重绘」(c089b30/5943c16)。呼吸灯要 120fps
13
+ /// 的 TimelineView 常驻跑,代价是操控期间一直烧 GPU。这里只画一圈静态辉光,只有几何真变了才重绘。
14
+ /// 3. **跟随窗口**:可见期间自己跑一个 8Hz 的 timer 拉 CGWindowList 的矩形(同步、便宜),几何没变
15
+ /// 就什么都不做。窗口没了(拿不到矩形)直接熄灯。
16
+
17
+ /// 光效在最后一次动作后继续亮多久(秒)。短了会在连续动作之间闪烁,长了显得 agent 还在动。
18
+ /// 顶层常量而非 static:@MainActor 类型的 static 不能当非隔离上下文里的默认参数(Swift 6 会报错)。
19
+ private let highlightLingerSeconds: TimeInterval = 2.0
20
+ private let highlightFollowInterval: TimeInterval = 0.125
21
+
22
+ /// 覆盖层几何(光效与示意光标共用)。抽成顶层纯函数是为了可单测 —— 算错了不会报错,
23
+ /// 只会"光标/光效画在看不见的地方",而那种错误只在特定的多屏摆位下出现,人工点验抓不住。
24
+ enum OverlayGeometry {
25
+ /// 所有屏幕的并集(AppKit 坐标,主屏左下为原点)。覆盖层必须按它开,否则副屏上的点画在窗口之外。
26
+ static func union(of screenFrames: [CGRect]) -> CGRect {
27
+ screenFrames.reduce(CGRect.null) { $0.union($1) }
28
+ }
29
+
30
+ /// 全局 CG 点(左上原点,y 向下)→ 覆盖层内的画布坐标(窗口左上原点,y 向下)。
31
+ ///
32
+ /// 单显示器时 union=(0,0,W,H)、primaryHeight=H,结果**恒等于输入** —— 与只有主屏时的老行为一致。
33
+ /// 多显示器时:副屏在主屏上方 → union.maxY > primaryHeight,y 要相应下移;副屏在左侧 →
34
+ /// union.minX < 0,x 要相应右移。
35
+ static func canvasPoint(_ point: CGPoint, union: CGRect, primaryHeight: CGFloat) -> CGPoint {
36
+ CGPoint(x: point.x - union.minX, y: point.y + union.maxY - primaryHeight)
37
+ }
38
+ }
39
+
40
+ @MainActor
41
+ final class AgentHighlight {
42
+ static let shared = AgentHighlight()
43
+
44
+ private var overlay: HighlightOverlayWindow?
45
+ private var hideTask: Task<Void, Never>?
46
+ private var followTimer: Timer?
47
+ private var generation: UInt = 0
48
+ private(set) var currentWindowId: UInt32 = 0
49
+ private var currentFrame: CGRect = .zero
50
+
51
+ /// 光效外扩多少点(辉光要向外晕开,覆盖层比目标窗口大一圈)。
52
+ private static let pad: CGFloat = 12
53
+
54
+ /// 点亮 / 续期(每次 act 调一次)。窗口矩形现查现用,调用方不必提供。
55
+ func show(windowId: UInt32, linger: TimeInterval = highlightLingerSeconds) {
56
+ guard let cgFrame = cgWindowBounds(windowId: windowId) else {
57
+ // 新目标定位不到(窗口刚关、或是没有 CG 窗口的菜单根)。**不能默默返回** ——
58
+ // 那会把光效留在上一个窗口上直到它自己的 linger 到期,等于指着错的窗口说"正在操作这里"。
59
+ if currentWindowId != 0 && currentWindowId != windowId { hide() }
60
+ return
61
+ }
62
+ place(windowId: windowId, cgFrame: cgFrame)
63
+
64
+ generation &+= 1
65
+ let mine = generation
66
+ hideTask?.cancel()
67
+ hideTask = Task { @MainActor in
68
+ try? await Task.sleep(for: .seconds(linger))
69
+ guard !Task.isCancelled, mine == self.generation else { return }
70
+ self.hide()
71
+ }
72
+ startFollowing()
73
+ }
74
+
75
+ func hide() {
76
+ hideTask?.cancel()
77
+ hideTask = nil
78
+ followTimer?.invalidate()
79
+ followTimer = nil
80
+ generation &+= 1
81
+ currentWindowId = 0
82
+ currentFrame = .zero
83
+ guard let window = overlay else { return }
84
+ window.orderOut(nil)
85
+ window.contentView = nil
86
+ window.close()
87
+ overlay = nil
88
+ }
89
+
90
+ /// 摆位(不续期):show 与跟随 timer 共用。
91
+ private func place(windowId: UInt32, cgFrame: CGRect) {
92
+ guard cgFrame.width > 1, cgFrame.height > 1 else { return }
93
+ let appKitFrame = AgentHighlight.toAppKit(cgFrame).insetBy(dx: -AgentHighlight.pad, dy: -AgentHighlight.pad)
94
+ let window = ensureWindow()
95
+ // 只有几何真变了才动窗口 —— 不变时 setFrame 也会触发一次重绘。
96
+ if currentWindowId != windowId || !AgentHighlight.nearlyEqual(currentFrame, appKitFrame) {
97
+ currentWindowId = windowId
98
+ currentFrame = appKitFrame
99
+ window.setFrame(appKitFrame, display: true)
100
+ }
101
+ if !window.isVisible { window.orderFrontRegardless() }
102
+ // ⚠️这里曾是 window.order(.above, relativeTo: Int(windowId)) —— 对**别的进程**的 window number
103
+ // 是空操作(实测:窗口既不上移也不消失)。跨进程的 z-order 只能靠 window level,见 init 里的 .floating。
104
+ }
105
+
106
+ private func startFollowing() {
107
+ guard followTimer == nil else { return }
108
+ let timer = Timer(timeInterval: highlightFollowInterval, repeats: true) { _ in
109
+ Task { @MainActor in AgentHighlight.shared.followTick() }
110
+ }
111
+ // .common:拖窗口/滚动时主 runloop 进 tracking mode,default mode 的 timer 会整个停掉 ——
112
+ // 而"用户拖窗口"恰好是最需要跟随的时刻。
113
+ RunLoop.main.add(timer, forMode: .common)
114
+ followTimer = timer
115
+ }
116
+
117
+ private func followTick() {
118
+ let id = currentWindowId
119
+ guard overlay != nil, id != 0 else {
120
+ followTimer?.invalidate()
121
+ followTimer = nil
122
+ return
123
+ }
124
+ guard let cgFrame = cgWindowBounds(windowId: id) else {
125
+ hide() // 窗口关了/最小化了,别把光效留在原地
126
+ return
127
+ }
128
+ place(windowId: id, cgFrame: cgFrame)
129
+ }
130
+
131
+ var isVisible: Bool { overlay?.isVisible == true }
132
+
133
+ private func ensureWindow() -> HighlightOverlayWindow {
134
+ if let overlay { return overlay }
135
+ let window = HighlightOverlayWindow(
136
+ contentRect: .zero,
137
+ styleMask: .borderless,
138
+ backing: .buffered,
139
+ defer: false
140
+ )
141
+ window.contentView = NSHostingView(rootView: HighlightView(inset: AgentHighlight.pad))
142
+ overlay = window
143
+ return window
144
+ }
145
+
146
+ /// CG 全局坐标(左上原点)→ AppKit 全局坐标(主屏左下原点)。
147
+ /// 主屏 = NSScreen.screens.first,**不是** NSScreen.main(后者是"当前有键盘焦点的屏")。
148
+ /// 拆出纯函数版是为了可测:算错就是整块光效系统性偏移一个屏高,肉眼在单屏下还未必看得出来。
149
+ nonisolated static func toAppKit(_ rect: CGRect, primaryHeight: CGFloat) -> CGRect {
150
+ CGRect(x: rect.minX, y: primaryHeight - rect.maxY, width: rect.width, height: rect.height)
151
+ }
152
+
153
+ static func toAppKit(_ rect: CGRect) -> CGRect {
154
+ toAppKit(rect, primaryHeight: NSScreen.screens.first?.frame.height ?? 0)
155
+ }
156
+
157
+ nonisolated static func nearlyEqual(_ a: CGRect, _ b: CGRect) -> Bool {
158
+ abs(a.minX - b.minX) < 0.5 && abs(a.minY - b.minY) < 0.5
159
+ && abs(a.width - b.width) < 0.5 && abs(a.height - b.height) < 0.5
160
+ }
161
+ }
162
+
163
+ /// 按 CG 窗口号取窗口矩形(CG 全局坐标,左上原点)。
164
+ /// 顶层函数而非 Bridge 方法:AgentHighlight 是 @MainActor 单例,不该为了拿个矩形去持有 Bridge。
165
+ /// 只用 CGWindowList(同步、便宜),不碰 ScreenCaptureKit —— 8Hz 跟随经不起异步捕获那条路。
166
+ func cgWindowBounds(windowId: UInt32) -> CGRect? {
167
+ // ⚠️别换回 CGWindowListCreateDescriptionFromArray:它对**别的进程**的窗口恒返回空数组
168
+ // (实测同一个计算器 windowId,CreateDescriptionFromArray → count=0,
169
+ // CopyWindowInfo(.optionIncludingWindow) → count=1 且 bounds 正确)。
170
+ // 这条曾让边缘光效整个功能静默失效 —— show() 在这里 return,窗口连建都没建。
171
+ // ⚠️还要看 kCGWindowIsOnscreen:`.optionIncludingWindow` 连**最小化 / 已隐藏**的窗口也照给
172
+ // bounds,拿它去摆光效 = 在目标已经不在屏上时,把光圈留在它原来的位置上闪。
173
+ guard let entries = CGWindowListCopyWindowInfo([.optionIncludingWindow], CGWindowID(windowId)) as? [[String: Any]],
174
+ let entry = entries.first(where: { ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == windowId }),
175
+ (entry[kCGWindowIsOnscreen as String] as? Bool) == true,
176
+ let boundsDict = entry[kCGWindowBounds as String] as? [String: Any],
177
+ let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary)
178
+ else { return nil }
179
+ return bounds
180
+ }
181
+
182
+ /// 点击穿透、永不成为 key/main 的覆盖层。
183
+ private final class HighlightOverlayWindow: NSWindow {
184
+ override var canBecomeKey: Bool { false }
185
+ override var canBecomeMain: Bool { false }
186
+
187
+ override init(contentRect: NSRect, styleMask: NSWindow.StyleMask, backing: NSWindow.BackingStoreType, defer flag: Bool) {
188
+ super.init(contentRect: contentRect, styleMask: styleMask, backing: backing, defer: flag)
189
+ isOpaque = false
190
+ backgroundColor = .clear
191
+ hasShadow = false
192
+ ignoresMouseEvents = true
193
+ // 宿主是 .accessory,永远不激活 → 普通层(0)的叠层会被"刚被激活的目标 App"直接压在下面,
194
+ // 用户什么也看不见。跨进程置顶只有 window level 一条路。光效用 .floating(3):盖住普通窗口,
195
+ // 但目标自己弹的菜单(popUpMenu=101)仍在它之上 —— 菜单本来就该盖住窗口边框的光。
196
+ level = .floating
197
+ collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
198
+ isReleasedWhenClosed = false
199
+ hidesOnDeactivate = false
200
+ }
201
+ }
202
+
203
+ /// 静态辉光边框:由外到内几道圆角描边,越外越淡 —— 一次绘制,不动画。
204
+ /// 颜色与 AgentCursor 同源(#FF7818),视觉上是同一个"agent 正在动手"的信号。
205
+ private struct HighlightView: View {
206
+ let inset: CGFloat
207
+
208
+ private static let accent = Color(red: 1, green: 0x78 / 255, blue: 0x18 / 255)
209
+ /// (相对边框中心线的外扩量, 线宽, 不透明度)
210
+ private static let rings: [(CGFloat, CGFloat, Double)] = [
211
+ (7, 10, 0.10),
212
+ (3.5, 7, 0.22),
213
+ (0, 3, 0.85),
214
+ ]
215
+
216
+ var body: some View {
217
+ Canvas { graphics, size in
218
+ let base = CGRect(x: inset, y: inset, width: max(0, size.width - inset * 2), height: max(0, size.height - inset * 2))
219
+ guard base.width > 2, base.height > 2 else { return }
220
+ for (spread, width, opacity) in HighlightView.rings {
221
+ let rect = base.insetBy(dx: -spread, dy: -spread)
222
+ let path = Path(roundedRect: rect, cornerRadius: 12 + spread)
223
+ graphics.stroke(path, with: .color(HighlightView.accent.opacity(opacity)), lineWidth: width)
224
+ }
225
+ }
226
+ .ignoresSafeArea()
227
+ .allowsHitTesting(false)
228
+ }
229
+ }
@@ -0,0 +1,99 @@
1
+ import AppKit
2
+ import CoreGraphics
3
+ import Foundation
4
+
5
+ /// 边缘光效里唯一「算错了肉眼未必看得出来」的部分:CG(左上原点)↔ AppKit(主屏左下原点)坐标换算。
6
+ /// 单屏、窗口居中时偏一个屏高会直接飞出屏幕(看得见);但多屏或窗口贴边时会变成"贴在别的窗口上",
7
+ /// 那种错误只在特定几何下出现,靠人工点验抓不住 —— 所以钉一组已知答案。
8
+ @main
9
+ struct AgentHighlightTests {
10
+ static func main() {
11
+ // ── 覆盖层几何(示意光标的"看不见"就出在这儿:覆盖层只开了主屏那么大)──
12
+ let primary = CGRect(x: 0, y: 0, width: 1600, height: 1000)
13
+ // 单屏:并集就是主屏,换算必须是恒等 —— 否则老的单屏行为就被改坏了
14
+ expect(
15
+ OverlayGeometry.union(of: [primary]) == primary,
16
+ "单屏时并集应等于主屏"
17
+ )
18
+ expect(
19
+ OverlayGeometry.canvasPoint(CGPoint(x: 300, y: 400), union: primary, primaryHeight: 1000)
20
+ == CGPoint(x: 300, y: 400),
21
+ "单屏时全局点到画布点必须是恒等换算"
22
+ )
23
+
24
+ // 副屏在主屏**右侧**:x 不变,y 也不变(并集顶边仍是主屏顶边)
25
+ let right = CGRect(x: 1600, y: 0, width: 1920, height: 1080)
26
+ let unionRight = OverlayGeometry.union(of: [primary, right])
27
+ expect(unionRight == CGRect(x: 0, y: 0, width: 3520, height: 1080), "并集应横向扩到两屏,高度取更高的那块")
28
+ // 副屏更高 → 并集顶边比主屏顶边高 80,全局 CG 的 y(从主屏顶边算)必须相应下移
29
+ expect(
30
+ OverlayGeometry.canvasPoint(CGPoint(x: 2000, y: 500), union: unionRight, primaryHeight: 1000)
31
+ == CGPoint(x: 2000, y: 580),
32
+ "右侧副屏更高时,画布 y 应比全局 y 多出并集顶边高出主屏的那 80"
33
+ )
34
+
35
+ // 副屏在主屏**左侧**:union.minX 为负,x 必须右移,否则画到覆盖层左边界之外(看不见)
36
+ let left = CGRect(x: -1920, y: 0, width: 1920, height: 1080)
37
+ let unionLeft = OverlayGeometry.union(of: [primary, left])
38
+ expect(
39
+ OverlayGeometry.canvasPoint(CGPoint(x: -500, y: 200), union: unionLeft, primaryHeight: 1000)
40
+ == CGPoint(x: 1420, y: 200 + unionLeft.maxY - 1000),
41
+ "副屏在左时 x 必须补上并集原点的负偏移"
42
+ )
43
+
44
+ // 副屏在主屏**上方**:并集顶边高过主屏顶边,y 必须下移
45
+ let above = CGRect(x: 0, y: 1000, width: 1600, height: 900)
46
+ let unionAbove = OverlayGeometry.union(of: [primary, above])
47
+ expect(unionAbove.maxY == 1900, "上方副屏应把并集顶边抬到 1900")
48
+ expect(
49
+ OverlayGeometry.canvasPoint(CGPoint(x: 100, y: 50), union: unionAbove, primaryHeight: 1000)
50
+ == CGPoint(x: 100, y: 950),
51
+ "副屏在上时 y 必须下移一个副屏高度"
52
+ )
53
+
54
+ let primaryHeight: CGFloat = 1000
55
+
56
+ // 顶到主屏顶部的窗口:CG y=0 → AppKit y = 屏高 - 窗高
57
+ expect(
58
+ AgentHighlight.toAppKit(CGRect(x: 10, y: 0, width: 300, height: 200), primaryHeight: primaryHeight)
59
+ == CGRect(x: 10, y: 800, width: 300, height: 200),
60
+ "窗口贴主屏顶部时,AppKit y 应为 屏高-窗高"
61
+ )
62
+
63
+ // 坐在主屏底部:CG maxY == 屏高 → AppKit y = 0
64
+ expect(
65
+ AgentHighlight.toAppKit(CGRect(x: 0, y: 800, width: 300, height: 200), primaryHeight: primaryHeight)
66
+ == CGRect(x: 0, y: 0, width: 300, height: 200),
67
+ "窗口贴主屏底部时,AppKit y 应为 0"
68
+ )
69
+
70
+ // 副屏在主屏上方:CG y 为负 → AppKit y 大于屏高(合法,不该被夹到 0)
71
+ expect(
72
+ AgentHighlight.toAppKit(CGRect(x: -400, y: -600, width: 300, height: 200), primaryHeight: primaryHeight)
73
+ == CGRect(x: -400, y: 1400, width: 300, height: 200),
74
+ "主屏上方的副屏窗口应换算出大于屏高的 AppKit y,不能夹紧"
75
+ )
76
+
77
+ // 宽高不参与换算
78
+ let converted = AgentHighlight.toAppKit(CGRect(x: 5, y: 5, width: 123, height: 456), primaryHeight: primaryHeight)
79
+ expect(converted.width == 123 && converted.height == 456, "换算不得改变尺寸")
80
+
81
+ // 抖动阈值:亚像素差算相等(否则每帧都 setFrame 重绘),半像素以上算变化
82
+ expect(
83
+ AgentHighlight.nearlyEqual(CGRect(x: 0, y: 0, width: 10, height: 10), CGRect(x: 0.2, y: 0, width: 10, height: 10)),
84
+ "亚像素抖动应视为未变化"
85
+ )
86
+ expect(
87
+ !AgentHighlight.nearlyEqual(CGRect(x: 0, y: 0, width: 10, height: 10), CGRect(x: 0, y: 0, width: 11, height: 10)),
88
+ "尺寸真变了必须视为变化"
89
+ )
90
+
91
+ print("agent highlight geometry checks passed")
92
+ }
93
+
94
+ private static func expect(_ condition: Bool, _ message: String) {
95
+ if condition { return }
96
+ FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
97
+ exit(1)
98
+ }
99
+ }