@forsion/tangu-computer-use 0.6.0 → 0.6.2

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 (35) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/UPSTREAM.md +3 -1
  3. package/icon.png +0 -0
  4. package/main.js +15 -0
  5. package/manifest.json +3 -3
  6. package/native/macos/bridge.swift +171 -7
  7. package/native/windows/bridge-rs/Cargo.lock +1 -0
  8. package/native/windows/bridge-rs/Cargo.toml +11 -0
  9. package/native/windows/bridge-rs/src/agent_marker.rs +97 -0
  10. package/native/windows/bridge-rs/src/lib.rs +4 -0
  11. package/native/windows/bridge-rs/src/main.rs +32 -0
  12. package/native/windows/bridge-rs/src/recorder.rs +1865 -0
  13. package/native/windows/bridge-rs/src/recorder_logic.rs +851 -0
  14. package/native/windows/bridge-rs/tests/recorder_logic_tests.rs +314 -0
  15. package/package.json +3 -2
  16. package/prebuilt/linux/x64/linux-bridge +0 -0
  17. package/prebuilt/macos/arm64/bridge +0 -0
  18. package/prebuilt/macos/arm64/tangu-computer-use.app.json +4 -4
  19. package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
  20. package/prebuilt/macos/x64/bridge +0 -0
  21. package/prebuilt/macos/x64/tangu-computer-use.app.json +4 -4
  22. package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
  23. package/prebuilt/windows/windows-bridge.exe +0 -0
  24. package/scripts/exit-listeners.check.mjs +61 -0
  25. package/scripts/no-foreground.check.mjs +14 -1
  26. package/scripts/probe-recorder-windows.mjs +356 -0
  27. package/skills/computer-use/SKILL.md +4 -0
  28. package/src/foregroundNote.ts +16 -0
  29. package/src/index.ts +17 -5
  30. package/src/tools.ts +4 -3
  31. package/src/vendor/platform/macos/helper.ts +1 -1
  32. package/tangu-plugins/computer-use/dist/foregroundNote.js +10 -1
  33. package/tangu-plugins/computer-use/dist/index.js +25 -11
  34. package/tangu-plugins/computer-use/tangu-plugin.json +1 -1
  35. package/types/tangu-agent.d.ts +60 -1
