@forsion/tangu-computer-use 0.5.8 → 0.6.0
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.
- package/CHANGELOG.md +23 -0
- package/README.md +8 -0
- package/UPSTREAM.md +2 -1
- package/manifest.json +1 -1
- package/native/macos/activity_recorder.swift +1806 -0
- package/native/macos/activity_recorder_tests.swift +832 -0
- package/native/macos/bridge.swift +45 -2
- package/package.json +5 -3
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +4 -4
- package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/macos/x64/bridge +0 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.json +4 -4
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/build-native.mjs +1 -0
- package/scripts/dev-recorder.mjs +106 -0
- package/scripts/probe-recorder/probe.swift +221 -0
- package/scripts/probe-recorder.mjs +47 -0
- package/scripts/recorder-logic.check.mjs +34 -0
- package/scripts/recorder.check.mjs +169 -0
- package/src/vendor/platform/macos/helper.ts +1 -1
- package/tangu-plugins/computer-use/dist/index.js +1 -1
- package/tangu-plugins/computer-use/tangu-plugin.json +1 -1
|
@@ -369,7 +369,7 @@ final class InputSuppressionGuard {
|
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
final class Bridge {
|
|
372
|
-
private let protocolVersion =
|
|
372
|
+
private let protocolVersion = 13
|
|
373
373
|
private let refStore = AXRefStore()
|
|
374
374
|
private let inputSuppressionGuard = InputSuppressionGuard()
|
|
375
375
|
private let physicalInputLock = NSRecursiveLock()
|
|
@@ -470,19 +470,47 @@ final class Bridge {
|
|
|
470
470
|
let clientInput = FileHandle(fileDescriptor: client, closeOnDealloc: true)
|
|
471
471
|
var buffer = Data()
|
|
472
472
|
let newline = Data([0x0A])
|
|
473
|
+
// Tangu:这条连接一旦成了电脑历史订阅(recordSubscribe),之后只由 ActivityRecorder 写事件、这里只等 EOF ——
|
|
474
|
+
// 再处理别的请求,回包会和事件行在不同线程上交错写坏。
|
|
475
|
+
var subscribed = false
|
|
473
476
|
while true {
|
|
474
477
|
let data = clientInput.availableData
|
|
475
478
|
if data.isEmpty { break }
|
|
479
|
+
if subscribed { continue }
|
|
476
480
|
buffer.append(data)
|
|
477
481
|
while let range = buffer.range(of: newline) {
|
|
478
482
|
let lineData = buffer.subdata(in: 0..<range.lowerBound)
|
|
479
483
|
buffer.removeSubrange(0..<range.upperBound)
|
|
480
|
-
|
|
484
|
+
guard let line = String(data: lineData, encoding: .utf8), !line.isEmpty else { continue }
|
|
485
|
+
if let subscribedNow = subscribeRecorder(line, client: client) {
|
|
486
|
+
if subscribedNow { subscribed = true; buffer.removeAll(); break }
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
handleLine(line, to: client)
|
|
481
490
|
}
|
|
482
491
|
}
|
|
492
|
+
// 先退订并排空它的写队列,再关 fd —— 否则排队中的写会落到被复用的 fd 上。
|
|
493
|
+
if subscribed { ActivityRecorder.shared.unsubscribe(fd: client) }
|
|
483
494
|
clientInput.closeFile()
|
|
484
495
|
}
|
|
485
496
|
|
|
497
|
+
/// Tangu:recordSubscribe(协议 13,见 activity_recorder.swift)。不是这条命令 → nil,照常走 handleLine;
|
|
498
|
+
/// 订阅成功 → true(回包已由 recorder 排在这条连接写队列的第一位);失败 → 在这里回错误包,false。
|
|
499
|
+
private func subscribeRecorder(_ line: String, client: Int32) -> Bool? {
|
|
500
|
+
guard line.contains("recordSubscribe"),
|
|
501
|
+
let data = line.data(using: .utf8),
|
|
502
|
+
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
|
503
|
+
object["cmd"] as? String == "recordSubscribe"
|
|
504
|
+
else { return nil }
|
|
505
|
+
let id = (object["id"] as? String) ?? "invalid"
|
|
506
|
+
guard let failure = ActivityRecorder.shared.subscribe(fd: client, id: id, policy: object["policy"], protocolVersion: protocolVersion, browserBundleIds: browserBundleIds) else {
|
|
507
|
+
return true
|
|
508
|
+
}
|
|
509
|
+
send(["id": id, "ok": false, "error": ["message": failure.message, "code": failure.code]], to: client)
|
|
510
|
+
recordCompletedRequest(id)
|
|
511
|
+
return false
|
|
512
|
+
}
|
|
513
|
+
|
|
486
514
|
private func processBufferedInput() {
|
|
487
515
|
let newline = Data([0x0A])
|
|
488
516
|
while let range = stdinBuffer.range(of: newline) {
|
|
@@ -735,16 +763,21 @@ final class Bridge {
|
|
|
735
763
|
case "endInputSuppression":
|
|
736
764
|
return endInputSuppression()
|
|
737
765
|
case "restoreUserFocus":
|
|
766
|
+
// Tangu:agent 发起的界面改动期间,电脑历史把观测到的事件记成 origin:"agent"(深度计数,见 activity_recorder.swift)。
|
|
767
|
+
ActivityRecorder.shared.agentEnter(); defer { ActivityRecorder.shared.agentLeave() }
|
|
738
768
|
return try restoreUserFocus(request)
|
|
739
769
|
case "focusWindow":
|
|
770
|
+
ActivityRecorder.shared.agentEnter(); defer { ActivityRecorder.shared.agentLeave() }
|
|
740
771
|
return try focusWindow(request)
|
|
741
772
|
case "setWindowFrame":
|
|
742
773
|
return try setWindowFrame(request)
|
|
743
774
|
case "look":
|
|
744
775
|
return try look(request)
|
|
745
776
|
case "act":
|
|
777
|
+
ActivityRecorder.shared.agentEnter(); defer { ActivityRecorder.shared.agentLeave() }
|
|
746
778
|
return try act(request)
|
|
747
779
|
case "actBatch":
|
|
780
|
+
ActivityRecorder.shared.agentEnter(); defer { ActivityRecorder.shared.agentLeave() }
|
|
748
781
|
return try actBatch(request)
|
|
749
782
|
case "liveView":
|
|
750
783
|
return try liveView(request)
|
|
@@ -758,6 +791,9 @@ final class Bridge {
|
|
|
758
791
|
return try axReadText(request)
|
|
759
792
|
case "getMousePosition":
|
|
760
793
|
return getMousePosition()
|
|
794
|
+
case "recordSubscribe":
|
|
795
|
+
// 只在 serve socket 的专用连接上有意义(processClient 截走了那条路);stdin 模式没有能长开的连接。
|
|
796
|
+
throw BridgeFailure(message: "recordSubscribe needs a dedicated connection to the serve socket", code: "unsupported")
|
|
761
797
|
default:
|
|
762
798
|
throw BridgeFailure(message: "Unknown command '\(cmd)'", code: "unknown_command")
|
|
763
799
|
}
|
|
@@ -866,7 +902,14 @@ final class Bridge {
|
|
|
866
902
|
"accessibility": permissions["accessibility"] ?? false,
|
|
867
903
|
"screenRecording": permissions["screenRecording"] ?? false,
|
|
868
904
|
"recentCompletedRequestIds": completedRequestIds(),
|
|
905
|
+
"recorderSubscribers": ActivityRecorder.shared.subscriberCount,
|
|
869
906
|
]
|
|
907
|
+
// Tangu:电脑历史的无痕扫描计数(只有计数)—— 浏览器窗口的有上限遍历走了几次、几次没看完、各因什么停手。
|
|
908
|
+
// 真机上据此判断「扫描不完整时仍按普通窗口记」要不要改(见 activity_recorder.swift RecorderScanStats)。
|
|
909
|
+
let privateScan = ActivityRecorder.shared.scanStats.snapshot()
|
|
910
|
+
output["recorderPrivateScans"] = privateScan.walks
|
|
911
|
+
output["recorderPrivateScanIncomplete"] = privateScan.incomplete
|
|
912
|
+
output["recorderPrivateScanStops"] = privateScan.stops
|
|
870
913
|
if let parentPath {
|
|
871
914
|
output["parentPath"] = parentPath
|
|
872
915
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forsion/tangu-computer-use",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Tangu engine plugin: let agents observe and control macOS, Windows, and Linux desktop apps. Fork of injaneity/pi-computer-use (MIT).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"check:platform": "node scripts/platform-contract.check.mjs",
|
|
55
55
|
"check:blindclick": "node scripts/blind-click.check.mjs",
|
|
56
56
|
"check:liveview": "node scripts/live-view.check.mjs",
|
|
57
|
-
"check": "npm run check:platform && npm run check:no-foreground && npm run check:helper-path && npm run check:highlight && npm run check:permissions && npm run check:plugin && npm run check:helper-refresh && npm run check:installer && npm run check:cli-exit",
|
|
57
|
+
"check": "npm run check:platform && npm run check:no-foreground && npm run check:helper-path && npm run check:highlight && npm run check:permissions && npm run check:plugin && npm run check:helper-refresh && npm run check:installer && npm run check:cli-exit && npm run check:recorder-logic",
|
|
58
58
|
"build:native": "node scripts/build-native.mjs",
|
|
59
59
|
"build:windows": "node scripts/build-native.mjs --platform windows",
|
|
60
60
|
"build:linux": "node scripts/build-native.mjs --platform linux",
|
|
@@ -64,7 +64,9 @@
|
|
|
64
64
|
"check:helper-refresh": "node scripts/helper-refresh.check.mjs",
|
|
65
65
|
"check:helper-signal": "node scripts/helper-signal.check.mjs",
|
|
66
66
|
"check:installer": "node scripts/keychain-free-install.check.mjs",
|
|
67
|
-
"check:cli-exit": "node scripts/cli-exit.check.mjs"
|
|
67
|
+
"check:cli-exit": "node scripts/cli-exit.check.mjs",
|
|
68
|
+
"check:recorder-logic": "node scripts/recorder-logic.check.mjs",
|
|
69
|
+
"check:recorder": "node scripts/recorder.check.mjs"
|
|
68
70
|
},
|
|
69
71
|
"devDependencies": {
|
|
70
72
|
"@types/node": "^22.10.0",
|
|
Binary file
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bundleId": "com.forsion.tangu-computer-use",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"archiveSha256": "
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"archiveSha256": "2888e7908a0f5cb3d9e2ae9dd26d4c53737d7cf0550eb7d1afccf58a773fda0a",
|
|
5
5
|
"files": {
|
|
6
|
-
"Contents/MacOS/bridge": "
|
|
7
|
-
"Contents/Info.plist": "
|
|
6
|
+
"Contents/MacOS/bridge": "1fac72ce2c0491c6191a877bc7ec373d17c0bc371d3ffe67cd27b11739eb94ef",
|
|
7
|
+
"Contents/Info.plist": "69122c51686853486a2453f8ac3ade5167f4a05b8614f235186aadbe1431b398",
|
|
8
8
|
"Contents/_CodeSignature/CodeResources": "6686de10a28a2fe11b36cbb86dcbacc827cfc4ea116b4dabf1845e5aee629e9b"
|
|
9
9
|
}
|
|
10
10
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bundleId": "com.forsion.tangu-computer-use",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"archiveSha256": "
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"archiveSha256": "aa25102c14e4777326ede78a71aa9bc4e452b4da1255c2849b5c248173708a52",
|
|
5
5
|
"files": {
|
|
6
|
-
"Contents/MacOS/bridge": "
|
|
7
|
-
"Contents/Info.plist": "
|
|
6
|
+
"Contents/MacOS/bridge": "8c09d6c17e70f329c7958cff4db15c1308bc97e73b42844f33c31de0ebf48318",
|
|
7
|
+
"Contents/Info.plist": "69122c51686853486a2453f8ac3ade5167f4a05b8614f235186aadbe1431b398",
|
|
8
8
|
"Contents/_CodeSignature/CodeResources": "6686de10a28a2fe11b36cbb86dcbacc827cfc4ea116b4dabf1845e5aee629e9b"
|
|
9
9
|
}
|
|
10
10
|
}
|
|
Binary file
|
|
Binary file
|
package/scripts/build-native.mjs
CHANGED
|
@@ -14,6 +14,7 @@ const macosSourcePaths = [
|
|
|
14
14
|
"agent_cursor_motion.swift",
|
|
15
15
|
"agent_highlight.swift",
|
|
16
16
|
"live_stream.swift",
|
|
17
|
+
"activity_recorder.swift",
|
|
17
18
|
"bridge.swift",
|
|
18
19
|
].map((file) => path.join(rootDir, "native", "macos", file));
|
|
19
20
|
const windowsCrateDir = path.join(rootDir, "native", "windows", "bridge-rs");
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 电脑历史 recorder 的真机台架(macOS)。把 `npm run build:native` 编出的 helper 包成一个**独立 bundle id**
|
|
3
|
+
// (com.forsion.tangu-computer-use.dev)的 app,用本机稳定的签名身份签 —— 于是:
|
|
4
|
+
// - 辅助功能授权只给一次,重编不丢(ad-hoc 每次 cdhash 都变,得重新授权);
|
|
5
|
+
// - 与正式 helper(com.forsion.tangu-computer-use)是两条 TCC 记录、两个 socket,绝不动用户正在用的 Computer Use 授权。
|
|
6
|
+
// 用法:
|
|
7
|
+
// node scripts/dev-recorder.mjs build -> build/tangu-computer-use-dev.app
|
|
8
|
+
// node scripts/dev-recorder.mjs start -> open -n -g 拉起 serve(socket 见下)
|
|
9
|
+
// node scripts/dev-recorder.mjs grant -> 弹辅助功能授权框(授权后 stop + start)
|
|
10
|
+
// node scripts/dev-recorder.mjs tail [秒] [--show] -> 订阅并逐条打印(缺省只打 kind/App/长度,--show 才打内容)
|
|
11
|
+
// node scripts/dev-recorder.mjs stop
|
|
12
|
+
// node scripts/dev-recorder.mjs diag -> 订阅者数 + 无痕扫描计数(recorderPrivateScan*,只有计数):
|
|
13
|
+
// 浏览器窗口的遍历走了几次、几次没看完、各因什么停手
|
|
14
|
+
// Desktop 接这个 helper:PI_CU_SOCKET_PATH=<下面的 socket> 起 dev 实例。
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import net from 'node:net';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
22
|
+
const app = path.join(root, 'build/tangu-computer-use-dev.app');
|
|
23
|
+
export const socketPath = path.join(os.homedir(), 'Library/Caches/tangu-computer-use-dev/bridge.sock');
|
|
24
|
+
const [cmd = 'tail', ...rest] = process.argv.slice(2);
|
|
25
|
+
const show = rest.includes('--show');
|
|
26
|
+
const seconds = Number(rest.find((a) => /^\d+$/.test(a)) ?? 60);
|
|
27
|
+
|
|
28
|
+
function request(payload, { stream = false, onEvent } = {}) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const socket = net.createConnection(socketPath);
|
|
31
|
+
let buffer = '';
|
|
32
|
+
let first;
|
|
33
|
+
socket.setEncoding('utf8');
|
|
34
|
+
socket.on('connect', () => socket.write(JSON.stringify({ id: 'dev', ...payload }) + '\n'));
|
|
35
|
+
socket.on('data', (chunk) => {
|
|
36
|
+
buffer += chunk;
|
|
37
|
+
let i;
|
|
38
|
+
while ((i = buffer.indexOf('\n')) >= 0) {
|
|
39
|
+
const line = JSON.parse(buffer.slice(0, i));
|
|
40
|
+
buffer = buffer.slice(i + 1);
|
|
41
|
+
if (first === undefined) {
|
|
42
|
+
first = line;
|
|
43
|
+
if (!stream || !line.ok) { socket.end(); resolve(line); }
|
|
44
|
+
else resolve({ reply: line, socket });
|
|
45
|
+
} else if (line.ev) onEvent?.(line.ev);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
socket.on('error', reject);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (cmd === 'build') {
|
|
53
|
+
const binary = path.join(root, 'prebuilt/macos', process.arch === 'x64' ? 'x64' : 'arm64', 'bridge');
|
|
54
|
+
if (!existsSync(binary)) throw new Error('run `npm run build:native` first');
|
|
55
|
+
rmSync(app, { recursive: true, force: true });
|
|
56
|
+
mkdirSync(path.join(app, 'Contents/MacOS'), { recursive: true });
|
|
57
|
+
copyFileSync(binary, path.join(app, 'Contents/MacOS/bridge'));
|
|
58
|
+
writeFileSync(path.join(app, 'Contents/Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
|
|
59
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
60
|
+
<plist version="1.0"><dict>
|
|
61
|
+
<key>CFBundleIdentifier</key><string>com.forsion.tangu-computer-use.dev</string>
|
|
62
|
+
<key>CFBundleName</key><string>tangu-computer-use-dev</string>
|
|
63
|
+
<key>CFBundleExecutable</key><string>bridge</string>
|
|
64
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
65
|
+
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
|
66
|
+
<key>LSUIElement</key><true/>
|
|
67
|
+
</dict></plist>\n`);
|
|
68
|
+
const ids = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' });
|
|
69
|
+
const identity = ids.match(/"(Apple Development:[^"]+)"/)?.[1] ?? '-';
|
|
70
|
+
execFileSync('codesign', ['--force', '--sign', identity, app], { stdio: 'inherit', timeout: 30_000 });
|
|
71
|
+
console.log(`built ${app} (signed: ${identity === '-' ? 'ad-hoc — grant is lost on every rebuild' : identity})`);
|
|
72
|
+
} else if (cmd === 'start') {
|
|
73
|
+
mkdirSync(path.dirname(socketPath), { recursive: true });
|
|
74
|
+
execFileSync('open', ['-n', '-g', app, '--args', 'serve', '--socket', socketPath]);
|
|
75
|
+
for (let i = 0; i < 20 && !existsSync(socketPath); i++) execFileSync('sleep', ['0.25']);
|
|
76
|
+
console.log(existsSync(socketPath) ? `serving on ${socketPath}` : 'helper did not come up');
|
|
77
|
+
} else if (cmd === 'grant') {
|
|
78
|
+
// 让 dev helper 出现在「辅助功能」列表里并弹系统授权框(只要辅助功能,不碰屏幕录制)。授权后要 stop + start 换新进程。
|
|
79
|
+
console.log(JSON.stringify(await request({ cmd: 'registerPermissions', kind: 'accessibility' })));
|
|
80
|
+
} else if (cmd === 'diag') {
|
|
81
|
+
const res = await request({ cmd: 'diagnostics' });
|
|
82
|
+
if (!res.ok) { console.log(JSON.stringify(res)); process.exit(1); }
|
|
83
|
+
const keys = ['protocolVersion', 'accessibility', 'recorderSubscribers', 'recorderPrivateScans', 'recorderPrivateScanIncomplete', 'recorderPrivateScanStops'];
|
|
84
|
+
console.log(JSON.stringify(Object.fromEntries(keys.map((key) => [key, res.result[key]])), null, 1));
|
|
85
|
+
} else if (cmd === 'stop') {
|
|
86
|
+
console.log(await request({ cmd: 'shutdown' }).catch((e) => `not running (${e.code})`));
|
|
87
|
+
} else if (cmd === 'tail') {
|
|
88
|
+
const counts = {};
|
|
89
|
+
const res = await request({ cmd: 'recordSubscribe', policy: {} }, {
|
|
90
|
+
stream: true,
|
|
91
|
+
onEvent: (ev) => {
|
|
92
|
+
const key = `${ev.app?.name ?? '-'} ${ev.kind}`;
|
|
93
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
94
|
+
const time = new Date(ev.t).toTimeString().slice(0, 8);
|
|
95
|
+
const detail = show
|
|
96
|
+
? JSON.stringify({ title: ev.title, url: ev.url, el: ev.el, text: ev.text, keys: ev.keys, state: ev.state, resumed: ev.resumed })
|
|
97
|
+
: `title=${ev.title?.length ?? '-'} url=${ev.url ? 'y' : '-'} text=${ev.text?.length ?? (ev.deleted ? `-${ev.deleted}` : '-')} el=${ev.el?.role ?? '-'}${ev.keys ? ' keys' : ''}${ev.state ? ` ${ev.state}` : ''}${ev.origin ? ' agent' : ''}${ev.app?.excluded ? ' excluded' : ''}${ev.resumed ? ' resumed' : ''}`;
|
|
98
|
+
console.log(`${time} ${ev.kind.padEnd(6)} ${(ev.app?.name ?? '').padEnd(22)} ${detail}`);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
if (!res.socket) { console.log('subscribe failed:', JSON.stringify(res)); process.exit(1); }
|
|
102
|
+
console.log('subscribed:', JSON.stringify(res.reply.result));
|
|
103
|
+
setTimeout(() => { res.socket.end(); console.log('\ncounts:', JSON.stringify(counts, null, 1)); process.exit(0); }, seconds * 1000);
|
|
104
|
+
} else {
|
|
105
|
+
throw new Error(`unknown command ${cmd}`);
|
|
106
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Computer History step-0 probe. Answers, per running app and with Accessibility ONLY (no Screen
|
|
2
|
+
// Recording, never enabling AXEnhancedUserInterface / AXManualAccessibility itself):
|
|
3
|
+
// - can we read the focused window title via AX?
|
|
4
|
+
// - do browsers expose the active URL (AXWebArea AXURL or the address field) without enhanced mode?
|
|
5
|
+
// - which AX notifications fire while the user works (ValueChanged for typing, TitleChanged for tabs)?
|
|
6
|
+
// - do NSEvent global monitors (mouse down, modified key down) deliver under Accessibility alone?
|
|
7
|
+
// - which signal (title / menu item / toolbar label / identifier) tells a browser's private window apart?
|
|
8
|
+
// (`privateSignals` per browser row; Safari stays "treated as private" in the recorder until this finds one)
|
|
9
|
+
// Privacy: the report holds booleans, counts, lengths and URL schemes only — never titles, text or hosts.
|
|
10
|
+
// Usage: ch-probe.app/Contents/MacOS/probe <out.json> <liveSeconds> (launch with `open -n -g ... --args`)
|
|
11
|
+
import AppKit
|
|
12
|
+
import ApplicationServices
|
|
13
|
+
import Carbon
|
|
14
|
+
|
|
15
|
+
let args = CommandLine.arguments
|
|
16
|
+
let outPath = args.count > 1 ? args[1] : NSTemporaryDirectory() + "ch-probe.json"
|
|
17
|
+
let liveSeconds = args.count > 2 ? Double(args[2]) ?? 60 : 60
|
|
18
|
+
|
|
19
|
+
func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
|
|
20
|
+
var v: AnyObject?
|
|
21
|
+
return AXUIElementCopyAttributeValue(el, name as CFString, &v) == .success ? v : nil
|
|
22
|
+
}
|
|
23
|
+
func str(_ el: AXUIElement, _ name: String) -> String? { attr(el, name) as? String }
|
|
24
|
+
func children(_ el: AXUIElement) -> [AXUIElement] { (attr(el, kAXChildrenAttribute) as? [AXUIElement]) ?? [] }
|
|
25
|
+
func bool(_ el: AXUIElement, _ name: String) -> Bool? { (attr(el, name) as? NSNumber)?.boolValue }
|
|
26
|
+
|
|
27
|
+
// Bounded BFS: the probe must itself stay cheap (this is exactly the cost question).
|
|
28
|
+
func findWebURL(_ root: AXUIElement) -> (webArea: Bool, url: String?, addressField: Bool, nodes: Int) {
|
|
29
|
+
var queue: [(AXUIElement, Int)] = [(root, 0)]
|
|
30
|
+
var nodes = 0, webArea = false, addressField = false
|
|
31
|
+
var url: String?
|
|
32
|
+
while !queue.isEmpty && nodes < 2500 {
|
|
33
|
+
let (el, depth) = queue.removeFirst()
|
|
34
|
+
nodes += 1
|
|
35
|
+
let role = str(el, kAXRoleAttribute) ?? ""
|
|
36
|
+
if role == "AXWebArea" {
|
|
37
|
+
webArea = true
|
|
38
|
+
if url == nil, let u = attr(el, "AXURL") { url = (u as? URL)?.absoluteString ?? (u as? String) }
|
|
39
|
+
continue // never descend into page content
|
|
40
|
+
}
|
|
41
|
+
if role == "AXTextField" || role == "AXComboBox" {
|
|
42
|
+
let label = ((str(el, kAXDescriptionAttribute) ?? "") + " " + (str(el, "AXIdentifier") ?? "") + " " + (str(el, kAXTitleAttribute) ?? "")).lowercased()
|
|
43
|
+
if label.contains("address") || label.contains("地址") || label.contains("url") || label.contains("search or enter") {
|
|
44
|
+
addressField = true
|
|
45
|
+
if url == nil, let v = str(el, kAXValueAttribute), !v.isEmpty { url = v.contains("://") ? v : "addressbar:" + v }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if depth < 14 { for c in children(el) { queue.append((c, depth + 1)) } }
|
|
49
|
+
}
|
|
50
|
+
return (webArea, url, addressField, nodes)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Private-window signals for browsers (the recorder treats Safari as private until one of these is proven).
|
|
54
|
+
// Run once with a private window in front of the browser and once with a normal window, then compare
|
|
55
|
+
// `privateSignals`. Only booleans, counts and developer identifiers are reported — never titles or labels.
|
|
56
|
+
let privateMarkers = ["private browsing", "private window", "incognito", "inprivate", "无痕", "無痕", "隐私浏览", "私密浏览", "隐身"]
|
|
57
|
+
func hasMarker(_ text: String?) -> Bool {
|
|
58
|
+
guard let lower = text?.lowercased() else { return false }
|
|
59
|
+
return privateMarkers.contains { lower.contains($0) }
|
|
60
|
+
}
|
|
61
|
+
let browserHints = ["safari", "chrome", "firefox", "edgemac", "brave", "vivaldi", "opera", "thebrowser", "kfmac", "duckduckgo", "zen"]
|
|
62
|
+
|
|
63
|
+
func privateSignals(app: AXUIElement, window: AXUIElement) -> [String: Any] {
|
|
64
|
+
var out: [String: Any] = ["titleMarker": hasMarker(str(window, kAXTitleAttribute)), "documentAttr": attr(window, "AXDocument") != nil]
|
|
65
|
+
// Menu bar: e.g. Safari's Window menu says "Move Tab to New Private Window" in a private window.
|
|
66
|
+
var menuItems = 0, menuHits = 0
|
|
67
|
+
if let bar = attr(app, kAXMenuBarAttribute) {
|
|
68
|
+
for top in children(bar as! AXUIElement) {
|
|
69
|
+
for menu in children(top) {
|
|
70
|
+
for item in children(menu) {
|
|
71
|
+
menuItems += 1
|
|
72
|
+
if hasMarker(str(item, kAXTitleAttribute)) { menuHits += 1 }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
out["menuItems"] = menuItems
|
|
78
|
+
out["menuMarkerItems"] = menuHits
|
|
79
|
+
// Window chrome outside web content: which roles / attributes carry a marker, plus developer identifiers.
|
|
80
|
+
var queue: [(AXUIElement, Int)] = [(window, 0)]
|
|
81
|
+
var nodes = 0
|
|
82
|
+
var hits: [String: Int] = [:]
|
|
83
|
+
var identifiers = Set<String>()
|
|
84
|
+
while !queue.isEmpty && nodes < 800 {
|
|
85
|
+
let (el, depth) = queue.removeFirst()
|
|
86
|
+
nodes += 1
|
|
87
|
+
let role = str(el, kAXRoleAttribute) ?? ""
|
|
88
|
+
if role == "AXWebArea" { continue }
|
|
89
|
+
for name in [kAXTitleAttribute, kAXDescriptionAttribute, kAXHelpAttribute, "AXPlaceholderValue"] where hasMarker(str(el, name)) {
|
|
90
|
+
hits["\(role).\(name)", default: 0] += 1
|
|
91
|
+
}
|
|
92
|
+
if let id = str(el, "AXIdentifier"), id.range(of: #"^[A-Za-z0-9_.:-]{1,64}$"#, options: .regularExpression) != nil, identifiers.count < 120 {
|
|
93
|
+
identifiers.insert("\(role)#\(id)")
|
|
94
|
+
}
|
|
95
|
+
if depth < 14 { for c in children(el) { queue.append((c, depth + 1)) } }
|
|
96
|
+
}
|
|
97
|
+
out["chromeNodes"] = nodes
|
|
98
|
+
out["chromeMarkerHits"] = hits
|
|
99
|
+
out["identifiers"] = identifiers.sorted()
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func scheme(_ url: String?) -> String? {
|
|
104
|
+
guard let url else { return nil }
|
|
105
|
+
if url.hasPrefix("addressbar:") { return "addressbar-text" }
|
|
106
|
+
return URL(string: url)?.scheme ?? "unparsed"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if !AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary) {
|
|
110
|
+
// Registers ch-probe in System Settings > Accessibility (unchecked) and shows the system prompt.
|
|
111
|
+
FileManager.default.createFile(atPath: outPath, contents: #"{"axTrusted":false}"#.data(using: .utf8))
|
|
112
|
+
exit(0)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
var snapshot: [[String: Any]] = []
|
|
116
|
+
for app in NSWorkspace.shared.runningApplications where app.activationPolicy == .regular {
|
|
117
|
+
let pid = app.processIdentifier
|
|
118
|
+
let el = AXUIElementCreateApplication(pid)
|
|
119
|
+
AXUIElementSetMessagingTimeout(el, 0.5)
|
|
120
|
+
let started = Date()
|
|
121
|
+
var row: [String: Any] = ["bundleId": app.bundleIdentifier ?? "?", "name": app.localizedName ?? "?"]
|
|
122
|
+
row["enhancedAlreadyOn"] = bool(el, "AXEnhancedUserInterface") as Any
|
|
123
|
+
row["manualAlreadyOn"] = bool(el, "AXManualAccessibility") as Any
|
|
124
|
+
let windows = (attr(el, kAXWindowsAttribute) as? [AXUIElement]) ?? []
|
|
125
|
+
row["windowCount"] = windows.count
|
|
126
|
+
row["windowsWithTitle"] = windows.filter { !(str($0, kAXTitleAttribute) ?? "").isEmpty }.count
|
|
127
|
+
if let fw = attr(el, kAXFocusedWindowAttribute) {
|
|
128
|
+
let w = fw as! AXUIElement
|
|
129
|
+
row["focusedWindowTitleLen"] = (str(w, kAXTitleAttribute) ?? "").count
|
|
130
|
+
let web = findWebURL(w)
|
|
131
|
+
row["webArea"] = web.webArea
|
|
132
|
+
row["addressField"] = web.addressField
|
|
133
|
+
row["urlScheme"] = scheme(web.url) as Any
|
|
134
|
+
row["nodesWalked"] = web.nodes
|
|
135
|
+
let bundle = (app.bundleIdentifier ?? "").lowercased()
|
|
136
|
+
if browserHints.contains(where: { bundle.contains($0) }) { row["privateSignals"] = privateSignals(app: el, window: w) }
|
|
137
|
+
}
|
|
138
|
+
if let fe = attr(el, kAXFocusedUIElementAttribute) {
|
|
139
|
+
let f = fe as! AXUIElement
|
|
140
|
+
row["focusedRole"] = str(f, kAXRoleAttribute) ?? ""
|
|
141
|
+
row["focusedSubrole"] = str(f, kAXSubroleAttribute) ?? ""
|
|
142
|
+
row["focusedValueLen"] = (str(f, kAXValueAttribute) ?? "").count
|
|
143
|
+
row["focusedCharCount"] = (attr(f, kAXNumberOfCharactersAttribute) as? NSNumber)?.intValue as Any
|
|
144
|
+
}
|
|
145
|
+
row["ms"] = Int(Date().timeIntervalSince(started) * 1000)
|
|
146
|
+
snapshot.append(row)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- live phase: observe the frontmost app while the user works ----
|
|
150
|
+
final class Live {
|
|
151
|
+
var counts: [String: [String: Int]] = [:]
|
|
152
|
+
var valueLenSamples: [String: [Int]] = [:]
|
|
153
|
+
var secureInputSeen = 0
|
|
154
|
+
var observer: AXObserver?
|
|
155
|
+
var observedPid: pid_t = 0
|
|
156
|
+
var mouseDowns = 0, modifiedKeys = 0, plainKeys = 0
|
|
157
|
+
func bump(_ bundle: String, _ key: String) { counts[bundle, default: [:]][key, default: 0] += 1 }
|
|
158
|
+
func bundle(of pid: pid_t) -> String { NSRunningApplication(processIdentifier: pid)?.bundleIdentifier ?? "?" }
|
|
159
|
+
|
|
160
|
+
func observeFrontmost() {
|
|
161
|
+
guard let app = NSWorkspace.shared.frontmostApplication else { return }
|
|
162
|
+
let pid = app.processIdentifier
|
|
163
|
+
if pid == observedPid { return }
|
|
164
|
+
if let old = observer { CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(old), .defaultMode) }
|
|
165
|
+
observer = nil
|
|
166
|
+
observedPid = pid
|
|
167
|
+
bump(bundle(of: pid), "activated")
|
|
168
|
+
var obs: AXObserver?
|
|
169
|
+
let cb: AXObserverCallback = { _, element, note, refcon in
|
|
170
|
+
let me = Unmanaged<Live>.fromOpaque(refcon!).takeUnretainedValue()
|
|
171
|
+
var pid: pid_t = 0
|
|
172
|
+
AXUIElementGetPid(element, &pid)
|
|
173
|
+
let b = me.bundle(of: pid)
|
|
174
|
+
me.bump(b, note as String)
|
|
175
|
+
if (note as String) == kAXValueChangedNotification {
|
|
176
|
+
if IsSecureEventInputEnabled() { me.secureInputSeen += 1 }
|
|
177
|
+
let role = str(element, kAXRoleAttribute) ?? ""
|
|
178
|
+
me.bump(b, "value:" + role)
|
|
179
|
+
me.valueLenSamples[b, default: []].append((str(element, kAXValueAttribute) ?? "").count)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
guard AXObserverCreate(pid, cb, &obs) == .success, let obs else { bump(bundle(of: pid), "observerCreateFailed"); return }
|
|
183
|
+
let appEl = AXUIElementCreateApplication(pid)
|
|
184
|
+
AXUIElementSetMessagingTimeout(appEl, 0.5)
|
|
185
|
+
let refcon = Unmanaged.passUnretained(self).toOpaque()
|
|
186
|
+
for n in [kAXFocusedWindowChangedNotification, kAXMainWindowChangedNotification, kAXTitleChangedNotification,
|
|
187
|
+
kAXFocusedUIElementChangedNotification, kAXValueChangedNotification, kAXSelectedTextChangedNotification] {
|
|
188
|
+
let r = AXObserverAddNotification(obs, appEl, n as CFString, refcon)
|
|
189
|
+
if r != .success { bump(bundle(of: pid), "addFail:" + n) }
|
|
190
|
+
}
|
|
191
|
+
CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .defaultMode)
|
|
192
|
+
observer = obs
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let live = Live()
|
|
197
|
+
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { _ in live.observeFrontmost() }
|
|
198
|
+
let mouseMon = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { _ in live.mouseDowns += 1 }
|
|
199
|
+
let keyMon = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { e in
|
|
200
|
+
if !e.modifierFlags.intersection([.command, .control]).isEmpty { live.modifiedKeys += 1 } else { live.plainKeys += 1 }
|
|
201
|
+
}
|
|
202
|
+
live.observeFrontmost()
|
|
203
|
+
|
|
204
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + liveSeconds) {
|
|
205
|
+
var lenStats: [String: Any] = [:]
|
|
206
|
+
for (b, s) in live.valueLenSamples { lenStats[b] = ["n": s.count, "max": s.max() ?? 0] }
|
|
207
|
+
let report: [String: Any] = [
|
|
208
|
+
"axTrusted": AXIsProcessTrusted(),
|
|
209
|
+
"screenRecording": CGPreflightScreenCaptureAccess(),
|
|
210
|
+
"snapshot": snapshot,
|
|
211
|
+
"live": ["seconds": liveSeconds, "notifications": live.counts, "valueLen": lenStats,
|
|
212
|
+
"secureInputDuringValueChange": live.secureInputSeen,
|
|
213
|
+
"globalMouseDownMonitor": mouseMon != nil, "mouseDowns": live.mouseDowns,
|
|
214
|
+
"globalKeyMonitor": keyMon != nil, "modifiedKeyDowns": live.modifiedKeys, "plainKeyDowns": live.plainKeys],
|
|
215
|
+
]
|
|
216
|
+
let data = try! JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys])
|
|
217
|
+
FileManager.default.createFile(atPath: outPath, contents: data)
|
|
218
|
+
exit(0)
|
|
219
|
+
}
|
|
220
|
+
NSApplication.shared.setActivationPolicy(.accessory)
|
|
221
|
+
NSApp.run()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Computer History step-0 probe (macOS). Builds scripts/probe-recorder/probe.swift into a tiny
|
|
3
|
+
// LSUIElement app with its own bundle id, so its Accessibility grant is separate from the helper's and
|
|
4
|
+
// it holds NO Screen Recording grant — the point is to prove what Accessibility alone can read.
|
|
5
|
+
// node scripts/probe-recorder.mjs build -> build/ch-probe.app (Apple Development identity if present, else ad-hoc)
|
|
6
|
+
// node scripts/probe-recorder.mjs run [seconds] -> launches it, waits, prints the JSON report path
|
|
7
|
+
// Grant once: System Settings > Privacy & Security > Accessibility > ch-probe. A stable signing identity
|
|
8
|
+
// keeps that grant across rebuilds; an ad-hoc build needs re-granting after every rebuild.
|
|
9
|
+
// Private-window detection (Safari is recorded as untitled switches only until this is settled): put a private
|
|
10
|
+
// window in front of the browser, `run 5`; then a normal window, `run 5`; compare the rows' `privateSignals`.
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
|
|
16
|
+
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
17
|
+
const app = path.join(root, 'build/ch-probe.app');
|
|
18
|
+
const [cmd = 'build', seconds = '90'] = process.argv.slice(2);
|
|
19
|
+
|
|
20
|
+
if (cmd === 'build') {
|
|
21
|
+
rmSync(app, { recursive: true, force: true });
|
|
22
|
+
mkdirSync(path.join(app, 'Contents/MacOS'), { recursive: true });
|
|
23
|
+
execFileSync('swiftc', ['-O', path.join(root, 'scripts/probe-recorder/probe.swift'), '-o', path.join(app, 'Contents/MacOS/probe')], { stdio: 'inherit' });
|
|
24
|
+
writeFileSync(path.join(app, 'Contents/Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
|
|
25
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
26
|
+
<plist version="1.0"><dict>
|
|
27
|
+
<key>CFBundleIdentifier</key><string>com.forsion.ch-probe</string>
|
|
28
|
+
<key>CFBundleName</key><string>ch-probe</string>
|
|
29
|
+
<key>CFBundleExecutable</key><string>probe</string>
|
|
30
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
31
|
+
<key>LSUIElement</key><true/>
|
|
32
|
+
</dict></plist>`);
|
|
33
|
+
const ids = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' });
|
|
34
|
+
const identity = ids.match(/"(Apple Development:[^"]+)"/)?.[1] ?? '-';
|
|
35
|
+
execFileSync('codesign', ['--force', '--sign', identity, app], { stdio: 'inherit', timeout: 30_000 });
|
|
36
|
+
console.log(`built ${app} (signed: ${identity === '-' ? 'ad-hoc' : identity})`);
|
|
37
|
+
} else if (cmd === 'run') {
|
|
38
|
+
if (!existsSync(app)) throw new Error('run `node scripts/probe-recorder.mjs build` first');
|
|
39
|
+
const out = path.join(os.tmpdir(), `ch-probe-${process.pid}.json`);
|
|
40
|
+
rmSync(out, { force: true });
|
|
41
|
+
execFileSync('open', ['-n', '-g', app, '--args', out, seconds]);
|
|
42
|
+
const deadline = Date.now() + (Number(seconds) + 30) * 1000;
|
|
43
|
+
while (!existsSync(out) && Date.now() < deadline) execFileSync('sleep', ['1']);
|
|
44
|
+
console.log(existsSync(out) ? out : 'probe produced no report (not granted? crashed?)');
|
|
45
|
+
} else {
|
|
46
|
+
throw new Error(`unknown command ${cmd}`);
|
|
47
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 电脑历史采集器(activity_recorder.swift)的单测:照 highlight / mini-foreground 的做法,临时编一个 @main 可执行文件跑一遍。
|
|
3
|
+
// 不需要任何授权;非 macOS 直接跳过。大部分是纯逻辑;订阅流那一组走真实订阅路径 —— 会装上 NSWorkspace 观察者与
|
|
4
|
+
// NSEvent 全局监听、对前台 App 发 AX 读取(没有辅助功能授权时读取失败、键盘监听收不到东西),
|
|
5
|
+
// 并断言最后一个订阅者走后这些全部拆掉。只断言形状,不打印事件内容。
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
|
|
14
|
+
if (process.platform !== 'darwin') {
|
|
15
|
+
console.log('recorder logic check skipped (not macOS)');
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const triple = process.arch === 'x64' ? 'x86_64-apple-macosx14.0' : 'arm64-apple-macosx14.0';
|
|
20
|
+
const temp = mkdtempSync(path.join(os.tmpdir(), 'cu-recorder-logic-'));
|
|
21
|
+
try {
|
|
22
|
+
execFileSync('xcrun', [
|
|
23
|
+
'swiftc', '-target', triple, '-parse-as-library',
|
|
24
|
+
'-module-cache-path', path.join(os.tmpdir(), `tangu-cu-recorder-test-cache-${process.arch}`),
|
|
25
|
+
'-framework', 'AppKit',
|
|
26
|
+
'-framework', 'ApplicationServices',
|
|
27
|
+
path.join(root, 'native/macos/activity_recorder.swift'),
|
|
28
|
+
path.join(root, 'native/macos/activity_recorder_tests.swift'),
|
|
29
|
+
'-o', path.join(temp, 'test'),
|
|
30
|
+
], { stdio: 'inherit' });
|
|
31
|
+
execFileSync(path.join(temp, 'test'), [], { stdio: 'inherit' });
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(temp, { recursive: true, force: true });
|
|
34
|
+
}
|