@forsion/tangu-computer-use 0.5.8 → 0.6.1

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.
@@ -0,0 +1,832 @@
1
+ import AppKit
2
+ import Foundation
3
+
4
+ // 电脑历史采集器的单测(npm run check:recorder-logic),不需要任何授权。
5
+ // 纯逻辑:差分 / 截断 / 无痕判定 / 浏览器与 bundle 匹配 / 快捷键串 / URL 清洗 / 按订阅者过滤 / 锁屏判定 /
6
+ // 点击归属与点击读取的属性 / 情境事件的去重与无痕窗口 / 断点 resumed / 焦点框变成密码框与角色查询失败(读值闸)/
7
+ // 采样的整次读计时 / 情境刷新时待发输入的去留 /
8
+ // 同标题换站点 / 有上限遍历(假树)/ 无痕扫描计数 / agent 计数 / 订阅写队列。
9
+ // subscriptionStream 走真实的订阅路径:会装上主线程观察者与 NSEvent 全局监听、对前台 App 发 AX 读取
10
+ // (本进程没有辅助功能授权时这些读取都失败、键盘监听收不到东西),只断言事件形状与退订后的拆除状态,绝不打印事件内容。
11
+ @main struct ActivityRecorderTests {
12
+ static func check(_ yes: Bool, _ name: String) {
13
+ precondition(yes, name)
14
+ print("PASS \(name)")
15
+ }
16
+
17
+ static func main() {
18
+ setvbuf(stdout, nil, _IOLBF, 0)
19
+ textDiff()
20
+ truncation()
21
+ privateMarkers()
22
+ urls()
23
+ domains()
24
+ bundles()
25
+ keys()
26
+ filtering()
27
+ privateContextDelivery()
28
+ resumedMarker()
29
+ secureFieldFlip()
30
+ sampleCutoff()
31
+ browsers()
32
+ sessionLock()
33
+ clickOwner()
34
+ pendingOnRefresh()
35
+ sameTitleNavigation()
36
+ boundedWalk()
37
+ scanStats()
38
+ agentGate()
39
+ subscriberQueue()
40
+ subscriptionStream(locked: false)
41
+ subscriptionStream(locked: true)
42
+ }
43
+
44
+ static func textDiff() {
45
+ check(recorderTextDiff(old: "abc", new: "abc") == nil, "identical values produce no diff")
46
+ check(recorderTextDiff(old: "", new: "hello") == RecorderTextDiff(inserted: "hello", deleted: 0), "insert into empty")
47
+ check(recorderTextDiff(old: "hello world", new: "hello brave world") == RecorderTextDiff(inserted: "brave ", deleted: 0), "insert in the middle")
48
+ check(recorderTextDiff(old: "hello world", new: "hello") == RecorderTextDiff(inserted: "", deleted: 6), "pure deletion reports only a count")
49
+ check(recorderTextDiff(old: "cat", new: "cot") == RecorderTextDiff(inserted: "o", deleted: 1), "replacement reports segment and count")
50
+ check(recorderTextDiff(old: "aa", new: "aaa") == RecorderTextDiff(inserted: "a", deleted: 0), "repeated characters never overlap prefix and suffix")
51
+ check(recorderTextDiff(old: "你好", new: "你们好") == RecorderTextDiff(inserted: "们", deleted: 0), "CJK diff is per character")
52
+ check(recorderTextDiff(old: "hi 👋🏽", new: "hi 👋🏽!") == RecorderTextDiff(inserted: "!", deleted: 0), "emoji clusters are never split")
53
+
54
+ // 聊天框:打字 → 回车发送 → 被清空。停手时 final 为空,必须按采样到的 latest 报。
55
+ check(recorderSettleText(baseline: "", latest: "hello", final: "") == RecorderTextDiff(inserted: "hello", deleted: 0), "cleared-after-send falls back to the sampled value")
56
+ check(recorderSettleText(baseline: "draft", latest: "draft more", final: "draft more!") == RecorderTextDiff(inserted: " more!", deleted: 0), "normal typing uses the final value")
57
+ check(recorderSettleText(baseline: "abc", latest: nil, final: "ab") == RecorderTextDiff(inserted: "", deleted: 1), "deletion without samples stays a deletion")
58
+ check(recorderSettleText(baseline: "x", latest: "x", final: "x") == nil, "no change settles to nothing")
59
+
60
+ let fields = recorderTextFields(RecorderTextDiff(inserted: String(repeating: "字", count: 600), deleted: 3))
61
+ check((fields["text"] as? String)?.count == RecorderLimit.text && fields["truncated"] as? Bool == true && fields["deleted"] as? Int == 3, "long insert is capped at 500 with truncated")
62
+ let deletion = recorderTextFields(RecorderTextDiff(inserted: "", deleted: 12))
63
+ check(deletion["text"] == nil && deletion["deleted"] as? Int == 12, "pure deletion emits deleted without text")
64
+ }
65
+
66
+ static func truncation() {
67
+ check(recorderTruncate("abc", max: 5) == ("abc", false), "short strings are untouched")
68
+ check(recorderTruncate("abcdef", max: 3) == ("abc", true), "long strings are cut and flagged")
69
+ check(recorderLabel(" Send\n message ") == "Send message", "labels collapse whitespace and newlines")
70
+ check(recorderLabel(" ") == nil, "blank labels are dropped")
71
+ check(recorderLabel(String(repeating: "x", count: 200))?.count == RecorderLimit.label, "labels are capped at 80")
72
+ check(recorderTitle(String(repeating: "t", count: 300))?.count == RecorderLimit.title, "titles are capped at 200")
73
+ check(recorderFirstNonEmpty(nil, "", "b", "c") == "b", "first non-empty label wins")
74
+ }
75
+
76
+ static func privateMarkers() {
77
+ check(recorderHasPrivateMarker("Example — Private Browsing"), "Firefox private title")
78
+ check(recorderHasPrivateMarker("New Tab - Google Chrome (Incognito)"), "Chrome incognito title")
79
+ check(recorderHasPrivateMarker("[InPrivate] Bing"), "Edge InPrivate title")
80
+ check(recorderHasPrivateMarker("新标签页 - 无痕模式"), "Chinese incognito title")
81
+ check(recorderHasPrivateMarker("隐私浏览"), "Safari Chinese private title")
82
+ check(!recorderHasPrivateMarker("Pull requests · Private repo"), "the bare word private is not a marker")
83
+ check(!recorderHasPrivateMarker("Inbox (3) - Gmail"), "ordinary titles are not private")
84
+ }
85
+
86
+ static func urls() {
87
+ check(recorderSanitizeURL("https://example.com/path?token=abc#frag") == "https://example.com/path", "query and fragment are stripped")
88
+ check(recorderSanitizeURL("https://user:pw@example.com/a") == "https://example.com/a", "credentials are stripped")
89
+ check(recorderSanitizeURL("example.com/docs") == "https://example.com/docs", "scheme-less address bar text gets https")
90
+ check(recorderSanitizeURL("how to cook rice") == nil, "search text is not a URL")
91
+ check(recorderSanitizeURL("file:///Users/me/secret.pdf") == nil, "non-web schemes are rejected")
92
+ check(recorderSanitizeURL("chrome://newtab") == nil, "browser-internal pages are rejected")
93
+ let long = recorderSanitizeURL("https://example.com/" + String(repeating: "p", count: 400))
94
+ check(long?.count == RecorderLimit.url, "URLs are capped at 300")
95
+ check(recorderURLHost("https://Docs.Example.com/x") == "docs.example.com", "host is lowercased")
96
+ check(recorderIsAddressFieldLabel("Address and search bar"), "Chrome address field label")
97
+ check(!recorderIsAddressFieldLabel("Message"), "ordinary field is not the address bar")
98
+ }
99
+
100
+ static func domains() {
101
+ check(recorderNormalizeDomain("https://www.Example.com/path") == "www.example.com", "scheme and path are removed")
102
+ check(recorderNormalizeDomain("*.example.com") == "example.com", "leading wildcard is removed")
103
+ check(recorderNormalizeDomain(".example.com.") == "example.com", "leading and trailing dots are removed")
104
+ check(recorderNormalizeDomain("example.com:8443") == "example.com", "port is removed")
105
+ check(recorderNormalizeDomain(" ") == nil, "blank domain is rejected")
106
+ check(recorderHostMatches("example.com", domain: "example.com"), "exact host matches")
107
+ check(recorderHostMatches("mail.example.com", domain: "example.com"), "subdomain matches")
108
+ check(recorderHostMatches("MAIL.example.com.", domain: "example.com"), "case and trailing dot are ignored")
109
+ check(!recorderHostMatches("badexample.com", domain: "example.com"), "suffix without a dot boundary does not match")
110
+ check(!recorderHostMatches("example.com.evil.net", domain: "example.com"), "prefix does not match")
111
+ }
112
+
113
+ static func bundles() {
114
+ check(recorderBundleMatches("com.forsion.desktop", pattern: "com.forsion.*"), "trailing wildcard matches a prefix")
115
+ check(!recorderBundleMatches("com.forsionx.desktop", pattern: "com.forsion.*"), "wildcard respects the dot boundary")
116
+ check(recorderBundleMatches("com.apple.Terminal", pattern: "com.apple.terminal"), "bundle ids compare case-insensitively")
117
+ check(!recorderBundleMatches("com.apple.TerminalX", pattern: "com.apple.Terminal"), "exact pattern does not prefix-match")
118
+ check(recorderHardExcludedBundleIds.contains("com.1password.1password") && recorderHardExcludedBundleIds.contains("com.apple.keychainaccess"), "password managers are hard-excluded")
119
+ check(recorderIsEditableText(role: "AXTextArea", subrole: "", editable: false), "text areas are editable")
120
+ check(!recorderIsEditableText(role: "AXTextField", subrole: "AXSecureTextField", editable: true), "secure fields are never editable text")
121
+ check(!recorderIsEditableText(role: "AXButton", subrole: "", editable: false), "buttons are not editable text")
122
+ check(recorderClickLabelAllowed(role: "AXButton", subrole: ""), "buttons carry a label")
123
+ check(recorderClickLabelAllowed(role: "AXRadioButton", subrole: "AXTabButton"), "tabs carry a label")
124
+ check(!recorderClickLabelAllowed(role: "AXTextField", subrole: ""), "text fields never carry their value as a label")
125
+ check(!recorderClickAttributes.contains(kAXValueAttribute), "a click never reads the hit element's value")
126
+ check(recorderClickAttributes == [kAXRoleAttribute, kAXSubroleAttribute, kAXTitleAttribute, kAXDescriptionAttribute], "a click reads only role, subrole, title and description")
127
+ }
128
+
129
+ static func keys() {
130
+ check(recorderKeyCombo(command: true, control: false, option: false, shift: true, key: "P") == "⌘⇧P", "command shift combo")
131
+ check(recorderKeyCombo(command: false, control: true, option: false, shift: false, key: "C") == "⌃C", "control combo")
132
+ check(recorderKeyCombo(command: false, control: false, option: true, shift: true, key: "A") == nil, "no command or control means no shortcut")
133
+ check(recorderKeyName(keyCode: 0, characters: "a") == "A", "letters are uppercased")
134
+ check(recorderKeyName(keyCode: 36, characters: "\r") == "↩", "return has a symbol")
135
+ check(recorderKeyName(keyCode: 122, characters: "\u{F704}") == "F1", "function keys are named")
136
+ check(recorderKeyName(keyCode: 8, characters: "\u{3}") == nil, "control characters are not key names")
137
+ }
138
+
139
+ static func filtering() {
140
+ let chrome = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome", title: "Docs", url: "https://docs.example.com/a")
141
+ let open = RecorderPolicy()
142
+ let full = recorderEventBody(kind: .text, context: chrome, policy: open)
143
+ check(full?["title"] as? String == "Docs" && full?["url"] as? String == "https://docs.example.com/a", "default policy keeps title and url")
144
+
145
+ let domainOff = RecorderPolicy(json: ["excludeDomains": ["https://example.com/"]])
146
+ let switched = recorderEventBody(kind: .app, context: chrome, policy: domainOff)
147
+ check((switched?["app"] as? [String: Any])?["excluded"] as? Bool == true && switched?["title"] == nil && switched?["url"] == nil, "excluded domain keeps only an excluded app marker")
148
+ check(recorderEventBody(kind: .text, context: chrome, policy: domainOff) == nil, "excluded domain drops text")
149
+ check(recorderEventBody(kind: .click, context: chrome, policy: domainOff) == nil, "excluded domain drops clicks")
150
+
151
+ // 排除 App:只有切到它的那一条 app 事件(不带标题),App 里的窗口变化一概不发。
152
+ let appOff = RecorderPolicy(json: ["excludeBundleIds": ["com.google.Chrome"]])
153
+ let appSwitch = recorderEventBody(kind: .app, context: chrome, policy: appOff)
154
+ check((appSwitch?["app"] as? [String: Any])?["excluded"] as? Bool == true && appSwitch?["title"] == nil && appSwitch?["url"] == nil, "excluded app keeps only an untitled app switch")
155
+ check(recorderEventBody(kind: .window, context: chrome, policy: appOff) == nil, "excluded app emits no window events")
156
+ check(recorderEventBody(kind: .key, context: chrome, policy: appOff) == nil, "excluded app drops keys")
157
+ // 排除站点:还留一条不带标题 / 网址的 window 标记(结束同一浏览器里上一页的计时)。
158
+ let domainMarker = recorderEventBody(kind: .window, context: chrome, policy: domainOff)
159
+ check((domainMarker?["app"] as? [String: Any])?["excluded"] as? Bool == true && domainMarker?["title"] == nil && domainMarker?["url"] == nil, "excluded domain keeps a content-free window marker")
160
+
161
+ let terminal = RecorderContext(name: "Terminal", bundleId: "com.apple.Terminal", title: "zsh")
162
+ let titleOnly = RecorderPolicy(json: ["titleOnlyBundleIds": ["com.apple.Terminal", "com.forsion.*"]])
163
+ check(recorderEventBody(kind: .window, context: terminal, policy: titleOnly)?["title"] as? String == "zsh", "title-only app keeps its title")
164
+ check(recorderEventBody(kind: .text, context: terminal, policy: titleOnly) == nil, "title-only app drops text")
165
+ let forsion = RecorderContext(name: "Forsion", bundleId: "com.forsion.desktop", title: "Chat")
166
+ check(recorderEventBody(kind: .click, context: forsion, policy: titleOnly) == nil, "wildcard title-only covers Forsion")
167
+
168
+ var incognito = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome")
169
+ incognito.isPrivate = true
170
+ let privateApp = recorderEventBody(kind: .app, context: incognito, policy: open)
171
+ check(privateApp != nil && privateApp?["title"] == nil && (privateApp?["app"] as? [String: Any])?["excluded"] == nil, "private window keeps an untitled switch")
172
+ check(recorderEventBody(kind: .text, context: incognito, policy: open) == nil, "private window drops text")
173
+ check(recorderEventBody(kind: .window, context: incognito, policy: open) == nil, "private window emits no window events")
174
+
175
+ var keychain = RecorderContext(name: "Keychain Access", bundleId: "com.apple.keychainaccess")
176
+ keychain.hardExcluded = true
177
+ check((recorderEventBody(kind: .app, context: keychain, policy: open)?["app"] as? [String: Any])?["excluded"] as? Bool == true, "hard exclusion cannot be overridden by an open policy")
178
+ check(recorderEventBody(kind: .window, context: keychain, policy: open) == nil, "hard-excluded app emits no window events")
179
+
180
+ let noText = RecorderPolicy(json: ["text": false, "clicks": true, "keys": false])
181
+ check(recorderEventBody(kind: .text, context: chrome, policy: noText) == nil, "text switch off drops text")
182
+ check(recorderEventBody(kind: .key, context: chrome, policy: noText) == nil, "keys switch off drops keys")
183
+ check(recorderEventBody(kind: .click, context: chrome, policy: noText) != nil, "clicks stay on")
184
+ check(recorderEventBody(kind: .system, context: nil, policy: open)?["kind"] as? String == "system", "system events pass every policy")
185
+
186
+ // 真实路径的策略来自 JSONSerialization(布尔是 __NSCFBoolean,不是 Swift Bool)。
187
+ let wire = #"{"text":false,"clicks":true,"excludeBundleIds":["a.b"],"excludeDomains":["Example.com"]}"#
188
+ let parsed = RecorderPolicy(json: try? JSONSerialization.jsonObject(with: Data(wire.utf8)))
189
+ check(parsed.text == false && parsed.clicks && parsed.keys && parsed.excludeBundleIds == ["a.b"] && parsed.excludeDomains == ["example.com"], "policy parses real JSON booleans and lists")
190
+
191
+ let garbage = RecorderPolicy(json: ["excludeBundleIds": "not-a-list", "text": "yes"])
192
+ check(garbage.excludeBundleIds.isEmpty && garbage.text, "malformed policy falls back to defaults")
193
+
194
+ let a = recorderContextKey(recorderEventBody(kind: .app, context: chrome, policy: open)!)
195
+ let b = recorderContextKey(recorderEventBody(kind: .window, context: chrome, policy: open)!)
196
+ var retitled = chrome
197
+ retitled.title = "Other"
198
+ check(a == b && a != recorderContextKey(recorderEventBody(kind: .window, context: retitled, policy: open)!), "context key dedupes identical contexts only")
199
+
200
+ // 浏览器网址没读到:设了排除站点的订阅者按排除处理(不带标题),没设的照常。
201
+ var unknown = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome.beta", title: "Chase — Accounts")
202
+ unknown.urlUnknown = true
203
+ let failClosed = recorderEventBody(kind: .window, context: unknown, policy: domainOff)
204
+ check((failClosed?["app"] as? [String: Any])?["excluded"] as? Bool == true && failClosed?["title"] == nil, "unknown browser URL fails closed when sites are excluded")
205
+ check(recorderEventBody(kind: .text, context: unknown, policy: domainOff) == nil && recorderEventBody(kind: .click, context: unknown, policy: domainOff) == nil && recorderEventBody(kind: .key, context: unknown, policy: domainOff) == nil, "unknown browser URL drops text, clicks and keys when sites are excluded")
206
+ check(recorderEventBody(kind: .window, context: unknown, policy: open)?["title"] as? String == "Chase — Accounts", "unknown browser URL is recorded when no site is excluded")
207
+ let newTab = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome", title: "New Tab")
208
+ check(recorderEventBody(kind: .window, context: newTab, policy: domainOff)?["title"] as? String == "New Tab", "a resolved page without a web URL is not treated as excluded")
209
+ }
210
+
211
+ /// 情境事件对一个订阅者的去重(emit 用的就是这个函数):无痕窗口不发 window 事件,但同一浏览器里切回普通窗口那条照常发出。
212
+ static func privateContextDelivery() {
213
+ let docs = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome", title: "Docs", url: "https://docs.example.com/a")
214
+ let mail = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome", title: "Inbox", url: "https://mail.example.com/")
215
+ var incognito = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome")
216
+ incognito.isPrivate = true
217
+ var state = RecorderDeliveryState()
218
+ func deliver(_ kind: RecorderKind, _ context: RecorderContext, dedupe: Bool, policy: RecorderPolicy = RecorderPolicy()) -> [String: Any]? {
219
+ recorderContextDelivery(kind: kind, context: context, policy: policy, state: &state, dedupe: dedupe)
220
+ }
221
+ check(deliver(.app, docs, dedupe: false)?["title"] as? String == "Docs", "switching into the browser sends a titled app event")
222
+ check(deliver(.window, docs, dedupe: true) == nil, "an identical window event is deduped")
223
+ // 普通 → 无痕 → 普通(同一个浏览器、切回同一个普通窗口)。
224
+ check(deliver(.window, incognito, dedupe: true) == nil, "entering a private window in the same browser sends no window event")
225
+ check(deliver(.window, incognito, dedupe: true) == nil, "staying in private windows sends nothing")
226
+ let back = deliver(.window, docs, dedupe: true)
227
+ check(back?["kind"] as? String == "window" && back?["title"] as? String == "Docs" && back?["url"] as? String == "https://docs.example.com/a", "returning to the same normal window is sent again, not deduped")
228
+ check(deliver(.window, docs, dedupe: true) == nil, "after returning, identical window events are deduped again")
229
+ // 还在无痕窗口时同一 App 重新激活(activated 的 samePid 路径,带去重):不再补一条切换。
230
+ _ = deliver(.window, incognito, dedupe: true)
231
+ check(deliver(.app, incognito, dedupe: true) == nil, "re-activating the browser while still private sends no second switch")
232
+ check(deliver(.window, docs, dedupe: true)?["title"] as? String == "Docs", "after that re-activation, returning to the normal window is still sent")
233
+ // 普通 → 无痕 → 另一个普通窗口。
234
+ _ = deliver(.window, incognito, dedupe: true)
235
+ check(deliver(.window, mail, dedupe: true)?["title"] as? String == "Inbox", "leaving a private window for another normal window is sent")
236
+ // 从别的 App 切进无痕窗口:只有一条不带标题的 app 切换。
237
+ state = RecorderDeliveryState(lastKey: "com.apple.Terminal")
238
+ let entered = deliver(.app, incognito, dedupe: false)
239
+ check(entered?["kind"] as? String == "app" && entered?["title"] == nil && entered?["url"] == nil, "switching into a private window from another app sends one untitled switch")
240
+ // 排除 App:切进来那条之后,里面的窗口变化不发,键也不变。
241
+ let appOff = RecorderPolicy(json: ["excludeBundleIds": ["com.google.Chrome"]])
242
+ state = RecorderDeliveryState()
243
+ check(deliver(.app, docs, dedupe: false, policy: appOff) != nil, "an excluded app still gets its untitled switch")
244
+ let excludedKey = state.lastKey
245
+ check(deliver(.window, mail, dedupe: true, policy: appOff) == nil && state.lastKey == excludedKey, "window changes inside an excluded app send nothing and keep the key")
246
+ }
247
+
248
+ /// 断点标记 resumed(合同见 Genesis desktop/shared/computerHistory.ts):没记的情境之后第一条可记录的情境事件带它,
249
+ /// 只此一条;不带那段的任何时间或内容;按订阅者各自的策略判断。
250
+ static func resumedMarker() {
251
+ let docs = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome", title: "Docs", url: "https://docs.example.com/a")
252
+ let docsB = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome", title: "Docs", url: "https://docs.example.com/b")
253
+ let bank = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome", title: "Accounts", url: "https://mybank.com/accounts")
254
+ let terminal = RecorderContext(name: "Terminal", bundleId: "com.apple.Terminal", title: "zsh")
255
+ var incognito = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome")
256
+ incognito.isPrivate = true
257
+ let open = RecorderPolicy()
258
+ let siteOff = RecorderPolicy(json: ["excludeDomains": ["mybank.com"]])
259
+ let appOff = RecorderPolicy(json: ["excludeBundleIds": ["com.google.Chrome"]])
260
+ var state = RecorderDeliveryState()
261
+ func deliver(_ kind: RecorderKind, _ context: RecorderContext, dedupe: Bool = true, policy: RecorderPolicy = RecorderPolicy()) -> [String: Any]? {
262
+ recorderContextDelivery(kind: kind, context: context, policy: policy, state: &state, dedupe: dedupe)
263
+ }
264
+ func resumed(_ body: [String: Any]?) -> Bool { body?["resumed"] as? Bool == true }
265
+
266
+ check(recorderContextRecorded(docs, policy: open) && !recorderContextRecorded(incognito, policy: open), "a normal window is recorded, a private one is not")
267
+ check(!recorderContextRecorded(bank, policy: siteOff) && recorderContextRecorded(bank, policy: open), "an excluded site is unrecorded only for the subscriber that excludes it")
268
+ check(!recorderContextRecorded(docs, policy: appOff), "an excluded app is unrecorded")
269
+
270
+ // 普通 → 无痕 → 同一个普通窗口。
271
+ check(!resumed(deliver(.app, docs, dedupe: false)), "the first recorded context carries no resumed marker")
272
+ _ = deliver(.window, incognito)
273
+ let back = deliver(.window, docs)
274
+ check(resumed(back) && back?["title"] as? String == "Docs", "returning from a private window to the same normal window carries resumed")
275
+ check(Set(back?.keys.map { $0 } ?? []) == ["kind", "app", "title", "url", "resumed"], "the resumed event carries only its own context, nothing about the private stretch")
276
+ check(!resumed(deliver(.window, docsB)), "only the first recorded event after the stretch carries resumed")
277
+ // 普通 → 无痕 → 另一个普通窗口(同标题不同网址:折叠方只按同键判断会把无痕那段并进去)。
278
+ _ = deliver(.window, incognito)
279
+ check(resumed(deliver(.window, docs)), "a same-title window with another URL after a private window carries resumed")
280
+ // 还在无痕窗口时同一 App 重新激活(去重掉)也不清标记。
281
+ _ = deliver(.window, incognito)
282
+ check(deliver(.app, incognito) == nil, "re-activating while private is still deduped")
283
+ check(resumed(deliver(.window, docsB)), "after a deduped re-activation the return still carries resumed")
284
+ // 从别的 App 切进无痕窗口:那条不带标题的切换本身不是可记录的情境,不带 resumed;之后切回别的 App 带。
285
+ _ = deliver(.app, terminal, dedupe: false)
286
+ let entered = deliver(.app, incognito, dedupe: false)
287
+ check(entered != nil && !resumed(entered), "the untitled switch into a private window is not a resumed event")
288
+ check(resumed(deliver(.app, terminal, dedupe: false)), "switching from a private window to another app carries resumed")
289
+
290
+ // 普通 → 排除站点 → 普通:排除标记不带,回来那条带。
291
+ state = RecorderDeliveryState()
292
+ _ = deliver(.app, docs, dedupe: false, policy: siteOff)
293
+ let marker = deliver(.window, bank, policy: siteOff)
294
+ check((marker?["app"] as? [String: Any])?["excluded"] as? Bool == true && !resumed(marker), "the excluded-site marker is not a resumed event")
295
+ check(resumed(deliver(.window, docs, policy: siteOff)), "returning from an excluded site carries resumed")
296
+ // 同一串事件,不排除这个站点的订阅者:银行页照常记,不带 resumed。
297
+ state = RecorderDeliveryState()
298
+ _ = deliver(.app, docs, dedupe: false, policy: open)
299
+ check(!resumed(deliver(.window, bank, policy: open)) && !resumed(deliver(.window, docs, policy: open)), "a subscriber that records the site never sees resumed")
300
+
301
+ // 排除 App → 别的 App:切进排除 App 那条已标出开头,之后那条照样带(合同:没记之后的第一条)。
302
+ state = RecorderDeliveryState()
303
+ _ = deliver(.app, terminal, dedupe: false, policy: appOff)
304
+ let excluded = deliver(.app, docs, dedupe: false, policy: appOff)
305
+ check((excluded?["app"] as? [String: Any])?["excluded"] as? Bool == true && !resumed(excluded), "the excluded-app switch is not a resumed event")
306
+ _ = deliver(.window, mail(), policy: appOff)
307
+ check(resumed(deliver(.app, terminal, dedupe: false, policy: appOff)), "the first app after an excluded app carries resumed")
308
+
309
+ // 无标题的普通窗口与无痕情境同键:回来那条不能被去重吞掉。
310
+ let untitled = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome")
311
+ state = RecorderDeliveryState()
312
+ _ = deliver(.app, untitled, dedupe: false)
313
+ _ = deliver(.window, incognito)
314
+ let untitledBack = deliver(.window, untitled)
315
+ check(untitledBack != nil && resumed(untitledBack), "an untitled normal window after a private one is sent with resumed, not deduped")
316
+ check(deliver(.window, untitled) == nil, "after that, identical events are deduped again")
317
+ }
318
+
319
+ static func mail() -> RecorderContext {
320
+ RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome", title: "Inbox", url: "https://mail.example.com/")
321
+ }
322
+
323
+ /// 焦点框在读值前被网页原地改成密码框(AX 身份不变):读值闸每次都重查角色 / 子角色与 Secure Input,是就不读值。
324
+ /// 角色 / 子角色查询失败(超时)证明不了不是密码框:也不读值,判成要丢掉待发的 .secure(Codex 第三轮 P1)。
325
+ static func secureFieldFlip() {
326
+ var role: RecorderAXAttr = .value("AXTextField")
327
+ var subrole: RecorderAXAttr = .absent
328
+ var count: Int? = 5
329
+ var secureInput = false
330
+ var probes = 0
331
+ var valueReads = 0
332
+ func read() -> RecorderFieldRead {
333
+ recorderReadField(
334
+ secureInput: { secureInput },
335
+ probe: { probes += 1; return (role, subrole, count) },
336
+ value: { valueReads += 1; return "hello" }
337
+ )
338
+ }
339
+ check(read() == .value("hello") && valueReads == 1, "a plain focused field is read")
340
+ subrole = .value("AXSecureTextField") // 同一个元素,type=text → type=password
341
+ check(read() == .secure && valueReads == 1, "a field that turned into a password field is not read")
342
+ subrole = .absent
343
+ role = .value("AXSecureTextField")
344
+ check(read() == .secure && valueReads == 1, "a secure-role field is not read")
345
+ role = .value("AXTextField")
346
+ secureInput = true
347
+ let probesBefore = probes
348
+ check(read() == .secure && valueReads == 1 && probes == probesBefore, "secure input skips the field without any AX read")
349
+ secureInput = false
350
+ // 子角色查询失败(超时 / App 不应答),角色照样读到 AXTextField:不读值,判成要丢掉待发(调用方 dropSecureField)。
351
+ subrole = .failed
352
+ check(read() == .secure && valueReads == 1, "a failed subrole query with an AXTextField role reads no value and drops pending text")
353
+ subrole = .absent
354
+ role = .failed
355
+ check(read() == .secure && valueReads == 1, "a failed role query reads no value and drops pending text")
356
+ role = .value("AXTextField")
357
+ subrole = .gone
358
+ check(read() == .unreadable && valueReads == 1, "a destroyed field is not read and is not treated as secure")
359
+ role = .absent
360
+ subrole = .absent
361
+ check(read() == .unreadable && valueReads == 1, "a field without a role is not proven plain, so the value is not read")
362
+ role = .value("AXTextField")
363
+ count = RecorderLimit.bigEditChars + 1
364
+ check(read() == .big && valueReads == 1, "an oversized field reports big without reading the value")
365
+ count = nil
366
+ check(read() == .value("hello") && valueReads == 2, "a field that genuinely has no subrole and no character count is still read")
367
+ // 报字前复核(fieldSecure):只有确认是普通框、或元素已销毁,才照常报。
368
+ func recheck(_ role: RecorderAXAttr, _ subrole: RecorderAXAttr, secureInput: Bool = false) -> Bool {
369
+ recorderRecheckDrops(recorderFieldState(secureInput: secureInput, role: role, subrole: subrole))
370
+ }
371
+ check(recheck(.value("AXTextField"), .value("AXSecureTextField")), "the emit-time recheck treats a secure subrole as secure")
372
+ check(recheck(.value("AXTextField"), .absent, secureInput: true), "the emit-time recheck treats secure input as secure")
373
+ check(recheck(.value("AXTextField"), .failed), "the emit-time recheck drops pending text when the subrole query failed")
374
+ check(recheck(.failed, .absent), "the emit-time recheck drops pending text when the role query failed")
375
+ check(recheck(.failed, .value("AXSecureTextField")) && recorderFieldState(secureInput: false, role: .failed, subrole: .value("AXSecureTextField")) == .secure, "a secure subrole wins over a failed role")
376
+ check(!recheck(.gone, .gone) && recorderFieldState(secureInput: false, role: .gone, subrole: .gone) == .unreadable, "the emit-time recheck does not call a destroyed field secure")
377
+ check(!recheck(.value("AXTextArea"), .absent) && recorderFieldState(secureInput: false, role: .value("AXTextArea"), subrole: .absent) == .plain, "an ordinary text area without a subrole is plain")
378
+ // AX 错误码 → 读数。
379
+ check(recorderAXAttr(error: .attributeUnsupported) == .absent && recorderAXAttr(error: .noValue) == .absent, "unsupported and no-value errors mean the attribute is genuinely absent")
380
+ check(recorderAXAttr(error: .invalidUIElement) == .gone, "an invalid element means the field is gone")
381
+ check(recorderAXAttr(error: .cannotComplete) == .failed && recorderAXAttr(error: .failure) == .failed && recorderAXAttr(error: .apiDisabled) == .failed && recorderAXAttr(error: .notImplemented) == .failed, "timeouts and every other error mean the query failed")
382
+ func wrapped(_ error: AXError) -> AnyObject {
383
+ var raw = error.rawValue
384
+ return AXValueCreate(.axError, &raw)!
385
+ }
386
+ check(recorderAXAttr(wrapped(.noValue)) == .absent && recorderAXAttr(wrapped(.attributeUnsupported)) == .absent, "an AXValue-wrapped missing attribute is absent")
387
+ check(recorderAXAttr(wrapped(.cannotComplete)) == .failed, "an AXValue-wrapped timeout is a failed query")
388
+ check(recorderAXAttr(wrapped(.invalidUIElement)) == .gone, "an AXValue-wrapped invalid element is gone")
389
+ check(recorderAXAttr(kCFNull) == .absent, "CFNull is an absent attribute")
390
+ check(recorderAXAttr("AXSecureTextField" as NSString) == .value("AXSecureTextField") && recorderAXAttr("" as NSString) == .absent, "strings are values and the empty string is absent")
391
+ check(recorderAXAttr(NSNumber(value: 1)) == .failed, "an unexpected value type is a failed query")
392
+ }
393
+
394
+ /// 边打边采样的 50ms 上限按整次读算(角色 / 子角色复核 + 值,Codex 第三轮 P2);停手那次照常复核。
395
+ static func sampleCutoff() {
396
+ var now = 0.0
397
+ var probeCost = 0.001
398
+ var valueCost = 0.001
399
+ var subrole: RecorderAXAttr = .absent
400
+ var count: Int?
401
+ var probes = 0
402
+ var valueReads = 0
403
+ var text = "hi"
404
+ func probe() -> (role: RecorderAXAttr, subrole: RecorderAXAttr, count: Int?) {
405
+ probes += 1
406
+ now += probeCost
407
+ return (.value("AXTextField"), subrole, count)
408
+ }
409
+ func value() -> String? { valueReads += 1; now += valueCost; return text }
410
+ func sample() -> (read: RecorderFieldRead, keepSampling: Bool) {
411
+ recorderSampleField(clock: { now }, secureInput: { false }, probe: probe, value: value)
412
+ }
413
+ let fast = sample()
414
+ check(fast.read == .value("hi") && fast.keepSampling, "a fast read keeps sampling")
415
+ probeCost = 0.045
416
+ valueCost = 0.01
417
+ let slow = sample()
418
+ check(slow.read == .value("hi") && !slow.keepSampling, "a slow role recheck with a fast value read stops sampling: the whole read is timed")
419
+ probeCost = 0.3
420
+ subrole = .value("AXSecureTextField")
421
+ let readsBefore = valueReads
422
+ let slowSecure = sample()
423
+ check(slowSecure.read == .secure && !slowSecure.keepSampling && valueReads == readsBefore, "a slow sample still honors the secure recheck and reads no value")
424
+ // 停了采样之后,停手那次读(recorderReadField)照常先复核角色 / 子角色。
425
+ let probesBefore = probes
426
+ check(recorderReadField(secureInput: { false }, probe: probe, value: value) == .secure && probes == probesBefore + 1 && valueReads == readsBefore, "with sampling off, the settle read still rechecks the role and subrole first")
427
+ probeCost = 0.001
428
+ valueCost = 0.001
429
+ subrole = .absent
430
+ text = String(repeating: "x", count: RecorderLimit.sampleChars + 1)
431
+ check(!sample().keepSampling, "text past the sampling size stops sampling")
432
+ text = "hi"
433
+ count = RecorderLimit.bigEditChars + 1
434
+ check(sample().read == .big && !sample().keepSampling, "an oversized field stops sampling")
435
+ count = nil
436
+ probeCost = 0.3
437
+ subrole = .gone
438
+ let slowGone = sample()
439
+ check(slowGone.read == .unreadable && !slowGone.keepSampling, "a slow unreadable sample stops sampling too")
440
+ probeCost = 0.001
441
+ check(sample().keepSampling, "a fast unreadable sample leaves sampling on")
442
+ }
443
+
444
+ static func browsers() {
445
+ for id in ["com.google.Chrome", "com.google.Chrome.beta", "com.google.Chrome.canary", "com.microsoft.edgemac.Dev", "com.brave.Browser.nightly",
446
+ "com.operasoftware.Opera", "com.operasoftware.OperaGX", "company.thebrowser.Browser", "company.thebrowser.dia",
447
+ "org.mozilla.firefox", "org.mozilla.firefoxdeveloperedition", "app.zen-browser.zen", "com.kagi.kfmac",
448
+ "com.duckduckgo.macos.browser", "com.apple.SafariTechnologyPreview"] {
449
+ check(recorderIsBrowser(id), "\(id) is a browser")
450
+ }
451
+ for id in ["org.mozilla.thunderbird", "com.google.chromeremotedesktop", "com.apple.Terminal", "com.operasoftwarex.app"] {
452
+ check(!recorderIsBrowser(id), "\(id) is not a browser")
453
+ }
454
+ check(recorderPrivateUnverified("com.apple.Safari") && recorderPrivateUnverified("com.apple.SafariTechnologyPreview"), "Safari windows are treated as private until detection is verified")
455
+ check(!recorderPrivateUnverified("com.google.Chrome"), "Chrome relies on title and toolbar markers")
456
+
457
+ check(recorderWebAreaURL("https://example.com/a?q=1") == ("https://example.com/a", true), "web URL resolves and is sanitized")
458
+ check(recorderWebAreaURL("chrome://newtab/") == (nil, true) && recorderWebAreaURL("about:blank") == (nil, true), "browser-internal pages resolve without a URL")
459
+ check(recorderWebAreaURL("view-source:https://mybank.com/") == (nil, false), "wrapped URLs do not count as resolved")
460
+ check(recorderWebAreaURL("blob:https://mybank.com/123") == (nil, false), "blob URLs do not count as resolved")
461
+ check(recorderWebAreaURL("") == (nil, false) && recorderWebAreaURL(nil) == (nil, false), "missing URL is unresolved")
462
+ }
463
+
464
+ static func sessionLock() {
465
+ check(!recorderSessionLocked(nil), "no session dictionary means not locked")
466
+ check(!recorderSessionLocked(["kCGSSessionOnConsoleKey": true]), "an active console session is not locked")
467
+ check(recorderSessionLocked(["CGSSessionScreenIsLocked": true, "kCGSSessionOnConsoleKey": true]), "a locked screen is locked")
468
+ check(recorderSessionLocked(["kCGSSessionOnConsoleKey": false]), "a switched-away session counts as locked")
469
+ // 真实字典里是 __NSCFBoolean。
470
+ let wire = try? JSONSerialization.jsonObject(with: Data(#"{"CGSSessionScreenIsLocked":false,"kCGSSessionOnConsoleKey":true}"#.utf8)) as? [String: Any]
471
+ check(!recorderSessionLocked(wire), "real boolean values parse")
472
+ }
473
+
474
+ static func clickOwner() {
475
+ func window(_ pid: pid_t, _ rect: CGRect, alpha: Double = 1) -> [String: Any] {
476
+ [kCGWindowOwnerPID as String: NSNumber(value: pid), kCGWindowAlpha as String: NSNumber(value: alpha),
477
+ kCGWindowBounds as String: rect.dictionaryRepresentation as NSDictionary as! [String: Any]]
478
+ }
479
+ let helper: pid_t = 10, xcode: pid_t = 20, spotlight: pid_t = 30, tint: pid_t = 40
480
+ let screen = CGRect(x: 0, y: 0, width: 1512, height: 982)
481
+ // 从前往后:helper 自己的全屏光标浮层、全透明窗口、Spotlight 面板、被观察 App 的最大化窗口。
482
+ let windows = [
483
+ window(helper, screen),
484
+ window(tint, screen, alpha: 0),
485
+ window(spotlight, CGRect(x: 456, y: 200, width: 600, height: 60)),
486
+ window(xcode, screen),
487
+ ]
488
+ check(recorderWindowOwner(at: CGPoint(x: 700, y: 220), windows: windows, skipPid: helper) == spotlight, "a click on a non-activating panel belongs to the panel")
489
+ check(recorderWindowOwner(at: CGPoint(x: 700, y: 600), windows: windows, skipPid: helper) == xcode, "own overlays and transparent windows are skipped")
490
+ check(recorderWindowOwner(at: CGPoint(x: 5000, y: 5000), windows: windows, skipPid: helper) == nil, "a point outside every window has no owner")
491
+ check(recorderWindowOwner(at: CGPoint(x: 10, y: 10), windows: [], skipPid: helper) == nil, "an empty window list has no owner")
492
+ }
493
+
494
+ static func pendingOnRefresh() {
495
+ check(recorderPendingOnRefresh(nextWantsText: false, windowChanged: true, urlChanged: false) == .drop, "new private or excluded window drops pending text")
496
+ check(recorderPendingOnRefresh(nextWantsText: false, windowChanged: false, urlChanged: false) == .drop, "same window turning excluded drops pending text")
497
+ check(recorderPendingOnRefresh(nextWantsText: true, windowChanged: true, urlChanged: false) == .flush, "an ordinary window change flushes pending text")
498
+ check(recorderPendingOnRefresh(nextWantsText: true, windowChanged: false, urlChanged: false) == .keep, "a title change keeps pending text")
499
+ check(recorderPendingOnRefresh(nextWantsText: true, windowChanged: false, urlChanged: true) == .flush, "a URL change in the same window flushes pending text like a window change")
500
+ check(recorderPendingOnRefresh(nextWantsText: false, windowChanged: false, urlChanged: true) == .drop, "a URL change onto an excluded site drops pending text")
501
+ }
502
+
503
+ /// Codex #2:同一窗口、同一标题,网址换到了排除站点(单页应用 / 同名登录页)。窗口与快路句柄只当不透明的身份用
504
+ /// (AXUIElementCreateApplication 造的,不会被读),读数全是注入的 —— 不需要授权,也不碰任何真实 App。
505
+ static func sameTitleNavigation() {
506
+ let window = AXUIElementCreateApplication(900_001)
507
+ let container = AXUIElementCreateApplication(900_002)
508
+ var live: (url: String?, resolved: Bool) = ("https://news.example.org/story?id=7", true)
509
+ var walkResult = RecorderBrowserURLs.Walked(url: nil, resolved: false, isPrivate: false, webContainer: container, addressField: nil)
510
+ var walks = 0
511
+ var fastReads = 0
512
+ var clock = 0.0
513
+ let urls = RecorderBrowserURLs(
514
+ fastRead: { webContainer, _ in
515
+ fastReads += 1
516
+ guard webContainer != nil else { return (nil, false) }
517
+ return (live.url.flatMap(recorderSanitizeURL), live.resolved)
518
+ },
519
+ walk: { _ in
520
+ walks += 1
521
+ var result = walkResult
522
+ result.url = live.url.flatMap(recorderSanitizeURL)
523
+ result.resolved = live.resolved
524
+ return result
525
+ },
526
+ now: { clock }
527
+ )
528
+ let first = urls.lookup(window: window, title: "Sign in")
529
+ check(first.url == "https://news.example.org/story" && first.resolved && walks == 1, "first sight of a browser window walks once")
530
+ var context = RecorderContext(name: "Google Chrome", bundleId: "com.google.Chrome", title: "Sign in", url: first.url)
531
+ check(!recorderURLChanged(context, fresh: urls.fastURL(window: window)), "an unchanged URL does not make the context stale")
532
+
533
+ live = ("https://login.bank.com/", true) // 同标题,换了站点
534
+ check(recorderURLChanged(context, fresh: urls.fastURL(window: window)), "same title on another host makes the context stale")
535
+ let second = urls.lookup(window: window, title: "Sign in")
536
+ check(second.url == "https://login.bank.com/" && second.resolved && walks == 1, "a same-title lookup re-reads the URL through the remembered fast path instead of reusing the cached one")
537
+ context.url = second.url
538
+ let policy = RecorderPolicy(json: ["excludeDomains": ["bank.com"]])
539
+ let nextWantsText = recorderEventBody(kind: .text, context: context, policy: policy) != nil
540
+ check(!nextWantsText, "the excluded host drops text")
541
+ check(recorderEventBody(kind: .click, context: context, policy: policy) == nil && recorderEventBody(kind: .key, context: context, policy: policy) == nil, "the excluded host drops clicks and keys")
542
+ check(recorderPendingOnRefresh(nextWantsText: nextWantsText, windowChanged: false, urlChanged: true) == .drop, "text pending from before the hop is dropped")
543
+ let marker = recorderEventBody(kind: .window, context: context, policy: policy)
544
+ check((marker?["app"] as? [String: Any])?["excluded"] as? Bool == true && marker?["url"] == nil, "the hop is marked by a content-free window event")
545
+
546
+ // 页面在加载 / 网页区域没了:快路读不到。情境里读到过网址 → 算变;同标题不走遍历,按没读到返回(失败即关闭)。
547
+ live = (nil, false)
548
+ check(recorderURLChanged(context, fresh: urls.fastURL(window: window)), "losing the URL counts as a change")
549
+ let third = urls.lookup(window: window, title: "Sign in")
550
+ check(!third.resolved && third.url == nil && walks == 1, "a same-title lookup that cannot read the URL fails closed without walking")
551
+ context.url = nil
552
+ context.urlUnknown = true
553
+ check(recorderEventBody(kind: .text, context: context, policy: policy) == nil, "an unknown URL still drops text for a subscriber that excludes sites")
554
+
555
+ // 标题变了而快路读不到 → 走一次遍历(有快路时不受 30 秒限制)。
556
+ live = ("https://news.example.org/next", true)
557
+ walkResult.webContainer = nil // 这次遍历什么句柄都没找到
558
+ let fastBefore = fastReads
559
+ _ = urls.lookup(window: window, title: "Next story")
560
+ check(walks == 1 && fastReads == fastBefore + 1, "a new title first tries the fast path")
561
+ live = (nil, false)
562
+ _ = urls.lookup(window: window, title: "Another story")
563
+ check(walks == 2, "a new title with an unreadable fast path walks again")
564
+ check(urls.fastURL(window: window) == nil, "a walk that finds nothing leaves no fast path, so refreshIfStale has no verdict")
565
+ _ = urls.lookup(window: window, title: "Yet another")
566
+ check(walks == 2, "a fruitless walk is not repeated within 30 seconds")
567
+ clock += RecorderBrowserURLs.rewalkAfter + 1
568
+ _ = urls.lookup(window: window, title: "Much later")
569
+ check(walks == 3, "after 30 seconds a new title walks again")
570
+
571
+ let other = AXUIElementCreateApplication(900_003)
572
+ check(urls.fastURL(window: other) == nil, "an unseen window has no fast path")
573
+ urls.removeAll()
574
+ check(urls.fastURL(window: window) == nil, "removeAll forgets every window")
575
+ }
576
+
577
+ /// 有上限的遍历(Codex #4 / #3):假树 + 计数。先读角色再读子节点,绝不向网页区域要子节点;无痕扫描不完整时如实报原因。
578
+ final class FakeNode {
579
+ let role: String
580
+ let label: String
581
+ let url: String?
582
+ let value: String?
583
+ let children: [FakeNode]
584
+ init(_ role: String, _ label: String = "", url: String? = nil, value: String? = nil, children: [FakeNode] = []) {
585
+ self.role = role
586
+ self.label = label
587
+ self.url = url
588
+ self.value = value
589
+ self.children = children
590
+ }
591
+ }
592
+
593
+ struct WalkTrace {
594
+ var described: [ObjectIdentifier] = []
595
+ var childrenAsked: [FakeNode] = []
596
+ var childrenBeforeDescribe = 0
597
+ var webURLReads = 0
598
+ }
599
+
600
+ static func walk(_ root: FakeNode, nodeCap: Int = 400, depthCap: Int = 12, expireAfter: Int = .max) -> (RecorderWalkResult<FakeNode>, WalkTrace) {
601
+ var trace = WalkTrace()
602
+ var ticks = 0
603
+ let result = recorderBoundedWalk(
604
+ root: root, nodeCap: nodeCap, depthCap: depthCap,
605
+ expired: { ticks += 1; return ticks > expireAfter },
606
+ describe: { node in
607
+ trace.described.append(ObjectIdentifier(node))
608
+ return (node.role, node.label)
609
+ },
610
+ webURL: { node in
611
+ trace.webURLReads += 1
612
+ return recorderWebAreaURL(node.url)
613
+ },
614
+ fieldValue: { $0.value },
615
+ children: { node in
616
+ if !trace.described.contains(ObjectIdentifier(node)) { trace.childrenBeforeDescribe += 1 }
617
+ trace.childrenAsked.append(node)
618
+ return node.children
619
+ }
620
+ )
621
+ return (result, trace)
622
+ }
623
+
624
+ static func boundedWalk() {
625
+ // 窗口:工具栏(地址栏 + 无痕头像按钮)排在网页区域前面;网页区域下面还挂着一大片网页内容。
626
+ let page = FakeNode("AXWebArea", url: "https://docs.example.com/a?q=1", children: (0..<50).map { FakeNode("AXGroup", "content \($0)") })
627
+ let webContainer = FakeNode("AXGroup", children: [page])
628
+ let toolbar = FakeNode("AXToolbar", children: [
629
+ FakeNode("AXTextField", "Address and search bar", value: "docs.example.com/a"),
630
+ FakeNode("AXButton", "Incognito"),
631
+ ])
632
+ let window = FakeNode("AXWindow", children: [toolbar, webContainer])
633
+ let (full, trace) = walk(window)
634
+ check(!trace.childrenAsked.contains { $0.role == "AXWebArea" }, "the walk never asks a web area for its children")
635
+ check(trace.childrenBeforeDescribe == 0, "every node's role is read before its children")
636
+ check(trace.webURLReads == 1, "the web area is asked only for its own URL")
637
+ check(full.url == "https://docs.example.com/a" && full.resolved && full.webContainer === webContainer, "the web area URL resolves and remembers its parent as the fast path")
638
+ check(full.isPrivate && full.addressField?.role == "AXTextField", "toolbar markers and the address field are found on the way")
639
+ check(full.complete && full.stops.isEmpty, "a toolbar ahead of the web area is scanned completely")
640
+ check(full.visited == 6, "the walk visits the window chrome and the web area but none of the page content")
641
+
642
+ // Codex #3 的形状:网页区域比工具栏里的无痕按钮浅。行为暂不改(仍按普通窗口),但如实报「没看完」。
643
+ let deepToolbar = FakeNode("AXToolbar", children: [FakeNode("AXGroup", children: [FakeNode("AXGroup", children: [FakeNode("AXButton", "InPrivate")])])])
644
+ let (early, earlyTrace) = walk(FakeNode("AXWindow", children: [FakeNode("AXGroup", children: [page]), deepToolbar]))
645
+ check(early.resolved && !early.isPrivate, "a private hint deeper than the web area is not reached (behavior unchanged for now)")
646
+ check(!early.complete && early.stops == [.afterWebArea], "stopping after the web area is reported as an incomplete private scan")
647
+ check(!earlyTrace.childrenAsked.contains { $0.role == "AXWebArea" }, "the early stop never asks a web area for its children either")
648
+
649
+ // 网页区域读不到网址 → 地址栏兜底;不因网页区域没读到就往里走。
650
+ let loading = FakeNode("AXWebArea", url: "", children: [FakeNode("AXGroup")])
651
+ let (fallback, fallbackTrace) = walk(FakeNode("AXWindow", children: [FakeNode("AXTextField", "Address", value: "example.org/path?x=1"), FakeNode("AXGroup", children: [loading])]))
652
+ check(fallback.url == "https://example.org/path" && fallback.resolved && fallback.webContainer == nil, "an unreadable web area falls back to the address field")
653
+ check(!fallbackTrace.childrenAsked.contains { $0.role == "AXWebArea" }, "an unresolved web area is not entered either")
654
+ check(fallback.complete, "a fully scanned window without a web URL is complete")
655
+
656
+ // 上限:节点数、深度、时间预算,各自报原因。
657
+ let (capped, _) = walk(FakeNode("AXWindow", children: (0..<20).map { FakeNode("AXGroup", "g\($0)") }), nodeCap: 5)
658
+ check(capped.visited == 5 && capped.stops.contains(.nodeCap) && !capped.complete, "the node cap stops the walk and is reported")
659
+ var chain = FakeNode("AXGroup")
660
+ for _ in 0..<20 { chain = FakeNode("AXGroup", children: [chain]) }
661
+ let (deep, _) = walk(chain, depthCap: 3)
662
+ check(deep.visited == 4 && deep.stops == [.depthCap], "the depth cap stops expansion and is reported")
663
+ let (late, _) = walk(window, expireAfter: 2)
664
+ check(late.visited == 2 && late.stops.contains(.deadline) && !late.resolved, "the time budget stops the walk and is reported")
665
+ }
666
+
667
+ static func scanStats() {
668
+ let stats = RecorderScanStats()
669
+ let empty = stats.snapshot()
670
+ check(empty.walks == 0 && empty.incomplete == 0 && Set(empty.stops.keys) == Set(RecorderWalkStop.allCases.map(\.rawValue)) && empty.stops.values.allSatisfy { $0 == 0 }, "scan stats start at zero with every reason present")
671
+ stats.record([])
672
+ stats.record([.afterWebArea])
673
+ stats.record([.afterWebArea, .deadline])
674
+ let after = stats.snapshot()
675
+ check(after.walks == 3 && after.incomplete == 2, "scan stats count walks and incomplete walks")
676
+ check(after.stops["afterWebArea"] == 2 && after.stops["deadline"] == 1 && after.stops["nodeCap"] == 0 && after.stops["depthCap"] == 0, "scan stats count each stop reason")
677
+ }
678
+
679
+ static func agentGate() {
680
+ var now = 100.0
681
+ let gate = RecorderAgentGate(grace: 0.75, now: { now })
682
+ check(!gate.active(), "idle gate is inactive")
683
+ gate.enter(); gate.enter()
684
+ check(gate.active(), "inside an action the gate is active")
685
+ gate.leave()
686
+ now = 200
687
+ check(gate.active(), "nested action keeps the gate active")
688
+ gate.leave()
689
+ now = 200.5
690
+ check(gate.active(), "grace period covers late AX notifications")
691
+ now = 201
692
+ check(!gate.active(), "gate expires after the grace period")
693
+ gate.leave()
694
+ check(!gate.active(), "extra leave never goes negative")
695
+ }
696
+
697
+ /// 写队列:回包在前、事件按序;对端不读时丢弃并计数,再次能写时先补 dropped;对端关闭 → 写失败 → shutdown。
698
+ static func subscriberQueue() {
699
+ var fds: [Int32] = [0, 0]
700
+ precondition(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0)
701
+ var noSigPipe: Int32 = 1
702
+ _ = setsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout<Int32>.size))
703
+ let subscriber = RecorderSubscriber(fd: fds[0], policy: RecorderPolicy(), maxPending: 4, sendTimeout: 1)
704
+ var small: Int32 = 2048
705
+ _ = setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &small, socklen_t(MemoryLayout<Int32>.size))
706
+ subscriber.send(["id": "r1", "ok": true])
707
+ let padding = String(repeating: "x", count: 1500)
708
+ for index in 0..<200 { subscriber.send(["ev": ["t": index, "kind": "text", "text": padding]]) }
709
+ // 对端开始读:能读到回包、若干事件,以及后续入队时补上的 dropped 标记。
710
+ let reader = FileHandle(fileDescriptor: fds[1], closeOnDealloc: false)
711
+ var received = Data()
712
+ var lines: [[String: Any]] = []
713
+ let deadline = Date().addingTimeInterval(5)
714
+ var sentAfterDrain = false
715
+ while Date() < deadline {
716
+ let chunk = reader.availableData
717
+ if chunk.isEmpty { break }
718
+ received.append(chunk)
719
+ while let newline = received.firstIndex(of: 0x0A) {
720
+ let line = received[received.startIndex..<newline]
721
+ received.removeSubrange(received.startIndex...newline)
722
+ if let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] { lines.append(object) }
723
+ }
724
+ if !sentAfterDrain && lines.count >= 4 {
725
+ sentAfterDrain = true
726
+ subscriber.send(["ev": ["t": 999, "kind": "key", "keys": "⌘S"]])
727
+ }
728
+ if lines.contains(where: { (($0["ev"] as? [String: Any])?["t"] as? Int) == 999 }) { break }
729
+ }
730
+ check(lines.first?["id"] as? String == "r1", "reply is the first line on the connection")
731
+ let dropped = lines.compactMap { ($0["ev"] as? [String: Any]).flatMap { $0["state"] as? String == "dropped" ? $0["count"] as? Int : nil } }.first
732
+ check((dropped ?? 0) > 0, "a stalled reader causes drops that are reported with a count")
733
+ // dropped 标记自带当前时间戳,可能插在任意两条之间;只比事件自己的顺序。
734
+ let times = lines.compactMap { ($0["ev"] as? [String: Any]).flatMap { $0["state"] == nil ? $0["t"] as? Int : nil } }.filter { $0 != 999 }
735
+ check(!times.isEmpty && times == times.sorted(), "delivered events keep their order")
736
+ check(!subscriber.isFailed, "a slow but alive reader does not fail the subscriber")
737
+
738
+ close(fds[1])
739
+ subscriber.send(["ev": ["t": 1000, "kind": "key", "keys": "⌘W"]])
740
+ let failDeadline = Date().addingTimeInterval(3)
741
+ while !subscriber.isFailed && Date() < failDeadline { usleep(20_000) }
742
+ check(subscriber.isFailed, "writing to a closed peer marks the subscriber failed")
743
+ subscriber.close()
744
+ close(fds[0])
745
+ print("PASS subscriber closes after draining")
746
+ }
747
+
748
+ /// 订阅流接线(不需要授权):回包在前、首条是 app 快照(锁屏时是 system/locked)、主线程观察者装上后
749
+ /// sleep / wake 变成 system 事件、退订后注册表归零且观察者 / 全局监听 / 定时器 / 基线全部拆掉。
750
+ /// sleep / wake 只发到本进程的 NSWorkspace 通知中心,不碰系统级分布式通知。
751
+ /// 只断言形状,绝不打印事件内容(跑测试的终端若恰好有辅助功能授权,这里读到的是真实前台窗口)。
752
+ static func subscriptionStream(locked lockedNow: Bool) {
753
+ print("-- subscription stream (session \(lockedNow ? "locked" : "unlocked"))")
754
+ var fds: [Int32] = [0, 0]
755
+ precondition(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0)
756
+ var noSigPipe: Int32 = 1
757
+ _ = setsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout<Int32>.size))
758
+ _ = fcntl(fds[1], F_SETFL, fcntl(fds[1], F_GETFL) | O_NONBLOCK)
759
+ let recorder = ActivityRecorder()
760
+ recorder.sessionLocked = { lockedNow }
761
+ recorder.accessibilityTrusted = { false }
762
+ check(recorder.subscribe(fd: fds[0], id: "x", policy: nil, protocolVersion: 13, browserBundleIds: [])?.code == "accessibility_denied", "untrusted helper refuses to subscribe")
763
+ check(recorder.subscriberCount == 0, "refused subscription registers nothing")
764
+ recorder.accessibilityTrusted = { true }
765
+ check(recorder.subscribe(fd: fds[0], id: "s1", policy: ["titleOnlyBundleIds": ["com.forsion.*"]], protocolVersion: 13, browserBundleIds: []) == nil, "trusted helper subscribes")
766
+ check(recorder.subscriberCount == 1, "subscription is registered")
767
+
768
+ var buffer = Data()
769
+ var lines: [[String: Any]] = []
770
+ func pump(until done: () -> Bool, seconds: Double = 5) {
771
+ let deadline = Date().addingTimeInterval(seconds)
772
+ var chunk = [UInt8](repeating: 0, count: 65_536)
773
+ while !done() && Date() < deadline {
774
+ RunLoop.main.run(until: Date().addingTimeInterval(0.05)) // 主线程观察者靠主队列装上
775
+ let count = read(fds[1], &chunk, chunk.count)
776
+ if count > 0 { buffer.append(contentsOf: chunk[0..<count]) }
777
+ while let newline = buffer.firstIndex(of: 0x0A) {
778
+ let line = buffer[buffer.startIndex..<newline]
779
+ buffer.removeSubrange(buffer.startIndex...newline)
780
+ if let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] { lines.append(object) }
781
+ }
782
+ }
783
+ }
784
+ func events(_ kind: String) -> [[String: Any]] { lines.compactMap { $0["ev"] as? [String: Any] }.filter { $0["kind"] as? String == kind } }
785
+
786
+ // 锁屏时开始订阅:先报 locked,不拍前台。
787
+ let hasFrontmost = NSWorkspace.shared.frontmostApplication != nil && !lockedNow
788
+ pump(until: { lines.count >= (hasFrontmost || lockedNow ? 2 : 1) })
789
+ check((lines.first?["id"] as? String) == "s1" && (lines.first?["result"] as? [String: Any])?["subscribed"] as? Bool == true, "reply is the first line of the stream")
790
+ if lockedNow {
791
+ check(events("system").first?["state"] as? String == "locked" && events("app").isEmpty, "subscribing while locked reports locked instead of a snapshot")
792
+ } else if hasFrontmost {
793
+ let app = events("app").first
794
+ check(app?["t"] is Int && (app?["app"] as? [String: Any])?["name"] is String, "stream starts with an app snapshot")
795
+ }
796
+
797
+ pump(until: { false }, seconds: 0.3) // 让主线程把 NSWorkspace 观察者装上
798
+ let live = recorder.debugState()
799
+ check(live.running && live.mainObservers > 0, "subscribing starts the recorder and installs main-thread observers")
800
+ NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.willSleepNotification, object: NSWorkspace.shared)
801
+ pump(until: { events("system").contains { $0["state"] as? String == "sleep" } })
802
+ check(events("system").contains { $0["state"] as? String == "sleep" }, "sleep becomes a system event")
803
+ let appsBeforeWake = events("app").count
804
+ NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.didWakeNotification, object: NSWorkspace.shared)
805
+ pump(until: { events("system").contains { $0["state"] as? String == "wake" } && (!hasFrontmost || events("app").count > appsBeforeWake) })
806
+ check(events("system").contains { $0["state"] as? String == "wake" }, "wake becomes a system event")
807
+ if hasFrontmost { check(events("app").count > appsBeforeWake, "waking re-snapshots the frontmost app") }
808
+ if lockedNow { check(events("app").count == appsBeforeWake, "waking while still locked takes no snapshot") }
809
+ check(lines.dropFirst().allSatisfy { ($0["ev"] as? [String: Any])?["t"] is Int }, "every streamed line is an ev object")
810
+
811
+ recorder.unsubscribe(fd: fds[0])
812
+ check(recorder.subscriberCount == 0, "unsubscribe empties the registry")
813
+ // 等 recorder 线程跑完 stopIfIdle、主线程拆完观察者(debugState 本身先排空 recorder 线程)。
814
+ var after = recorder.debugState()
815
+ let settle = Date().addingTimeInterval(3)
816
+ while after != ActivityRecorder.DebugState() && Date() < settle {
817
+ RunLoop.main.run(until: Date().addingTimeInterval(0.05))
818
+ after = recorder.debugState()
819
+ }
820
+ check(!after.running && !after.observer && !after.focus && !after.pendingText && !after.timers && after.baselines == 0, "the last subscriber leaving stops the recorder and drops observer, focus, timers and baselines")
821
+ check(after.mainObservers == 0 && !after.mouseMonitor && !after.keyMonitor, "the last subscriber leaving removes notification observers and global monitors")
822
+ let before = lines.count
823
+ let deliveries = recorder.debugMainDeliveries
824
+ check(deliveries > 0, "main-thread observers received the test notifications while subscribed")
825
+ NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.willSleepNotification, object: NSWorkspace.shared)
826
+ pump(until: { false }, seconds: 0.3)
827
+ check(lines.count == before, "nothing is streamed after the last subscriber leaves")
828
+ check(recorder.debugMainDeliveries == deliveries, "removed observers no longer receive notifications")
829
+ close(fds[0])
830
+ close(fds[1])
831
+ }
832
+ }