@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,57 @@
1
+ import Foundation
2
+
3
+ /// A data-only signal beside the helper socket. Desktop can read it while a long input
4
+ /// command is running; no screenshots, focus changes or helper startup are involved.
5
+ final class ForegroundActivity {
6
+ static let shared = ForegroundActivity()
7
+ private let lock = NSLock()
8
+ private var depth = 0
9
+ private var active = false
10
+ private var file: URL?
11
+ private var timer: DispatchSourceTimer?
12
+ private let now: () -> Double
13
+
14
+ init(now: @escaping () -> Double = { Date().timeIntervalSince1970 * 1000 }) { self.now = now }
15
+
16
+ func configure(socketPath: String) {
17
+ lock.lock(); defer { lock.unlock() }
18
+ file = URL(fileURLWithPath: socketPath).deletingLastPathComponent().appendingPathComponent("foreground.json")
19
+ publish(active: false, until: now())
20
+ let timer = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "forsion.foreground-signal"))
21
+ timer.schedule(deadline: .now() + 1, repeating: 1)
22
+ timer.setEventHandler { [weak self] in self?.heartbeat() }
23
+ timer.resume()
24
+ self.timer = timer
25
+ }
26
+
27
+ // Called inside the recursive physical-input lock. A permitted foreground policy
28
+ // alone does not activate the signal: successful AX/background paths stay silent.
29
+ func enter() { lock.lock(); depth += 1; lock.unlock() }
30
+ func physicalInput() {
31
+ lock.lock(); defer { lock.unlock() }
32
+ guard !active else { return } // HID movement can emit 120 events/s; the heartbeat renews the lease.
33
+ active = true
34
+ publish(active: true, until: now() + 2500)
35
+ }
36
+ func leave() {
37
+ lock.lock(); defer { lock.unlock() }
38
+ depth = max(0, depth - 1)
39
+ if depth == 0 && active {
40
+ active = false
41
+ // Preserve a short click between desktop polls, then expire deterministically.
42
+ publish(active: false, until: now() + 350)
43
+ }
44
+ }
45
+ private func heartbeat() {
46
+ lock.lock(); defer { lock.unlock() }
47
+ if active { publish(active: true, until: now() + 2500) }
48
+ }
49
+ private func publish(active: Bool, until: Double) {
50
+ guard let file else { return }
51
+ let payload: [String: Any] = ["v": 1, "active": active, "expiresAt": until,
52
+ "updatedAt": now(), "helperPid": ProcessInfo.processInfo.processIdentifier]
53
+ guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
54
+ try? data.write(to: file, options: .atomic)
55
+ }
56
+ deinit { timer?.cancel() }
57
+ }
@@ -0,0 +1,31 @@
1
+ import Foundation
2
+
3
+ @main struct ForegroundActivityTests {
4
+ static func main() throws {
5
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
6
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
7
+ defer { try? FileManager.default.removeItem(at: dir) }
8
+ let signal = dir.appendingPathComponent("foreground.json")
9
+ var now = 1000.0
10
+ let activity = ForegroundActivity(now: { now })
11
+ activity.configure(socketPath: dir.appendingPathComponent("bridge.sock").path)
12
+ func snapshot() throws -> [String: Any] {
13
+ try JSONSerialization.jsonObject(with: Data(contentsOf: signal)) as! [String: Any]
14
+ }
15
+ func check(_ yes: Bool, _ name: String) { precondition(yes, name); print("PASS \(name)") }
16
+ activity.enter(); activity.leave()
17
+ check(try snapshot()["active"] as? Bool == false, "AX/background scope does not activate")
18
+ check(try snapshot()["expiresAt"] as? Double == 1000, "background scope does not extend lease")
19
+ activity.enter(); activity.enter(); activity.physicalInput()
20
+ check(try snapshot()["active"] as? Bool == true, "physical input activates before posting")
21
+ activity.leave()
22
+ check(try snapshot()["active"] as? Bool == true, "nested input keeps outer action active")
23
+ now = 1200
24
+ activity.leave()
25
+ check(try snapshot()["active"] as? Bool == false, "completion clears active state")
26
+ check(try snapshot()["expiresAt"] as? Double == 1550, "short click has bounded 350ms grace")
27
+ activity.enter(); activity.leave()
28
+ check(try snapshot()["expiresAt"] as? Double == 1550, "later background action never renews it")
29
+ check(try snapshot()["helperPid"] as? Int == Int(ProcessInfo.processInfo.processIdentifier), "reader can detect dead helper")
30
+ }
31
+ }
@@ -0,0 +1,289 @@
1
+ import CoreImage
2
+ import CoreMedia
3
+ import CoreVideo
4
+ import Foundation
5
+ import ScreenCaptureKit
6
+
7
+ /// 被操控窗口的**常驻取景流**(Tangu 新增,非上游)。
8
+ ///
9
+ /// 为什么不是"每帧现截":`SCScreenshotManager.captureImage` 每次都要重新协商一次采集会话,
10
+ /// 单帧几十到几百毫秒不等,偶发能跑到秒级(所以 `captureWindow` 那边挂着 8 秒超时 +
11
+ /// CGWindowList 兜底)。拿它做实时画面就是 1fps 幻灯片 —— 用户实报"掉帧,不是 screen sharing"。
12
+ /// ScreenCaptureKit 的正路是开一条 `SCStream` 让系统持续推帧,取景时只从缓存里拿最新那张。
13
+ ///
14
+ /// 三条纪律:
15
+ /// 1. **只在有人看时跑**。每次取帧记一次时间戳,`idleStopSeconds` 内没人要就自停 —— 采集是有
16
+ /// 功耗的,不能因为 agent 点过一次窗口就永远在后台录屏。
17
+ /// 2. **绝不阻塞调用线程**。开流是 async 的;首帧到达前 `latestFrame` 返回 nil,由调用方回落到
18
+ /// 单帧截图(所以第一帧照样立刻有画面,之后才切到流)。
19
+ /// 3. **换窗口 / 窗口改尺寸就重开**。SCStream 的输出尺寸在 config 里定死,窗口拉大后不重开只会
20
+ /// 一直拿到糊的那张。
21
+ ///
22
+ /// 静止的窗口不会有新帧(系统只在内容变化时推 `.complete`),这是**正常**的:不做"帧太旧就作废",
23
+ /// 否则一个没人动的窗口会让我们每一帧都退回昂贵的单帧截图。流断了由 `didStopWithError` 清状态。
24
+ @available(macOS 14.0, *)
25
+ final class LiveWindowStream: NSObject, SCStreamOutput, SCStreamDelegate {
26
+ static let shared = LiveWindowStream()
27
+
28
+ /// 没人取帧超过这么久就停流。比视图的最慢轮询(隐藏时 6s)宽一点,免得来回起停。
29
+ private let idleStopSeconds: TimeInterval = 9
30
+ /// 窗口尺寸变化超过这么多像素才算漂了。
31
+ private let resizeTolerancePixels: CGFloat = 8
32
+ /// 漂了还要连续稳住这么久才重开(拖动窗口时尺寸一直在变,见 driftedLongEnoughLocked)。
33
+ private let resizeSettleSeconds: TimeInterval = 0.6
34
+ /// 流采纳后这么久还没来过一帧 = 死流,重开。
35
+ private let deadStreamSeconds: TimeInterval = 2.0
36
+ /// 流的推帧上限。再高也没意义:瓶颈在 JPEG 编码 + base64 过 socket,不在采集。
37
+ private let framesPerSecond: Int32 = 20
38
+
39
+ private let lock = NSLock()
40
+ private let sampleQueue = DispatchQueue(label: "com.forsion.tangu-computer-use.livestream")
41
+ private let ciContext = CIContext(options: [.useSoftwareRenderer: false])
42
+
43
+ private var stream: SCStream?
44
+ private var streamWindowId: UInt32 = 0
45
+ private var configuredSize = CGSize.zero
46
+ private var latestImage: CGImage?
47
+ /// 每收到一张新画面 +1。调用方带着上次见过的号来,一样就不必重编一遍 JPEG(静止窗口占绝大多数)。
48
+ private var frameSeq: UInt64 = 0
49
+ private var lastWantedAt = Date.distantPast
50
+ private var starting = false
51
+ private var idleTimer: DispatchSourceTimer?
52
+ /// 流被采纳的时刻 —— 用来发现「开起来了却一帧都不来」的死流(见 latestFrame 里的看门狗)。
53
+ private var adoptedAt = Date.distantPast
54
+ /// 尺寸开始漂移的时刻。拖动窗口边缘时尺寸每帧都在变,立刻重开就会变成重开风暴。
55
+ private var driftSince: Date?
56
+ /// 已知启动后就失败了的流对象 —— 不许再被 adopt 装进来当活流用。
57
+ private var failedStream: SCStream?
58
+
59
+ /// 取这个窗口的最新一帧。没有就返回 nil(调用方回落到单帧截图),同时把流开起来/切过去。
60
+ /// `maxDimension` 只决定流的输出尺寸,缩放交给系统做 —— 比我们截全尺寸再 downscale 便宜得多。
61
+ /// 返回的 `seq` 每来一张新画面 +1;和调用方上次见过的号一样,说明画面没变过。
62
+ func latestFrame(windowId: UInt32, maxDimension: Int) -> (image: CGImage, seq: UInt64)? {
63
+ lock.lock()
64
+ let now = Date()
65
+ lastWantedAt = now
66
+ let sameWindow = streamWindowId == windowId && stream != nil
67
+ let image = sameWindow ? latestImage : nil
68
+ let seq = frameSeq
69
+ let needsStart = !sameWindow && !starting
70
+ // 死流看门狗:流采纳了这么久还一帧都没来 = 它在 startCapture 与 adopt 之间就死了
71
+ // (didStopWithError 那会儿 self.stream 还不是它,所以清不掉),不重开就永远回落单帧截图。
72
+ let deadStream = sameWindow && !starting && latestImage == nil && now.timeIntervalSince(adoptedAt) > deadStreamSeconds
73
+ let needsRestart = sameWindow && !starting && (deadStream || driftedLongEnoughLocked(windowId: windowId, maxDimension: maxDimension, now: now))
74
+ if needsStart || needsRestart { starting = true }
75
+ lock.unlock()
76
+
77
+ if needsStart || needsRestart {
78
+ Task { await self.restart(windowId: windowId, maxDimension: maxDimension) }
79
+ }
80
+ armIdleTimer()
81
+ guard let image else { return nil }
82
+ return (image, seq)
83
+ }
84
+
85
+ /// 尺寸漂了、**而且已经稳住一会儿**才算该重开。lock 必须已持有。
86
+ /// ⚠️去抖不能省:拖动窗口边缘时尺寸每 120ms 一变,一变就重开 = 整个拖动过程都在重开流,
87
+ /// 而重开期间流是断的 → 只能回落单帧截图,恰好在最需要看画面的时候退化成幻灯片。
88
+ private func driftedLongEnoughLocked(windowId: UInt32, maxDimension: Int, now: Date) -> Bool {
89
+ guard let bounds = cgWindowBounds(windowId: windowId) else { driftSince = nil; return false }
90
+ let wanted = Self.outputSize(for: bounds.size, maxDimension: maxDimension)
91
+ guard wanted.width > 0, configuredSize.width > 0 else { driftSince = nil; return false }
92
+ let drifted = abs(wanted.width - configuredSize.width) > resizeTolerancePixels
93
+ || abs(wanted.height - configuredSize.height) > resizeTolerancePixels
94
+ guard drifted else { driftSince = nil; return false }
95
+ guard let since = driftSince else { driftSince = now; return false }
96
+ return now.timeIntervalSince(since) >= resizeSettleSeconds
97
+ }
98
+
99
+ /// 窗口尺寸 → 流的输出尺寸(等比缩到 maxDimension 以内;窗口本来就小就别放大)。
100
+ static func outputSize(for size: CGSize, maxDimension: Int) -> CGSize {
101
+ let longest = max(size.width, size.height)
102
+ guard longest > 0 else { return .zero }
103
+ let limit = CGFloat(max(160, maxDimension))
104
+ let scale = min(1, limit / longest)
105
+ return CGSize(width: max(1, (size.width * scale).rounded()), height: max(1, (size.height * scale).rounded()))
106
+ }
107
+
108
+ /// 单帧兜底:流还没起来(首帧、刚换窗口)时用,**与流同一套输出尺寸**。
109
+ ///
110
+ /// ⚠️不能退回 bridge 的 `captureWindow`:那边不设 `config.width/height`,ScreenCaptureKit 就按
111
+ /// 整块屏幕出图 —— 230×408 的计算器会得到一张 1920×1080、窗口缩在左上角、其余全是白的图。
112
+ /// 用户报的"Agent Desk 里窗口占不满、有留白",有一半就是这么**烤进 JPEG** 里的,CSS 那头再怎么
113
+ /// 适配也救不回来。设了宽高才拿到窗口本身。
114
+ func captureOnce(windowId: UInt32, maxDimension: Int, timeout: TimeInterval = 3) -> CGImage? {
115
+ let semaphore = DispatchSemaphore(value: 0)
116
+ let box = Box<CGImage?>(nil)
117
+ let task = Task {
118
+ defer { semaphore.signal() }
119
+ guard let shareable = try? await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false),
120
+ let window = shareable.windows.first(where: { $0.windowID == windowId })
121
+ else { return }
122
+ let size = Self.outputSize(for: window.frame.size, maxDimension: maxDimension)
123
+ let config = SCStreamConfiguration()
124
+ config.showsCursor = false
125
+ config.ignoreShadowsSingleWindow = true
126
+ config.width = Int(size.width)
127
+ config.height = Int(size.height)
128
+ config.scalesToFit = true
129
+ box.value = try? await SCScreenshotManager.captureImage(
130
+ contentFilter: SCContentFilter(desktopIndependentWindow: window),
131
+ configuration: config
132
+ )
133
+ }
134
+ // 兜底就该干脆:等不到就让调用方走更老的那条路,别把视图的这一帧卡住。
135
+ if semaphore.wait(timeout: .now() + timeout) == .timedOut {
136
+ task.cancel()
137
+ return nil
138
+ }
139
+ return box.value
140
+ }
141
+
142
+ private func restart(windowId: UInt32, maxDimension: Int) async {
143
+ await stopCurrent()
144
+ do {
145
+ let shareable = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
146
+ guard let window = shareable.windows.first(where: { $0.windowID == windowId }) else {
147
+ finishStarting()
148
+ return
149
+ }
150
+ let size = Self.outputSize(for: window.frame.size, maxDimension: maxDimension)
151
+ let config = SCStreamConfiguration()
152
+ config.showsCursor = false
153
+ config.ignoreShadowsSingleWindow = true
154
+ config.width = Int(size.width)
155
+ config.height = Int(size.height)
156
+ config.scalesToFit = true
157
+ config.pixelFormat = kCVPixelFormatType_32BGRA
158
+ config.queueDepth = 3
159
+ config.minimumFrameInterval = CMTime(value: 1, timescale: framesPerSecond)
160
+
161
+ let filter = SCContentFilter(desktopIndependentWindow: window)
162
+ let created = SCStream(filter: filter, configuration: config, delegate: self)
163
+ try created.addStreamOutput(self, type: .screen, sampleHandlerQueue: sampleQueue)
164
+ try await created.startCapture()
165
+ adopt(created, windowId: windowId, size: size)
166
+ } catch {
167
+ finishStarting()
168
+ }
169
+ }
170
+
171
+ // ⚠️下面三个是**同步**的:NSLock 不能跨 await 持有(Swift 6 里直接是编译错误),
172
+ // 所以 async 的 restart/stopCurrent 只调它们,自己一行锁都不碰。
173
+ private func adopt(_ created: SCStream, windowId: UInt32, size: CGSize) {
174
+ lock.lock()
175
+ // startCapture 成功之后、装进来之前就失败的流:didStopWithError 那会儿 self.stream 还不是它,
176
+ // 只能靠这块墓碑拦住 —— 装进来的话它永远不吐帧,而 sameWindow 恒真,再也不会重开。
177
+ if failedStream === created {
178
+ failedStream = nil
179
+ starting = false
180
+ lock.unlock()
181
+ return
182
+ }
183
+ stream = created
184
+ streamWindowId = windowId
185
+ configuredSize = size
186
+ latestImage = nil
187
+ adoptedAt = Date()
188
+ driftSince = nil
189
+ starting = false
190
+ lock.unlock()
191
+ }
192
+
193
+ private func finishStarting() {
194
+ lock.lock()
195
+ starting = false
196
+ lock.unlock()
197
+ }
198
+
199
+ private func detachCurrent() -> SCStream? {
200
+ lock.lock()
201
+ defer { lock.unlock() }
202
+ return detachLocked()
203
+ }
204
+
205
+ /// 只在「确实还闲着」时才摘 —— 定时器判定与真正动手之间隔着一次加锁,那当口可能刚好来了新的取帧请求
206
+ /// (甚至刚采纳了一条新流)。不复核就会把刚要用的流停掉,下一帧只能回落单帧截图。
207
+ private func detachIfStillIdle() -> SCStream? {
208
+ lock.lock()
209
+ defer { lock.unlock() }
210
+ guard stream != nil, Date().timeIntervalSince(lastWantedAt) > idleStopSeconds else { return nil }
211
+ return detachLocked()
212
+ }
213
+
214
+ /// lock 必须已持有。
215
+ private func detachLocked() -> SCStream? {
216
+ let old = stream
217
+ stream = nil
218
+ streamWindowId = 0
219
+ configuredSize = .zero
220
+ latestImage = nil
221
+ adoptedAt = .distantPast
222
+ driftSince = nil
223
+ return old
224
+ }
225
+
226
+ private func stopCurrent() async {
227
+ guard let old = detachCurrent() else { return }
228
+ try? old.removeStreamOutput(self, type: .screen)
229
+ try? await old.stopCapture()
230
+ }
231
+
232
+ /// 没人取帧就自动收摊。定时器常驻(2 秒一跳,几乎不耗)—— 它只在真的该停时才动手。
233
+ private func armIdleTimer() {
234
+ lock.lock()
235
+ defer { lock.unlock() }
236
+ guard idleTimer == nil else { return }
237
+ let timer = DispatchSource.makeTimerSource(queue: sampleQueue)
238
+ timer.schedule(deadline: .now() + 2, repeating: 2)
239
+ timer.setEventHandler { [weak self] in
240
+ guard let self else { return }
241
+ self.lock.lock()
242
+ let maybeIdle = self.stream != nil && Date().timeIntervalSince(self.lastWantedAt) > self.idleStopSeconds
243
+ self.lock.unlock()
244
+ guard maybeIdle else { return }
245
+ Task {
246
+ // ⚠️必须复核(detachIfStillIdle):上面判完到这里动手之间,新的取帧请求可能已经来了。
247
+ guard let old = self.detachIfStillIdle() else { return }
248
+ try? old.removeStreamOutput(self, type: .screen)
249
+ try? await old.stopCapture()
250
+ }
251
+ }
252
+ idleTimer = timer
253
+ timer.resume()
254
+ }
255
+
256
+ // MARK: - SCStreamOutput
257
+
258
+ func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
259
+ guard type == .screen, CMSampleBufferIsValid(sampleBuffer) else { return }
260
+ // 只收 .complete:静止的窗口会推 .idle 空帧,拿它去转图会得到一张全黑。
261
+ guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
262
+ let rawStatus = attachments.first?[.status] as? Int,
263
+ SCFrameStatus(rawValue: rawStatus) == .complete,
264
+ let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
265
+ else { return }
266
+ let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
267
+ guard let image = ciContext.createCGImage(ciImage, from: ciImage.extent) else { return }
268
+ lock.lock()
269
+ // 这一帧属于哪条流?重开期间旧流可能还在吐帧,别把它盖到新窗口上。
270
+ if self.stream === stream {
271
+ latestImage = image
272
+ frameSeq &+= 1
273
+ }
274
+ lock.unlock()
275
+ }
276
+
277
+ // MARK: - SCStreamDelegate
278
+
279
+ func stream(_ stream: SCStream, didStopWithError error: Error) {
280
+ lock.lock()
281
+ if self.stream === stream {
282
+ _ = detachLocked()
283
+ } else {
284
+ // 还没被 adopt 就死了(startCapture 与 adopt 之间)。留个墓碑,别让它被装进来当活流。
285
+ failedStream = stream
286
+ }
287
+ lock.unlock()
288
+ }
289
+ }