@@ -0,0 +1,851 @@
1
+ //! Tangu 新增:电脑历史(Computer History)采集器的纯逻辑 —— Windows `serve` 模式的 `recordSubscribe`。
2
+ //!
3
+ //! 与 macOS 的 `native/macos/activity_recorder.swift` 同一份语义(那边的纯函数逐条移植到这里,单测在
4
+ //! `tests/recorder_logic_tests.rs`,在任何平台都能跑);事件形状见 Genesis `desktop/shared/computerHistory.ts`,
5
+ //! 三处同步。平台相关的部分(钩子、UIA、命名管道)在 `recorder.rs`,只在 Windows 编译。
6
+ //!
7
+ //! Windows 上的「bundle id」= 小写的可执行文件名(`chrome.exe`),桌面下发的排除 / 只记标题表按它匹配。
8
+
9
+ use serde_json::{json, Map, Value};
10
+
11
+ /// recordSubscribe 合同的版本号(与 macOS helper 的协议号同一口径:桌面要求 ≥ 13)。
12
+ /// **不是** stdio 协议号(`protocol::PROTOCOL_VERSION`,vendor TS 按 `!==` 校验,不能动)。
13
+ pub const RECORDER_PROTOCOL: u32 = 13;
14
+
15
+ pub mod limit {
16
+ pub const TITLE: usize = 200;
17
+ pub const URL: usize = 300;
18
+ pub const LABEL: usize = 80;
19
+ pub const TEXT: usize = 500;
20
+ /// 超过这个字符数的输入框不做差分,只报 bigEdit。
21
+ pub const BIG_EDIT_CHARS: usize = 20_000;
22
+ /// 不超过这个长度的输入框才边打边采样(聊天框、搜索框);更大的只在停手时读一次。
23
+ pub const SAMPLE_CHARS: usize = 2_000;
24
+ /// 一次采样超过这么久的 App 不再边打边采样。
25
+ pub const SAMPLE_MS: u128 = 50;
26
+ }
27
+
28
+ /// 始终排除:不读标题、不看输入框,只发一条 `app.excluded` 的切换。桌面下发的排除表叠加在这之上,改不掉这份。
29
+ pub const HARD_EXCLUDED_EXES: &[&str] = &[
30
+ "1password.exe",
31
+ "bitwarden.exe",
32
+ "keepass.exe",
33
+ "keepassxc.exe",
34
+ "lastpass.exe",
35
+ "dashlane.exe",
36
+ "nordpass.exe",
37
+ "proton pass.exe",
38
+ "protonpass.exe",
39
+ "enpass.exe",
40
+ "roboform.exe",
41
+ "keeper.exe",
42
+ "keepersecurity.exe",
43
+ // Windows 自己的凭据 / 提权界面
44
+ "credentialuibroker.exe",
45
+ "consent.exe",
46
+ "logonui.exe",
47
+ // 采集器自己(windows-bridge*.exe 另按 pid 判)
48
+ "windows-bridge.exe",
49
+ ];
50
+
51
+ /// 系统浮层:前台切到它们当没发生(不切情境、不发事件),免得时间线被开始菜单 / 搜索 / 通知中心 / 输入法切碎。
52
+ pub const IGNORED_EXES: &[&str] = &[
53
+ "searchhost.exe",
54
+ "searchapp.exe",
55
+ "searchui.exe",
56
+ "startmenuexperiencehost.exe",
57
+ "shellexperiencehost.exe",
58
+ "textinputhost.exe",
59
+ "lockapp.exe",
60
+ ];
61
+
62
+ /// 同上,按前台窗口类名判(任务栏、Alt+Tab 切换器、托盘溢出区都属于 explorer.exe,不能按进程排)。
63
+ pub const IGNORED_CLASSES: &[&str] = &[
64
+ "Shell_TrayWnd",
65
+ "Shell_SecondaryTrayWnd",
66
+ "NotifyIconOverflowWindow",
67
+ "TopLevelWindowForOverflowXamlIsland",
68
+ "MultitaskingViewFrame",
69
+ "XamlExplorerHostIslandWindow",
70
+ "TaskSwitcherWnd",
71
+ "ForegroundStaging",
72
+ ];
73
+
74
+ /// 按浏览器处理(读网址、找无痕提示、排除站点读不到网址就按排除算)的可执行文件。
75
+ pub const BROWSER_EXES: &[&str] = &[
76
+ "chrome.exe",
77
+ "chromium.exe",
78
+ "msedge.exe",
79
+ "brave.exe",
80
+ "vivaldi.exe",
81
+ "opera.exe",
82
+ "opera_gx.exe",
83
+ "arc.exe",
84
+ "thorium.exe",
85
+ "duckduckgo.exe",
86
+ "firefox.exe",
87
+ "librewolf.exe",
88
+ "waterfox.exe",
89
+ "floorp.exe",
90
+ "zen.exe",
91
+ "360se.exe",
92
+ "360chrome.exe",
93
+ "qqbrowser.exe",
94
+ "sogouexplorer.exe",
95
+ "2345explorer.exe",
96
+ "liebao.exe",
97
+ ];
98
+
99
+ /// 无痕 / 私密窗口的标题标记(小写比较,多语言;与 macOS 那份一致)。宁可误判成无痕少记,也不把无痕窗口记进去。
100
+ pub const PRIVATE_MARKERS: &[&str] = &[
101
+ "private browsing",
102
+ "private window",
103
+ "incognito",
104
+ "inprivate",
105
+ "privater modus",
106
+ "navigation privée",
107
+ "navegación privada",
108
+ "navegação privada",
109
+ "navigazione anonima",
110
+ "инкогнито",
111
+ "приватный просмотр",
112
+ "プライベートブラウズ",
113
+ "シークレット",
114
+ "시크릿",
115
+ "无痕",
116
+ "無痕",
117
+ "隐私浏览",
118
+ "隱私瀏覽",
119
+ "隐身",
120
+ "隱身",
121
+ "私密浏览",
122
+ "私密瀏覽",
123
+ ];
124
+
125
+ fn exe_in(list: &[&str], exe: &str) -> bool {
126
+ let exe = exe.to_lowercase();
127
+ list.iter().any(|item| *item == exe)
128
+ }
129
+
130
+ pub fn is_hard_excluded(exe: &str) -> bool {
131
+ exe_in(HARD_EXCLUDED_EXES, exe)
132
+ }
133
+
134
+ pub fn is_ignored(exe: &str, class_name: &str) -> bool {
135
+ exe_in(IGNORED_EXES, exe) || IGNORED_CLASSES.contains(&class_name)
136
+ }
137
+
138
+ pub fn is_browser(exe: &str) -> bool {
139
+ exe_in(BROWSER_EXES, exe)
140
+ }
141
+
142
+ /// 无痕窗口的标题一定带标记的浏览器:Edge 的 InPrivate 窗口标题带「[InPrivate]」,Firefox 系带「Private Browsing」。
143
+ /// 这几家标题没标记就可以信是普通窗口。**Chromium 系(Chrome、Brave 等)在 Windows 上无痕窗口的标题没有任何标记**
144
+ /// (CI 实测:无痕窗口标题就是「Example Domain - Google Chrome」),只能靠工具栏上的无痕提示判定。
145
+ pub const TITLE_MARKS_PRIVATE_EXES: &[&str] = &[
146
+ "msedge.exe",
147
+ "firefox.exe",
148
+ "librewolf.exe",
149
+ "waterfox.exe",
150
+ "floorp.exe",
151
+ "zen.exe",
152
+ ];
153
+
154
+ pub fn title_marks_private(exe: &str) -> bool {
155
+ exe_in(TITLE_MARKS_PRIVATE_EXES, exe)
156
+ }
157
+
158
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
159
+ pub enum PrivateVerdict {
160
+ Private,
161
+ Normal,
162
+ /// 判不了(工具栏遍历没走完):调用方按无痕处理,下次刷新再遍历 —— 失败即关闭。
163
+ Unknown,
164
+ }
165
+
166
+ /// 标题里没有无痕标记的浏览器窗口(有标记的调用方已经直接按无痕处理)算不算无痕:工具栏上找到无痕提示 → 无痕;
167
+ /// 标题可信的浏览器 → 普通;遍历走完了也没找到 → 普通;遍历没走完 → 判不了。
168
+ pub fn private_verdict(title_trusted: bool, hint_found: bool, walk_complete: bool) -> PrivateVerdict {
169
+ if hint_found {
170
+ PrivateVerdict::Private
171
+ } else if title_trusted || walk_complete {
172
+ PrivateVerdict::Normal
173
+ } else {
174
+ PrivateVerdict::Unknown
175
+ }
176
+ }
177
+
178
+ pub fn has_private_marker(text: &str) -> bool {
179
+ let lower = text.to_lowercase();
180
+ PRIVATE_MARKERS.iter().any(|marker| lower.contains(marker))
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // 字符串收敛
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /// 按字符截到 max(不会切出半个汉字);第二项 = 是否截过。
188
+ pub fn truncate(value: &str, max: usize) -> (String, bool) {
189
+ match value.char_indices().nth(max) {
190
+ Some((cut, _)) => (value[..cut].to_owned(), true),
191
+ None => (value.to_owned(), false),
192
+ }
193
+ }
194
+
195
+ fn collapse(raw: &str) -> Option<String> {
196
+ let collapsed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
197
+ (!collapsed.is_empty()).then_some(collapsed)
198
+ }
199
+
200
+ /// 界面标签(控件名):空白与换行折叠成一个空格,≤80;空串 → None。
201
+ pub fn label(raw: &str) -> Option<String> {
202
+ collapse(raw).map(|s| truncate(&s, limit::LABEL).0)
203
+ }
204
+
205
+ /// 窗口标题:同上,≤200。
206
+ pub fn title(raw: &str) -> Option<String> {
207
+ collapse(raw).map(|s| truncate(&s, limit::TITLE).0)
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // 文本差分
212
+ // ---------------------------------------------------------------------------
213
+
214
+ #[derive(Debug, Clone, PartialEq, Eq)]
215
+ pub struct TextDiff {
216
+ /// 新值独有的那段(替换 / 插入);纯删除时为空串。
217
+ pub inserted: String,
218
+ /// 旧值被删掉的字符数。
219
+ pub deleted: usize,
220
+ }
221
+
222
+ /// 两版值剥掉公共前缀 / 后缀,剩下中间那段。相同 → None。按字符比较。
223
+ pub fn text_diff(old: &str, new: &str) -> Option<TextDiff> {
224
+ if old == new {
225
+ return None;
226
+ }
227
+ let before: Vec<char> = old.chars().collect();
228
+ let after: Vec<char> = new.chars().collect();
229
+ let limit = before.len().min(after.len());
230
+ let mut prefix = 0;
231
+ while prefix < limit && before[prefix] == after[prefix] {
232
+ prefix += 1;
233
+ }
234
+ let mut suffix = 0;
235
+ while suffix < limit - prefix
236
+ && before[before.len() - 1 - suffix] == after[after.len() - 1 - suffix]
237
+ {
238
+ suffix += 1;
239
+ }
240
+ Some(TextDiff {
241
+ inserted: after[prefix..after.len() - suffix].iter().collect(),
242
+ deleted: before.len() - prefix - suffix,
243
+ })
244
+ }
245
+
246
+ /// 一段输入停手后该报什么(与 macOS recorderSettleText 一致):正常按 baseline→final;final 相对 baseline 没有新增
247
+ /// (发送后被清空、打完又删回去)而 latest 有,就按 baseline→latest。
248
+ pub fn settle_text(baseline: &str, latest: Option<&str>, final_value: &str) -> Option<TextDiff> {
249
+ let direct = text_diff(baseline, final_value);
250
+ if let Some(diff) = &direct {
251
+ if !diff.inserted.is_empty() {
252
+ return direct;
253
+ }
254
+ }
255
+ if let Some(mid) = latest.and_then(|latest| text_diff(baseline, latest)) {
256
+ if !mid.inserted.is_empty() {
257
+ return Some(mid);
258
+ }
259
+ }
260
+ direct
261
+ }
262
+
263
+ /// 差分 → text 事件字段:插入段 ≤500(超了带 truncated),纯删除只报 deleted。
264
+ pub fn text_fields(diff: &TextDiff) -> Map<String, Value> {
265
+ let mut fields = Map::new();
266
+ if !diff.inserted.is_empty() {
267
+ let (text, truncated) = truncate(&diff.inserted, limit::TEXT);
268
+ fields.insert("text".into(), json!(text));
269
+ if truncated {
270
+ fields.insert("truncated".into(), json!(true));
271
+ }
272
+ }
273
+ if diff.deleted > 0 {
274
+ fields.insert("deleted".into(), json!(diff.deleted));
275
+ }
276
+ fields
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // UIA 控件
281
+ // ---------------------------------------------------------------------------
282
+
283
+ pub const CT_BUTTON: i32 = 50000;
284
+ pub const CT_CHECKBOX: i32 = 50002;
285
+ pub const CT_COMBOBOX: i32 = 50003;
286
+ pub const CT_EDIT: i32 = 50004;
287
+ pub const CT_HYPERLINK: i32 = 50005;
288
+ pub const CT_MENUITEM: i32 = 50011;
289
+ pub const CT_RADIOBUTTON: i32 = 50013;
290
+ pub const CT_SPLITBUTTON: i32 = 50031;
291
+ pub const CT_TABITEM: i32 = 50019;
292
+ pub const CT_DOCUMENT: i32 = 50030;
293
+
294
+ /// 事件里 `el.role` 用的控件类型名(UIA 的程序名,与 macOS 的 AXButton 之类各用各的平台词汇)。
295
+ pub fn control_type_name(control_type: i32) -> &'static str {
296
+ match control_type {
297
+ 50000 => "Button",
298
+ 50001 => "Calendar",
299
+ 50002 => "CheckBox",
300
+ 50003 => "ComboBox",
301
+ 50004 => "Edit",
302
+ 50005 => "Hyperlink",
303
+ 50006 => "Image",
304
+ 50007 => "ListItem",
305
+ 50008 => "List",
306
+ 50009 => "Menu",
307
+ 50010 => "MenuBar",
308
+ 50011 => "MenuItem",
309
+ 50012 => "ProgressBar",
310
+ 50013 => "RadioButton",
311
+ 50014 => "ScrollBar",
312
+ 50015 => "Slider",
313
+ 50016 => "Spinner",
314
+ 50017 => "StatusBar",
315
+ 50018 => "Tab",
316
+ 50019 => "TabItem",
317
+ 50020 => "Text",
318
+ 50021 => "ToolBar",
319
+ 50022 => "ToolTip",
320
+ 50023 => "Tree",
321
+ 50024 => "TreeItem",
322
+ 50025 => "Custom",
323
+ 50026 => "Group",
324
+ 50027 => "Thumb",
325
+ 50028 => "DataGrid",
326
+ 50029 => "DataItem",
327
+ 50030 => "Document",
328
+ 50031 => "SplitButton",
329
+ 50032 => "Window",
330
+ 50033 => "Pane",
331
+ 50034 => "Header",
332
+ 50035 => "HeaderItem",
333
+ 50036 => "Table",
334
+ 50037 => "TitleBar",
335
+ 50038 => "Separator",
336
+ 50039 => "SemanticZoom",
337
+ 50040 => "AppBar",
338
+ _ => "Unknown",
339
+ }
340
+ }
341
+
342
+ /// 点击只给按钮 / 链接 / 菜单项 / 标签页这类控件带标签;文本框的值绝不当标签(点击也从不读值)。
343
+ pub fn click_label_allowed(control_type: i32) -> bool {
344
+ matches!(
345
+ control_type,
346
+ CT_BUTTON
347
+ | CT_SPLITBUTTON
348
+ | CT_HYPERLINK
349
+ | CT_MENUITEM
350
+ | CT_TABITEM
351
+ | CT_CHECKBOX
352
+ | CT_RADIOBUTTON
353
+ | 50010 // MenuBar 本身不常被点,保守起见也给
354
+ )
355
+ }
356
+
357
+ pub const CT_GROUP: i32 = 50026;
358
+ pub const CT_CUSTOM: i32 = 50025;
359
+ pub const CT_PANE: i32 = 50033;
360
+
361
+ /// 焦点输入框怎么读值。
362
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
363
+ pub enum TextMode {
364
+ /// ValuePattern.Value(普通输入框、网页 input / textarea)。
365
+ Value,
366
+ /// TextPattern 的文档区间(没有 ValuePattern 的富文本:Word、写字板、网页 contenteditable)。
367
+ Text,
368
+ }
369
+
370
+ /// 焦点元素能不能当「可编辑文本」看,按什么读。密码框一律不是。
371
+ /// value_editable:Some(true) = ValuePattern 可写;Some(false) = 有但只读;None = 没有 ValuePattern。
372
+ /// text_editable:TextPattern 文档区间的 IsReadOnly 为假(调用方只在没有 ValuePattern 时才去查;None = 没查 / 没有)。
373
+ /// 网页里的 contenteditable 在 UIA 里常是 Group / Custom,不能只认 Edit。
374
+ pub fn text_mode(control_type: i32, is_password: bool, value_editable: Option<bool>, text_editable: Option<bool>) -> Option<TextMode> {
375
+ if is_password {
376
+ return None;
377
+ }
378
+ let texty = matches!(control_type, CT_EDIT | CT_DOCUMENT | CT_COMBOBOX | CT_GROUP | CT_CUSTOM | CT_PANE);
379
+ match value_editable {
380
+ Some(true) if texty => Some(TextMode::Value),
381
+ Some(_) => None,
382
+ None if control_type != CT_COMBOBOX && text_editable == Some(true) => Some(TextMode::Text),
383
+ None => None,
384
+ }
385
+ }
386
+
387
+ /// 浏览器地址栏:Chromium 系的类名是 OmniboxViewViews,Firefox 的 AutomationId 是 urlbar-input,其余按名字(多语言)。
388
+ pub fn is_address_field(name: &str, class_name: &str, automation_id: &str) -> bool {
389
+ if class_name.contains("Omnibox") || automation_id == "urlbar-input" {
390
+ return true;
391
+ }
392
+ let lower = name.to_lowercase();
393
+ [
394
+ "address",
395
+ "url",
396
+ "地址",
397
+ "网址",
398
+ "網址",
399
+ "search or enter",
400
+ "enter address",
401
+ "搜索或输入",
402
+ "location",
403
+ ]
404
+ .iter()
405
+ .any(|needle| lower.contains(needle))
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // URL 与域名
410
+ // ---------------------------------------------------------------------------
411
+
412
+ struct UrlParts<'a> {
413
+ scheme: String,
414
+ host_port: &'a str,
415
+ path: &'a str,
416
+ }
417
+
418
+ fn split_url(text: &str) -> Option<UrlParts<'_>> {
419
+ let (scheme, rest) = text.split_once("://")?;
420
+ if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) {
421
+ return None;
422
+ }
423
+ let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
424
+ let authority = &rest[..end];
425
+ let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
426
+ let tail = &rest[end..];
427
+ let path = &tail[..tail.find(['?', '#']).unwrap_or(tail.len())];
428
+ Some(UrlParts { scheme: scheme.to_lowercase(), host_port, path })
429
+ }
430
+
431
+ fn host_of(host_port: &str) -> &str {
432
+ if let Some(stripped) = host_port.strip_prefix('[') {
433
+ return stripped.split(']').next().unwrap_or("");
434
+ }
435
+ host_port.split(':').next().unwrap_or("")
436
+ }
437
+
438
+ /// 浏览器 URL 出 helper 前的形状:只收 http(s),去掉 query / fragment / 用户名密码,≤300。
439
+ /// 地址栏里没写 scheme 的(example.com/path)按 https 补上;带空白的(搜索词)不是 URL → None。
440
+ pub fn sanitize_url(raw: &str) -> Option<String> {
441
+ let mut text = raw.trim().to_owned();
442
+ if text.is_empty() || text.chars().any(char::is_whitespace) {
443
+ return None;
444
+ }
445
+ if !text.contains("://") {
446
+ let host = text.split('/').next().unwrap_or("");
447
+ if !host.contains('.') || host.starts_with('.') || host.ends_with('.') {
448
+ return None;
449
+ }
450
+ text = format!("https://{text}");
451
+ }
452
+ let parts = split_url(&text)?;
453
+ if parts.scheme != "http" && parts.scheme != "https" || host_of(parts.host_port).is_empty() {
454
+ return None;
455
+ }
456
+ let clean = format!("{}://{}{}", parts.scheme, parts.host_port, parts.path);
457
+ Some(truncate(&clean, limit::URL).0)
458
+ }
459
+
460
+ /// 浏览器内置页的 scheme(新标签页、设置页、本地文件):读到了、但不属于任何站点,排除站点管不到它们。
461
+ const INTERNAL_SCHEMES: &[&str] = &[
462
+ "about", "chrome", "edge", "brave", "vivaldi", "opera", "arc", "file", "moz-extension",
463
+ ];
464
+
465
+ /// 地址栏里的值 → (出 helper 的网址, 算不算读到了)。空串 = 新标签页(读到了、没有网址);内置页同理;
466
+ /// 别的解析不了(正在输入的搜索词、半截网址)= 没读到 —— 设了排除站点的订阅者据此失败即关闭。
467
+ pub fn address_bar_url(raw: &str) -> (Option<String>, bool) {
468
+ let text = raw.trim();
469
+ if text.is_empty() {
470
+ return (None, true);
471
+ }
472
+ if let Some(url) = sanitize_url(text) {
473
+ return (Some(url), true);
474
+ }
475
+ let internal = text
476
+ .split_once(':')
477
+ .is_some_and(|(scheme, _)| INTERNAL_SCHEMES.contains(&scheme.to_lowercase().as_str()));
478
+ (None, internal)
479
+ }
480
+
481
+ pub fn url_host(url: &str) -> Option<String> {
482
+ let host = host_of(split_url(url)?.host_port);
483
+ (!host.is_empty()).then(|| host.to_lowercase())
484
+ }
485
+
486
+ /// 排除站点条目归一成裸域名:去 scheme / 路径 / 用户名 / 端口 / 前导「*.」「.」/ 末尾点,小写。不像域名 → None。
487
+ pub fn normalize_domain(raw: &str) -> Option<String> {
488
+ let mut text = raw.trim().to_lowercase();
489
+ if let Some((_, rest)) = text.split_once("://") {
490
+ text = rest.to_owned();
491
+ }
492
+ text = text.split('/').next().unwrap_or("").to_owned();
493
+ if let Some((_, rest)) = text.rsplit_once('@') {
494
+ text = rest.to_owned();
495
+ }
496
+ if let Some((host, _)) = text.split_once(':') {
497
+ text = host.to_owned();
498
+ }
499
+ while let Some(rest) = text.strip_prefix("*.") {
500
+ text = rest.to_owned();
501
+ }
502
+ let text = text.trim_start_matches('.').trim_end_matches('.').to_owned();
503
+ if text.is_empty() || text.chars().any(|c| c.is_whitespace() || c == '*') {
504
+ return None;
505
+ }
506
+ Some(text)
507
+ }
508
+
509
+ /// host 等于该域名或是它的子域(`docs.example.com` 命中 `example.com`,`badexample.com` 不命中)。
510
+ pub fn host_matches(host: &str, domain: &str) -> bool {
511
+ let normalized = host.to_lowercase();
512
+ let normalized = normalized.trim_end_matches('.');
513
+ normalized == domain || normalized.ends_with(&format!(".{domain}"))
514
+ }
515
+
516
+ /// bundle id(Windows 上 = exe 名)匹配:不分大小写;精确,或以「.*」结尾的前缀通配。
517
+ pub fn bundle_matches(bundle_id: &str, pattern: &str) -> bool {
518
+ let id = bundle_id.to_lowercase();
519
+ let rule = pattern.to_lowercase();
520
+ match rule.strip_suffix('*') {
521
+ Some(prefix) if rule.ends_with(".*") => id.starts_with(prefix),
522
+ _ => id == rule,
523
+ }
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // 快捷键
528
+ // ---------------------------------------------------------------------------
529
+
530
+ /// 主键名:特殊键查表,字母 / 数字取虚拟键码,其余(标点)用调用方按当前键盘布局换算出的字符;取不到 → None(不报)。
531
+ pub fn key_name(vk: u32, character: Option<char>) -> Option<String> {
532
+ let special = match vk {
533
+ 0x08 => "Backspace",
534
+ 0x09 => "Tab",
535
+ 0x0D => "Enter",
536
+ 0x1B => "Esc",
537
+ 0x20 => "Space",
538
+ 0x21 => "PageUp",
539
+ 0x22 => "PageDown",
540
+ 0x23 => "End",
541
+ 0x24 => "Home",
542
+ 0x25 => "Left",
543
+ 0x26 => "Up",
544
+ 0x27 => "Right",
545
+ 0x28 => "Down",
546
+ 0x2C => "PrintScreen",
547
+ 0x2D => "Insert",
548
+ 0x2E => "Delete",
549
+ _ => "",
550
+ };
551
+ if !special.is_empty() {
552
+ return Some(special.to_owned());
553
+ }
554
+ match vk {
555
+ 0x30..=0x39 | 0x41..=0x5A => return char::from_u32(vk).map(String::from),
556
+ 0x60..=0x69 => return Some(format!("Num{}", vk - 0x60)),
557
+ 0x70..=0x87 => return Some(format!("F{}", vk - 0x6F)),
558
+ _ => {}
559
+ }
560
+ let ch = character?;
561
+ if ch.is_control() || ch.is_whitespace() {
562
+ return None;
563
+ }
564
+ Some(ch.to_uppercase().collect())
565
+ }
566
+
567
+ /// 组合串,如「Ctrl+Shift+P」「Win+E」。没按 Ctrl / Win 的一律不算快捷键 → None。
568
+ pub fn key_combo(ctrl: bool, alt: bool, shift: bool, win: bool, key: &str) -> Option<String> {
569
+ if !(ctrl || win) || key.is_empty() {
570
+ return None;
571
+ }
572
+ let mut parts = Vec::with_capacity(5);
573
+ if win {
574
+ parts.push("Win");
575
+ }
576
+ if ctrl {
577
+ parts.push("Ctrl");
578
+ }
579
+ if alt {
580
+ parts.push("Alt");
581
+ }
582
+ if shift {
583
+ parts.push("Shift");
584
+ }
585
+ parts.push(key);
586
+ Some(parts.join("+"))
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // 策略、情境、投递
591
+ // ---------------------------------------------------------------------------
592
+
593
+ /// 订阅者随 recordSubscribe 下发的策略。全部可选;硬排除名单在它之上生效。
594
+ #[derive(Debug, Clone, PartialEq)]
595
+ pub struct Policy {
596
+ pub exclude_bundle_ids: Vec<String>,
597
+ pub title_only_bundle_ids: Vec<String>,
598
+ /// 已归一的裸域名。
599
+ pub exclude_domains: Vec<String>,
600
+ pub text: bool,
601
+ pub clicks: bool,
602
+ pub keys: bool,
603
+ }
604
+
605
+ impl Default for Policy {
606
+ fn default() -> Self {
607
+ Self {
608
+ exclude_bundle_ids: Vec::new(),
609
+ title_only_bundle_ids: Vec::new(),
610
+ exclude_domains: Vec::new(),
611
+ text: true,
612
+ clicks: true,
613
+ keys: true,
614
+ }
615
+ }
616
+ }
617
+
618
+ impl Policy {
619
+ pub fn from_json(value: Option<&Value>) -> Self {
620
+ let empty = Map::new();
621
+ let object = value.and_then(Value::as_object).unwrap_or(&empty);
622
+ let list = |key: &str| -> Vec<String> {
623
+ object
624
+ .get(key)
625
+ .and_then(Value::as_array)
626
+ .map(|items| {
627
+ items
628
+ .iter()
629
+ .filter_map(Value::as_str)
630
+ .take(1000)
631
+ .map(str::to_owned)
632
+ .collect()
633
+ })
634
+ .unwrap_or_default()
635
+ };
636
+ let flag = |key: &str| object.get(key).and_then(Value::as_bool).unwrap_or(true);
637
+ Self {
638
+ exclude_bundle_ids: list("excludeBundleIds"),
639
+ title_only_bundle_ids: list("titleOnlyBundleIds"),
640
+ exclude_domains: list("excludeDomains")
641
+ .iter()
642
+ .filter_map(|d| normalize_domain(d))
643
+ .collect(),
644
+ text: flag("text"),
645
+ clicks: flag("clicks"),
646
+ keys: flag("keys"),
647
+ }
648
+ }
649
+
650
+ pub fn excludes(&self, bundle_id: &str) -> bool {
651
+ self.exclude_bundle_ids
652
+ .iter()
653
+ .any(|p| bundle_matches(bundle_id, p))
654
+ }
655
+
656
+ pub fn title_only(&self, bundle_id: &str) -> bool {
657
+ self.title_only_bundle_ids
658
+ .iter()
659
+ .any(|p| bundle_matches(bundle_id, p))
660
+ }
661
+
662
+ /// 这个订阅者要不要这个 App 标题以上的信息。
663
+ pub fn wants_app(&self, bundle_id: &str) -> bool {
664
+ !self.excludes(bundle_id)
665
+ }
666
+ }
667
+
668
+ /// 一次观测时的前台情境。纯数据,过滤只看它 —— 所以能单测。
669
+ #[derive(Debug, Clone, PartialEq, Default)]
670
+ pub struct Context {
671
+ pub name: String,
672
+ /// 小写 exe 名。
673
+ pub bundle_id: String,
674
+ /// 硬排除(密码管理器 / 凭据界面 / 采集器自己):连标题都没读。
675
+ pub hard_excluded: bool,
676
+ /// 无痕 / 私密窗口时恒为 None。
677
+ pub title: Option<String>,
678
+ pub url: Option<String>,
679
+ /// 浏览器但网址没读到。订阅者设了排除站点时按排除处理。
680
+ pub url_unknown: bool,
681
+ pub is_private: bool,
682
+ }
683
+
684
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
685
+ pub enum Kind {
686
+ App,
687
+ Window,
688
+ Text,
689
+ Click,
690
+ Key,
691
+ System,
692
+ }
693
+
694
+ impl Kind {
695
+ pub fn as_str(self) -> &'static str {
696
+ match self {
697
+ Kind::App => "app",
698
+ Kind::Window => "window",
699
+ Kind::Text => "text",
700
+ Kind::Click => "click",
701
+ Kind::Key => "key",
702
+ Kind::System => "system",
703
+ }
704
+ }
705
+ }
706
+
707
+ /// 按某个订阅者的策略,把一次观测变成事件体(不含 t / origin / 各 kind 自己的字段)。None = 这个订阅者不该收到。
708
+ /// 规则与 macOS recorderEventBody 逐条一致:排除 App 只留切换;排除站点(含设了排除站点而网址没读到)留不带内容的
709
+ /// app / window 标记;无痕窗口只留一条不带标题的切换;只记标题的 App 没有 text / click / key。
710
+ pub fn event_body(kind: Kind, context: Option<&Context>, policy: &Policy) -> Option<Map<String, Value>> {
711
+ let mut body = Map::new();
712
+ body.insert("kind".into(), json!(kind.as_str()));
713
+ let Some(context) = context else {
714
+ return (kind == Kind::System).then_some(body);
715
+ };
716
+ let mut app = Map::new();
717
+ app.insert("name".into(), json!(context.name));
718
+ if !context.bundle_id.is_empty() {
719
+ app.insert("bundleId".into(), json!(context.bundle_id));
720
+ }
721
+ let domain_excluded = match context.url.as_deref().and_then(url_host) {
722
+ Some(host) => policy.exclude_domains.iter().any(|d| host_matches(&host, d)),
723
+ None => context.url_unknown && !policy.exclude_domains.is_empty(),
724
+ };
725
+ let app_excluded = context.hard_excluded || policy.excludes(&context.bundle_id);
726
+ if app_excluded || domain_excluded {
727
+ if !(kind == Kind::App || (kind == Kind::Window && !app_excluded)) {
728
+ return None;
729
+ }
730
+ app.insert("excluded".into(), json!(true));
731
+ body.insert("app".into(), Value::Object(app));
732
+ return Some(body);
733
+ }
734
+ body.insert("app".into(), Value::Object(app));
735
+ if context.is_private {
736
+ return (kind == Kind::App).then_some(body);
737
+ }
738
+ match kind {
739
+ Kind::Text if !policy.text => return None,
740
+ Kind::Click if !policy.clicks => return None,
741
+ Kind::Key if !policy.keys => return None,
742
+ _ => {}
743
+ }
744
+ if matches!(kind, Kind::Text | Kind::Click | Kind::Key) && policy.title_only(&context.bundle_id) {
745
+ return None;
746
+ }
747
+ if let Some(title) = context.title.as_deref().filter(|t| !t.is_empty()) {
748
+ body.insert("title".into(), json!(title));
749
+ }
750
+ if let Some(url) = &context.url {
751
+ body.insert("url".into(), json!(url));
752
+ }
753
+ Some(body)
754
+ }
755
+
756
+ /// app / window 事件的去重键(与 macOS recorderContextKey、桌面 contextKey 同口径)。
757
+ pub fn context_key(body: &Map<String, Value>) -> String {
758
+ let app = body.get("app").and_then(Value::as_object);
759
+ let id = app
760
+ .and_then(|a| a.get("bundleId").or_else(|| a.get("name")))
761
+ .and_then(Value::as_str)
762
+ .unwrap_or("");
763
+ let excluded = app
764
+ .and_then(|a| a.get("excluded"))
765
+ .and_then(Value::as_bool)
766
+ == Some(true);
767
+ let field = |key: &str| body.get(key).and_then(Value::as_str).unwrap_or("");
768
+ [id, if excluded { "x" } else { "" }, field("title"), field("url")].join("\u{1F}")
769
+ }
770
+
771
+ /// 一个订阅者的情境投递状态。只在 recorder 线程读写。
772
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
773
+ pub struct DeliveryState {
774
+ pub last_key: Option<String>,
775
+ /// 最近处理过的情境对它没记(无痕 / 排除):下一条可记录的情境事件带 `resumed: true`。
776
+ pub unrecorded: bool,
777
+ }
778
+
779
+ pub fn context_recorded(context: &Context, policy: &Policy) -> bool {
780
+ event_body(Kind::Window, Some(context), policy).is_some_and(|body| {
781
+ body.get("app")
782
+ .and_then(|a| a.get("excluded"))
783
+ .and_then(Value::as_bool)
784
+ != Some(true)
785
+ })
786
+ }
787
+
788
+ /// 一条 app / window 情境事件对某个订阅者:发什么(None = 不发),并更新它的投递状态。
789
+ /// 与 macOS recorderContextDelivery 一致(去重、无痕窗口的键、断点 resumed 的规则见那边的长注释)。
790
+ pub fn context_delivery(
791
+ kind: Kind,
792
+ context: Option<&Context>,
793
+ policy: &Policy,
794
+ state: &mut DeliveryState,
795
+ dedupe: bool,
796
+ ) -> Option<Map<String, Value>> {
797
+ let context = context?;
798
+ let recorded = context_recorded(context, policy);
799
+ if !recorded {
800
+ state.unrecorded = true;
801
+ }
802
+ let Some(mut body) = event_body(kind, Some(context), policy) else {
803
+ if kind == Kind::Window {
804
+ if let Some(app) = event_body(Kind::App, Some(context), policy) {
805
+ state.last_key = Some(context_key(&app));
806
+ }
807
+ }
808
+ return None;
809
+ };
810
+ let key = context_key(&body);
811
+ let resumed = recorded && state.unrecorded;
812
+ if dedupe && state.last_key.as_deref() == Some(key.as_str()) && !resumed {
813
+ return None;
814
+ }
815
+ if resumed {
816
+ body.insert("resumed".into(), json!(true));
817
+ state.unrecorded = false;
818
+ }
819
+ state.last_key = Some(key);
820
+ Some(body)
821
+ }
822
+
823
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
824
+ pub enum PendingAction {
825
+ Keep,
826
+ Flush,
827
+ Drop,
828
+ }
829
+
830
+ /// 窗口情境刷新时待发那段输入怎么办(与 macOS recorderPendingOnRefresh 一致)。
831
+ pub fn pending_on_refresh(next_wants_text: bool, window_changed: bool, url_changed: bool) -> PendingAction {
832
+ if !next_wants_text {
833
+ PendingAction::Drop
834
+ } else if window_changed || url_changed {
835
+ PendingAction::Flush
836
+ } else {
837
+ PendingAction::Keep
838
+ }
839
+ }
840
+
841
+ /// `serve --pipe` 的管道名只收本机命名管道(`\\.\pipe\…`,不含别的反斜杠以外的路径成分)。
842
+ pub fn pipe_name_valid(name: &str) -> bool {
843
+ name.strip_prefix(r"\\.\pipe\").is_some_and(|rest| {
844
+ !rest.is_empty()
845
+ && rest.len() <= 200
846
+ && !rest.contains('\\')
847
+ && rest
848
+ .chars()
849
+ .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
850
+ })
851
+ }