@forsion/tangu-computer-use 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +223 -0
- package/LICENSE +26 -0
- package/LICENSE.upstream +21 -0
- package/README.md +169 -0
- package/UPSTREAM.md +277 -0
- package/check.mjs +369 -0
- package/icon.png +0 -0
- package/install.sh +112 -0
- package/main.js +541 -0
- package/manifest.json +47 -0
- package/native/linux/bridge-rs/Cargo.lock +1204 -0
- package/native/linux/bridge-rs/Cargo.toml +18 -0
- package/native/linux/bridge-rs/src/atspi.rs +640 -0
- package/native/linux/bridge-rs/src/error.rs +65 -0
- package/native/linux/bridge-rs/src/lib.rs +11 -0
- package/native/linux/bridge-rs/src/main.rs +1074 -0
- package/native/linux/bridge-rs/src/protocol.rs +49 -0
- package/native/linux/bridge-rs/src/state.rs +293 -0
- package/native/linux/bridge-rs/src/wayland.rs +89 -0
- package/native/linux/bridge-rs/src/x11.rs +909 -0
- package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
- package/native/macos/agent_cursor.swift +184 -0
- package/native/macos/agent_cursor_motion.swift +252 -0
- package/native/macos/agent_cursor_tests.swift +128 -0
- package/native/macos/agent_highlight.swift +229 -0
- package/native/macos/agent_highlight_tests.swift +99 -0
- package/native/macos/bridge.swift +3852 -0
- package/native/macos/foreground_activity.swift +57 -0
- package/native/macos/foreground_activity_tests.swift +31 -0
- package/native/macos/live_stream.swift +289 -0
- package/native/windows/bridge-rs/Cargo.lock +396 -0
- package/native/windows/bridge-rs/Cargo.toml +30 -0
- package/native/windows/bridge-rs/src/capture.rs +518 -0
- package/native/windows/bridge-rs/src/error.rs +81 -0
- package/native/windows/bridge-rs/src/input.rs +501 -0
- package/native/windows/bridge-rs/src/lib.rs +16 -0
- package/native/windows/bridge-rs/src/main.rs +1683 -0
- package/native/windows/bridge-rs/src/protocol.rs +53 -0
- package/native/windows/bridge-rs/src/refs.rs +237 -0
- package/native/windows/bridge-rs/src/state.rs +55 -0
- package/native/windows/bridge-rs/src/uia.rs +1252 -0
- package/native/windows/bridge-rs/src/window.rs +718 -0
- package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
- package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
- package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
- package/package.json +74 -0
- package/prebuilt/linux/arm64/linux-bridge +0 -0
- package/prebuilt/linux/x64/linux-bridge +0 -0
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
- 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 +10 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/blind-click.check.mjs +128 -0
- package/scripts/build-native.mjs +302 -0
- package/scripts/build.mjs +84 -0
- package/scripts/calc-fixture.mjs +78 -0
- package/scripts/cli-exit.check.mjs +49 -0
- package/scripts/helper-path.check.mjs +82 -0
- package/scripts/helper-refresh.check.mjs +62 -0
- package/scripts/helper-signal.check.mjs +59 -0
- package/scripts/highlight.check.mjs +32 -0
- package/scripts/keychain-free-install.check.mjs +140 -0
- package/scripts/live-view.check.mjs +124 -0
- package/scripts/macos-bundle.d.mts +1 -0
- package/scripts/macos-bundle.mjs +119 -0
- package/scripts/make-signing-cert.sh +57 -0
- package/scripts/mini-foreground.check.mjs +11 -0
- package/scripts/no-foreground.check.mjs +81 -0
- package/scripts/overlay-visible.check.mjs +221 -0
- package/scripts/package-macos-app.mjs +39 -0
- package/scripts/permissions.check.mjs +199 -0
- package/scripts/platform-contract.check.mjs +30 -0
- package/scripts/setup-helper.mjs +154 -0
- package/scripts/tangu-computer-use.entitlements +10 -0
- package/scripts/verify-macos-bundles.mjs +25 -0
- package/scripts/verify-package.mjs +74 -0
- package/skills/computer-use/SKILL.md +110 -0
- package/src/foregroundNote.ts +43 -0
- package/src/helperState.ts +31 -0
- package/src/index.ts +63 -0
- package/src/onboarding.ts +198 -0
- package/src/pi-compat.ts +49 -0
- package/src/settings.ts +11 -0
- package/src/setup.ts +97 -0
- package/src/tools.ts +289 -0
- package/src/vendor/actions.ts +130 -0
- package/src/vendor/bridge.ts +2405 -0
- package/src/vendor/cdp.ts +658 -0
- package/src/vendor/config.ts +113 -0
- package/src/vendor/contract.ts +104 -0
- package/src/vendor/note.ts +195 -0
- package/src/vendor/outline.ts +651 -0
- package/src/vendor/output.ts +134 -0
- package/src/vendor/permissions.ts +111 -0
- package/src/vendor/platform/architecture.ts +23 -0
- package/src/vendor/platform/coerce.ts +16 -0
- package/src/vendor/platform/index.ts +59 -0
- package/src/vendor/platform/linux/backend.ts +186 -0
- package/src/vendor/platform/linux/helper.ts +238 -0
- package/src/vendor/platform/macos/backend.ts +131 -0
- package/src/vendor/platform/macos/browser.ts +110 -0
- package/src/vendor/platform/macos/helper-path.d.mts +9 -0
- package/src/vendor/platform/macos/helper-path.mjs +34 -0
- package/src/vendor/platform/macos/helper.ts +291 -0
- package/src/vendor/platform/macos/permissions.ts +146 -0
- package/src/vendor/platform/types.ts +222 -0
- package/src/vendor/platform/windows/backend.ts +142 -0
- package/src/vendor/platform/windows/helper.ts +140 -0
- package/src/vendor/root-selection.ts +25 -0
- package/src/vendor/runtime.ts +129 -0
- package/src/vendor/state.ts +146 -0
- package/src/vendor/view.ts +147 -0
- package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
- package/tangu-plugins/computer-use/dist/index.js +5672 -0
- package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
- package/tsconfig.json +22 -0
- package/types/tangu-agent.d.ts +142 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 「虚拟光标 / 边缘光效到底有没有出现在用户屏幕上」的真机仪器。
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要它:此前只有几何单测(agent_cursor_tests / agent_highlight_tests)—— 它们证明**算出来的
|
|
6
|
+
* 坐标**对,却证明不了那个 NSWindow 真的被创建、真的可见、真的盖在目标窗口**上面**。用户报「辅助
|
|
7
|
+
* 鼠标还是没有出现」时,这三件事差得很远,而我们一直只测了第一件。
|
|
8
|
+
*
|
|
9
|
+
* 观测手段:CGWindowListCopyWindowInfo(.optionOnScreenOnly)。
|
|
10
|
+
* - **不需要屏幕录制权限**(只有拿窗口标题才需要),所以任何终端都能跑。
|
|
11
|
+
* - 返回的数组是**前后顺序**(front-to-back),于是 "叠层的下标 < 目标窗口的下标" 就等价于
|
|
12
|
+
* "叠层确实盖在目标之上" —— 这正是 z-order bug 的判定式,光看 isVisible 是看不出来的。
|
|
13
|
+
* - 单点采样会被 linger(光效 2s)和动画时机骗过去,所以整段过程按 100ms **时间线采样**。
|
|
14
|
+
*
|
|
15
|
+
* **它不证明什么**:只看 NSWindow 的创建/层级/z 序与整窗 alpha。SwiftUI 画布画了个空、光标路径为空、
|
|
16
|
+
* 局部坐标算错 —— 这些照样能过。要覆盖那一层需要屏幕录制权限去比像素,本脚本刻意不要求那个权限。
|
|
17
|
+
*
|
|
18
|
+
* 跑法:node scripts/overlay-visible.check.mjs
|
|
19
|
+
* 需要:macOS、helper 已装并授权。会短暂打开「计算器」,跑完关掉。
|
|
20
|
+
*/
|
|
21
|
+
import assert from 'node:assert/strict'
|
|
22
|
+
import { execFile, execFileSync, spawn } from 'node:child_process'
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import { connect } from 'node:net'
|
|
25
|
+
import os from 'node:os'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import { promisify } from 'node:util'
|
|
28
|
+
import { ensureDaemon, freshCalculator, killCalculator, HELPER_EXECUTABLE, SOCK } from './calc-fixture.mjs'
|
|
29
|
+
|
|
30
|
+
const execFileP = promisify(execFile)
|
|
31
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
32
|
+
|
|
33
|
+
if (process.platform !== 'darwin') {
|
|
34
|
+
console.log('overlay check skipped (not macOS)')
|
|
35
|
+
process.exit(0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let seq = 0
|
|
39
|
+
function ask(payload) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const sock = connect(SOCK)
|
|
42
|
+
let buf = ''
|
|
43
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error(`timeout: ${payload.cmd}`)) }, 20_000)
|
|
44
|
+
sock.on('error', (e) => { clearTimeout(timer); reject(e) })
|
|
45
|
+
sock.on('data', (d) => {
|
|
46
|
+
buf += d
|
|
47
|
+
const nl = buf.indexOf('\n')
|
|
48
|
+
if (nl < 0) return
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
sock.end()
|
|
51
|
+
const msg = JSON.parse(buf.slice(0, nl))
|
|
52
|
+
if (!msg.ok) return reject(Object.assign(new Error(msg.error?.message || 'helper error'), { code: msg.error?.code }))
|
|
53
|
+
resolve(msg.result)
|
|
54
|
+
})
|
|
55
|
+
sock.write(`${JSON.stringify({ id: `ov_${++seq}`, ...payload })}\n`)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 探针:按 100ms 采样在屏窗口栈,每次打一行 JSON。数组顺序 = 前后顺序。 ──────────────
|
|
60
|
+
const PROBE_SWIFT = `
|
|
61
|
+
import CoreGraphics
|
|
62
|
+
import Foundation
|
|
63
|
+
|
|
64
|
+
let seconds = Double(CommandLine.arguments[1]) ?? 6
|
|
65
|
+
let deadline = Date().addingTimeInterval(seconds)
|
|
66
|
+
let start = Date()
|
|
67
|
+
while Date() < deadline {
|
|
68
|
+
guard let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { continue }
|
|
69
|
+
var out: [[String: Any]] = []
|
|
70
|
+
for (index, info) in list.enumerated() {
|
|
71
|
+
let b = info[kCGWindowBounds as String] as? [String: Any] ?? [:]
|
|
72
|
+
out.append([
|
|
73
|
+
"z": index,
|
|
74
|
+
"pid": info[kCGWindowOwnerPID as String] as? Int32 ?? 0,
|
|
75
|
+
"owner": info[kCGWindowOwnerName as String] as? String ?? "",
|
|
76
|
+
"windowId": info[kCGWindowNumber as String] as? Int ?? 0,
|
|
77
|
+
"layer": info[kCGWindowLayer as String] as? Int ?? 0,
|
|
78
|
+
"alpha": info[kCGWindowAlpha as String] as? Double ?? 0,
|
|
79
|
+
"w": b["Width"] as? Double ?? 0, "h": b["Height"] as? Double ?? 0,
|
|
80
|
+
])
|
|
81
|
+
}
|
|
82
|
+
let frame: [String: Any] = ["t": Int(Date().timeIntervalSince(start) * 1000), "windows": out]
|
|
83
|
+
print(String(data: try! JSONSerialization.data(withJSONObject: frame), encoding: .utf8)!)
|
|
84
|
+
fflush(stdout)
|
|
85
|
+
Thread.sleep(forTimeInterval: 0.1)
|
|
86
|
+
}
|
|
87
|
+
`
|
|
88
|
+
|
|
89
|
+
const triple = process.arch === 'x64' ? 'x86_64-apple-macosx14.0' : 'arm64-apple-macosx14.0'
|
|
90
|
+
const swiftFile = path.join(os.tmpdir(), `tangu-cu-overlay-probe-${process.pid}.swift`)
|
|
91
|
+
const probeBin = path.join(os.tmpdir(), `tangu-cu-overlay-probe-${process.pid}`)
|
|
92
|
+
fs.writeFileSync(swiftFile, PROBE_SWIFT)
|
|
93
|
+
execFileSync('xcrun', ['swiftc', '-target', triple, '-O',
|
|
94
|
+
'-module-cache-path', path.join(os.tmpdir(), `tangu-cu-overlay-cache-${process.arch}`),
|
|
95
|
+
swiftFile, '-o', probeBin], { stdio: 'inherit' })
|
|
96
|
+
|
|
97
|
+
/** 后台采样 seconds 秒,返回每帧的窗口栈。 */
|
|
98
|
+
function watch(seconds) {
|
|
99
|
+
const child = spawn(probeBin, [String(seconds)])
|
|
100
|
+
const frames = []
|
|
101
|
+
let buf = ''
|
|
102
|
+
child.stdout.on('data', (d) => {
|
|
103
|
+
buf += d
|
|
104
|
+
let nl
|
|
105
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
106
|
+
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
|
|
107
|
+
if (line.trim()) frames.push(JSON.parse(line))
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
return { frames, done: new Promise((r) => child.on('close', r)) }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function helperPid() {
|
|
114
|
+
const out = execFileSync('pgrep', ['-f', HELPER_EXECUTABLE], { encoding: 'utf8' })
|
|
115
|
+
const pid = Number(out.trim().split('\n')[0])
|
|
116
|
+
assert.ok(Number.isFinite(pid) && pid > 0, 'helper daemon 没在跑')
|
|
117
|
+
return pid
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const CURSOR_LEVEL = 102 // NSWindow.Level.popUpMenu + 1(见 agent_cursor.swift)
|
|
121
|
+
const GLOW_LEVEL = 3 // NSWindow.Level.floating
|
|
122
|
+
const VISIBLE_ALPHA = 0.5 // 淡入淡出中的窗口不算"用户看得见"
|
|
123
|
+
const overlaysIn = (windowStack, hpid) => {
|
|
124
|
+
const mine = windowStack.filter((x) => x.pid === hpid && x.alpha > VISIBLE_ALPHA)
|
|
125
|
+
return { cursor: mine.find((x) => x.layer === CURSOR_LEVEL), glow: mine.find((x) => x.layer === GLOW_LEVEL) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 等到屏幕上一个叠层都不剩,再开始测。
|
|
130
|
+
*
|
|
131
|
+
* ⚠️这一步是**假绿灯防线**:光标空闲 8 秒才自动隐藏,而 check:live 会紧接着 check:liveview 跑 ——
|
|
132
|
+
* 上一个仪器留下的光标窗口足以让本次的断言通过,哪怕本次这一下压根没画光标。
|
|
133
|
+
*/
|
|
134
|
+
async function waitForCleanSlate(hpid, timeoutMs = 14_000) {
|
|
135
|
+
const deadline = Date.now() + timeoutMs
|
|
136
|
+
while (Date.now() < deadline) {
|
|
137
|
+
const w = watch(0.15)
|
|
138
|
+
await w.done
|
|
139
|
+
const last = w.frames.at(-1)
|
|
140
|
+
if (last) {
|
|
141
|
+
const { cursor, glow } = overlaysIn(last.windows, hpid)
|
|
142
|
+
if (!cursor && !glow) return
|
|
143
|
+
}
|
|
144
|
+
await sleep(500)
|
|
145
|
+
}
|
|
146
|
+
throw new Error('等叠层散场超时 —— 上一次的光标还挂在屏上,此时测不出本次有没有画')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const findNode = (node, pred) => {
|
|
150
|
+
if (!node) return null
|
|
151
|
+
if (pred(node)) return node
|
|
152
|
+
for (const c of node.children || []) { const hit = findNode(c, pred); if (hit) return hit }
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
// 先发一条命令 —— daemon 是惰性启动的,没连过就没有进程可 pgrep。
|
|
157
|
+
const diag = await ensureDaemon(ask)
|
|
158
|
+
assert.ok(diag.accessibility, '需要「辅助功能」权限')
|
|
159
|
+
const hpid = helperPid()
|
|
160
|
+
|
|
161
|
+
const { calc, win } = await freshCalculator(ask)
|
|
162
|
+
const look = await ask({ cmd: 'look', pid: calc.pid, windowId: win.windowId, includeImage: true })
|
|
163
|
+
const five = findNode(look.outline, (x) => x.role === 'AXButton' && (x.description === '5' || x.title === '5'))
|
|
164
|
+
assert.ok(five, '没找到数字键 5')
|
|
165
|
+
|
|
166
|
+
// 干净起跑线:确认此刻一个叠层都没有,后面「出现了」才归功于这一次操作。
|
|
167
|
+
await waitForCleanSlate(hpid)
|
|
168
|
+
|
|
169
|
+
const w = watch(6)
|
|
170
|
+
await sleep(400) // 这段是基线帧,必须是空的
|
|
171
|
+
const actStartedAt = Date.now()
|
|
172
|
+
// policy=background → helper delivery=pid/ax → 这一路**按设计必须**画虚拟光标 + 边缘光效。
|
|
173
|
+
const acted = await ask({
|
|
174
|
+
cmd: 'act', lookId: look.lookId, pid: calc.pid, policy: 'background',
|
|
175
|
+
cursorOverlay: true, action: 'click',
|
|
176
|
+
target: { x: five.rect.x + five.rect.w / 2, y: five.rect.y + five.rect.h / 2 },
|
|
177
|
+
params: { button: 'left', clickCount: 1 },
|
|
178
|
+
})
|
|
179
|
+
const actMs = Date.now() - actStartedAt
|
|
180
|
+
await w.done
|
|
181
|
+
|
|
182
|
+
console.log(` performed = ${JSON.stringify(acted.performed)}`)
|
|
183
|
+
assert.ok(['ax', 'pid'].includes(acted.performed?.delivery),
|
|
184
|
+
`policy=background 必须走后台投递,实得 delivery=${acted.performed?.delivery}`)
|
|
185
|
+
|
|
186
|
+
// ── 时间线归约 ──────────────────────────────────────────────────────────────
|
|
187
|
+
// ⚠️按 **window level** 分辨,不要按尺寸:关闭中的叠层会带着 alpha≈0.007 淡出好几帧,
|
|
188
|
+
// 一度被误判成"边缘光效已上屏"而给出假绿灯。level 同时正是这次的修复点,一并钉死。
|
|
189
|
+
const calcWinId = win.windowId
|
|
190
|
+
const rows = w.frames.map((f) => {
|
|
191
|
+
const target = f.windows.find((x) => x.windowId === calcWinId)
|
|
192
|
+
return { t: f.t, ...overlaysIn(f.windows, hpid), targetZ: target?.z }
|
|
193
|
+
})
|
|
194
|
+
const baseline = rows.filter((r) => r.t < 400) // act 发出之前
|
|
195
|
+
const after = rows.filter((r) => r.t >= 400 + actMs)
|
|
196
|
+
const glyph = (o, targetZ) => (!o ? '·' : targetZ == null ? '?' : o.z < targetZ ? 'A' : 'b')
|
|
197
|
+
console.log(` 示意光标: ${rows.map((r) => glyph(r.cursor, r.targetZ)).join('')}`)
|
|
198
|
+
console.log(` 边缘光效: ${rows.map((r) => glyph(r.glow, r.targetZ)).join('')}`)
|
|
199
|
+
console.log(` (· 不在屏上 | A 在目标窗口之上 | b 被目标窗口盖住;前 ${baseline.length} 帧是基线)`)
|
|
200
|
+
const sample = rows.find((r) => r.cursor)
|
|
201
|
+
if (sample) console.log(` 示意光标窗口:layer=${sample.cursor.layer} alpha=${sample.cursor.alpha} ${sample.cursor.w}x${sample.cursor.h}`)
|
|
202
|
+
const glowSample = rows.find((r) => r.glow)
|
|
203
|
+
if (glowSample) console.log(` 边缘光效窗口:layer=${glowSample.glow.layer} alpha=${glowSample.glow.alpha} ${glowSample.glow.w}x${glowSample.glow.h}`)
|
|
204
|
+
|
|
205
|
+
// ⚠️基线必须是空的,否则「出现了」可能是上一个仪器留下的窗口(光标空闲 8 秒才隐藏)。
|
|
206
|
+
assert.ok(!baseline.some((r) => r.cursor), '动手之前就有示意光标在屏上 —— 这轮的断言会认错功劳')
|
|
207
|
+
assert.ok(!baseline.some((r) => r.glow), '动手之前就有边缘光效在屏上 —— 这轮的断言会认错功劳')
|
|
208
|
+
|
|
209
|
+
assert.ok(after.some((r) => r.cursor), '这一下之后示意光标没出现 —— AgentCursor 的 NSWindow 没被创建/显示')
|
|
210
|
+
assert.ok(after.some((r) => r.glow), '这一下之后边缘光效没出现 —— AgentHighlight 没被点亮')
|
|
211
|
+
// 命门:窗口在屏上 ≠ 用户看得见。level 没设的话它待在普通层(layer=0),
|
|
212
|
+
// 而宿主是 .accessory 从不激活 → 永远被被操控的那个 App 压在下面 = 用户什么也看不到。
|
|
213
|
+
assert.ok(after.some((r) => r.cursor && r.targetZ != null && r.cursor.z < r.targetZ), '示意光标始终被目标窗口盖住(z-order 在其下)—— 用户看不见,等于没画')
|
|
214
|
+
assert.ok(after.some((r) => r.glow && r.targetZ != null && r.glow.z < r.targetZ), '边缘光效始终被目标窗口盖住(z-order 在其下)—— 用户看不见,等于没画')
|
|
215
|
+
console.log('✅ 叠层可见性:这一下之后两个叠层都新上了屏,且都盖在被操控窗口之上')
|
|
216
|
+
console.log(' (只验证窗口的创建/层级/z 序 —— 画布内容是否真画出了像素,本仪器不覆盖)')
|
|
217
|
+
} finally {
|
|
218
|
+
await killCalculator(ask).catch(() => {})
|
|
219
|
+
fs.rmSync(probeBin, { force: true })
|
|
220
|
+
fs.rmSync(swiftFile, { force: true })
|
|
221
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Build-time only. All private-key signing is opt-in here, never in setup-helper.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { appName, bundleId, sealedFiles, sha256, verifyMacosApp } from './macos-bundle.mjs';
|
|
7
|
+
|
|
8
|
+
export async function packageMacosApp(binary, outputDir, version, signArgs) {
|
|
9
|
+
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'cu-package-'));
|
|
10
|
+
try {
|
|
11
|
+
const app = path.join(temp, appName);
|
|
12
|
+
await fs.mkdir(path.join(app, 'Contents/MacOS'), { recursive: true });
|
|
13
|
+
await fs.copyFile(binary, path.join(app, 'Contents/MacOS/bridge'));
|
|
14
|
+
await fs.chmod(path.join(app, 'Contents/MacOS/bridge'), 0o755);
|
|
15
|
+
await fs.writeFile(path.join(app, 'Contents/Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
|
|
16
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
17
|
+
<plist version="1.0"><dict>
|
|
18
|
+
<key>CFBundleIdentifier</key><string>${bundleId}</string>
|
|
19
|
+
<key>CFBundleName</key><string>tangu-computer-use</string>
|
|
20
|
+
<key>CFBundleDisplayName</key><string>tangu-computer-use</string>
|
|
21
|
+
<key>CFBundleExecutable</key><string>bridge</string>
|
|
22
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
23
|
+
<key>CFBundleShortVersionString</key><string>${version}</string>
|
|
24
|
+
<key>CFBundleVersion</key><string>${version}</string>
|
|
25
|
+
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
|
26
|
+
<key>LSUIElement</key><true/>
|
|
27
|
+
</dict></plist>\n`);
|
|
28
|
+
execFileSync('/usr/bin/codesign', [...signArgs, app], { stdio: 'inherit' });
|
|
29
|
+
verifyMacosApp(app);
|
|
30
|
+
const archive = path.join(temp, `${appName}.zip`);
|
|
31
|
+
execFileSync('/usr/bin/ditto', ['-c', '-k', '--keepParent', app, archive], { stdio: 'inherit' });
|
|
32
|
+
const files = Object.fromEntries(await Promise.all(sealedFiles.map(async file => [file, sha256(await fs.readFile(path.join(app, file)))])));
|
|
33
|
+
const metadata = { bundleId, version, archiveSha256: sha256(await fs.readFile(archive)), files };
|
|
34
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
35
|
+
await fs.copyFile(archive, path.join(outputDir, `${appName}.zip`));
|
|
36
|
+
await fs.writeFile(path.join(outputDir, `${appName}.json`), JSON.stringify(metadata, null, 2) + '\n');
|
|
37
|
+
console.log(`Packaged sealed helper app at ${outputDir}`);
|
|
38
|
+
} finally { await fs.rm(temp, { recursive: true, force: true }); }
|
|
39
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Compile the actual permission methods against fake OS APIs. Never launch the
|
|
3
|
+
// installed helper, query TCC, request capture, or open System Settings in tests.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const source = fs.readFileSync(path.join(root, 'native/macos/bridge.swift'), 'utf8');
|
|
13
|
+
function method(name) {
|
|
14
|
+
const start = source.indexOf(`\tprivate func ${name}(`);
|
|
15
|
+
assert.ok(start >= 0, `missing native method: ${name}`);
|
|
16
|
+
const end = source.indexOf('\n\t}', start);
|
|
17
|
+
assert.ok(end > start, `missing method end: ${name}`);
|
|
18
|
+
return source.slice(start, end + 4).replace('private func', 'func');
|
|
19
|
+
}
|
|
20
|
+
const dispatchStart = source.indexOf('\t\tcase "permissionStatus":');
|
|
21
|
+
const dispatchEnd = source.indexOf('\t\tcase "openPermissionPane":', dispatchStart);
|
|
22
|
+
assert.ok(dispatchStart >= 0 && dispatchEnd > dispatchStart, 'permission command dispatch must exist');
|
|
23
|
+
const dispatch = source.slice(dispatchStart, dispatchEnd);
|
|
24
|
+
const methods = ['permissionStatus', 'systemSettingsWindowBounds', 'checkPermissions', 'registerPermissions', 'boolArg', 'stringArg'].map(method).join('\n');
|
|
25
|
+
|
|
26
|
+
if (process.platform !== 'darwin') {
|
|
27
|
+
console.log('native permission checks skipped (not macOS)');
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const harness = String.raw`
|
|
32
|
+
import Foundation
|
|
33
|
+
import CoreGraphics
|
|
34
|
+
|
|
35
|
+
struct BridgeFailure: Error { let message: String; let code: String }
|
|
36
|
+
struct App { let bundleIdentifier: String; let processIdentifier: Int32 }
|
|
37
|
+
final class NSWorkspace {
|
|
38
|
+
static let shared = NSWorkspace()
|
|
39
|
+
var frontmostApplication: App?
|
|
40
|
+
var runningApplications: [App] = []
|
|
41
|
+
}
|
|
42
|
+
enum Fake {
|
|
43
|
+
static var ax = false
|
|
44
|
+
static var preflight = false
|
|
45
|
+
static var capturable = false
|
|
46
|
+
static var axReads = 0
|
|
47
|
+
static var preflightReads = 0
|
|
48
|
+
static var axRequests = 0
|
|
49
|
+
static var screenRequests = 0
|
|
50
|
+
static var probes = 0
|
|
51
|
+
static var windowReads = 0
|
|
52
|
+
static var windows: [[String: Any]] = []
|
|
53
|
+
static func reset() {
|
|
54
|
+
ax = false; preflight = false; capturable = false
|
|
55
|
+
axReads = 0; preflightReads = 0; axRequests = 0; screenRequests = 0; probes = 0; windowReads = 0
|
|
56
|
+
windows = []
|
|
57
|
+
NSWorkspace.shared.frontmostApplication = nil
|
|
58
|
+
NSWorkspace.shared.runningApplications = []
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let kAXTrustedCheckOptionPrompt = Unmanaged.passRetained("prompt" as CFString)
|
|
62
|
+
func AXIsProcessTrusted() -> Bool { Fake.axReads += 1; return Fake.ax }
|
|
63
|
+
func CGPreflightScreenCaptureAccess() -> Bool { Fake.preflightReads += 1; return Fake.preflight }
|
|
64
|
+
func AXIsProcessTrustedWithOptions(_ options: CFDictionary) -> Bool {
|
|
65
|
+
precondition((options as NSDictionary)["prompt"] as? Bool == true)
|
|
66
|
+
Fake.axRequests += 1; return Fake.ax
|
|
67
|
+
}
|
|
68
|
+
func CGRequestScreenCaptureAccess() -> Bool { Fake.screenRequests += 1; return Fake.preflight }
|
|
69
|
+
func CGWindowListCopyWindowInfo(_ options: CGWindowListOption, _ window: CGWindowID) -> CFArray? {
|
|
70
|
+
precondition(options.contains(.optionOnScreenOnly) && window == kCGNullWindowID)
|
|
71
|
+
Fake.windowReads += 1; return Fake.windows as CFArray
|
|
72
|
+
}
|
|
73
|
+
final class Bridge {
|
|
74
|
+
let permissionCacheLock = NSLock()
|
|
75
|
+
var grantedPermissionStatus: [String: Any]?
|
|
76
|
+
func permissionSource() -> [String: Any] { ["attribution": "helper-app", "pid": 123] }
|
|
77
|
+
func screenRecordingCapturable() -> Bool { Fake.probes += 1; return Fake.capturable }
|
|
78
|
+
func command(_ request: [String: Any]) throws -> [String: Any] {
|
|
79
|
+
let cmd = try stringArg(request, "cmd")
|
|
80
|
+
switch cmd {
|
|
81
|
+
${dispatch}
|
|
82
|
+
default: throw BridgeFailure(message: "Unexpected command", code: "unknown_command")
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
${methods}
|
|
86
|
+
}
|
|
87
|
+
func window(_ pid: Int32, _ rect: CGRect, layer: Int = 0, visible: Bool = true) -> [String: Any] {
|
|
88
|
+
// No title: this is also the metadata shape before Screen Recording is granted.
|
|
89
|
+
[kCGWindowOwnerPID as String: pid, kCGWindowLayer as String: layer,
|
|
90
|
+
kCGWindowIsOnscreen as String: visible, kCGWindowBounds as String: rect.dictionaryRepresentation]
|
|
91
|
+
}
|
|
92
|
+
var passed = 0
|
|
93
|
+
func check(_ condition: @autoclosure () -> Bool, _ label: String) {
|
|
94
|
+
guard condition() else { fatalError(label) }
|
|
95
|
+
passed += 1
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Passive status ignores a previous positive capture cache and never prompts.
|
|
99
|
+
Fake.reset()
|
|
100
|
+
let passive = Bridge()
|
|
101
|
+
passive.grantedPermissionStatus = ["accessibility": true, "screenRecording": true]
|
|
102
|
+
Fake.preflight = true
|
|
103
|
+
let status1 = try passive.command(["cmd": "permissionStatus"])
|
|
104
|
+
Fake.preflight = false
|
|
105
|
+
let status2 = try passive.command(["cmd": "permissionStatus"])
|
|
106
|
+
check(status1["accessibility"] as? Bool == false, "status must not use capture cache")
|
|
107
|
+
check(status1["screenRecordingPreflight"] as? Bool == true && status2["screenRecordingPreflight"] as? Bool == false, "status must reread preflight")
|
|
108
|
+
check(Fake.axReads == 2 && Fake.preflightReads == 2, "both preflights run on every status")
|
|
109
|
+
check(Fake.probes == 0 && Fake.axRequests == 0 && Fake.screenRequests == 0, "passive status has no capture or request side effects")
|
|
110
|
+
check(status1["screenRecording"] == nil && status1["screenRecordingCapturable"] == nil, "preflight must not be presented as verified capture")
|
|
111
|
+
check((status1["source"] as? [String: Any])?["attribution"] as? String == "helper-app", "status preserves permission identity")
|
|
112
|
+
check(status1["settingsWindow"] == nil && status1["settingsFrontmost"] as? Bool == false, "absent Settings has no invented bounds")
|
|
113
|
+
|
|
114
|
+
// Only the largest visible layer-zero window of System Settings is selected.
|
|
115
|
+
let settings = App(bundleIdentifier: "com.apple.systempreferences", processIdentifier: 42)
|
|
116
|
+
NSWorkspace.shared.runningApplications = [App(bundleIdentifier: "other.app", processIdentifier: 8), settings]
|
|
117
|
+
NSWorkspace.shared.frontmostApplication = settings
|
|
118
|
+
let wanted = CGRect(x: -1000, y: -200, width: 850, height: 700)
|
|
119
|
+
Fake.windows = [
|
|
120
|
+
window(8, CGRect(x: 0, y: 0, width: 5000, height: 5000)),
|
|
121
|
+
window(42, CGRect(x: 0, y: 0, width: 5000, height: 5000), layer: 1),
|
|
122
|
+
window(42, CGRect(x: 0, y: 0, width: 5000, height: 5000), visible: false),
|
|
123
|
+
window(42, CGRect(x: 0, y: 0, width: 0, height: 5000)),
|
|
124
|
+
window(42, CGRect(x: CGFloat.infinity, y: 0, width: 5000, height: 5000)),
|
|
125
|
+
window(42, CGRect(x: 10, y: 20, width: 300, height: 200)),
|
|
126
|
+
window(42, wanted),
|
|
127
|
+
]
|
|
128
|
+
let windowStatus = try passive.command(["cmd": "permissionStatus"])
|
|
129
|
+
let bounds = windowStatus["settingsWindow"] as? [String: CGFloat]
|
|
130
|
+
check(bounds == ["x": -1000, "y": -200, "width": 850, "height": 700], "Settings bounds must use PID/layer/visibility and preserve CG coordinates")
|
|
131
|
+
check(windowStatus["settingsFrontmost"] as? Bool == true, "frontmost uses bundle identity")
|
|
132
|
+
check(Fake.probes == 0 && Fake.axRequests == 0 && Fake.screenRequests == 0, "window metadata requires no grants")
|
|
133
|
+
NSWorkspace.shared.frontmostApplication = App(bundleIdentifier: "other.app", processIdentifier: 8)
|
|
134
|
+
let behind = try passive.command(["cmd": "permissionStatus"])
|
|
135
|
+
check(behind["settingsWindow"] != nil && behind["settingsFrontmost"] as? Bool == false, "visibility and frontmost are separate")
|
|
136
|
+
Fake.windows = []
|
|
137
|
+
let closed = try passive.command(["cmd": "permissionStatus"])
|
|
138
|
+
check(closed["settingsWindow"] == nil, "closed Settings window is omitted")
|
|
139
|
+
|
|
140
|
+
// Single-kind request isolation; malformed kinds fail before any OS operation.
|
|
141
|
+
Fake.reset()
|
|
142
|
+
let requests = Bridge()
|
|
143
|
+
let axRequest = try requests.command(["cmd": "registerPermissions", "kind": "accessibility"])
|
|
144
|
+
check(Fake.axRequests == 1 && Fake.screenRequests == 0 && Fake.probes == 0, "AX-only request must not request/probe screen capture")
|
|
145
|
+
check(Set(axRequest.keys) == ["accessibility"], "AX-only response cannot claim screen permission")
|
|
146
|
+
Fake.reset()
|
|
147
|
+
let screenRequest = try requests.command(["cmd": "registerPermissions", "kind": "screenRecording"])
|
|
148
|
+
check(Fake.screenRequests == 1 && Fake.axRequests == 0 && Fake.axReads == 0 && Fake.probes == 0, "screen-only request must not touch AX or capture probe")
|
|
149
|
+
check(Set(screenRequest.keys) == ["screenRecording"], "screen request returns request result only")
|
|
150
|
+
for kind: Any in ["invalid", "", 42, NSNull()] {
|
|
151
|
+
Fake.reset()
|
|
152
|
+
do {
|
|
153
|
+
_ = try requests.command(["cmd": "registerPermissions", "kind": kind])
|
|
154
|
+
fatalError("invalid permission kind was accepted")
|
|
155
|
+
} catch let error as BridgeFailure {
|
|
156
|
+
check(error.code == "invalid_args", "invalid kind error code")
|
|
157
|
+
}
|
|
158
|
+
check(Fake.axRequests == 0 && Fake.screenRequests == 0 && Fake.probes == 0, "invalid kind must have no side effects")
|
|
159
|
+
}
|
|
160
|
+
Fake.reset()
|
|
161
|
+
Fake.ax = true; Fake.preflight = true; Fake.capturable = true
|
|
162
|
+
let legacy = try requests.command(["cmd": "registerPermissions"])
|
|
163
|
+
check(Fake.axRequests == 1 && Fake.screenRequests == 1 && Fake.probes == 1, "omitted kind retains both legacy requests and probe")
|
|
164
|
+
check(legacy["accessibility"] as? Bool == true && legacy["screenRecordingCapturable"] as? Bool == true, "legacy response preserved")
|
|
165
|
+
|
|
166
|
+
// Fresh validation must invalidate an old success even when the new check fails.
|
|
167
|
+
Fake.reset()
|
|
168
|
+
Fake.ax = true; Fake.preflight = true; Fake.capturable = true
|
|
169
|
+
let validator = Bridge()
|
|
170
|
+
_ = try validator.command(["cmd": "checkPermissions"])
|
|
171
|
+
Fake.capturable = false
|
|
172
|
+
let cached = try validator.command(["cmd": "checkPermissions", "fresh": false])
|
|
173
|
+
check(cached["screenRecording"] as? Bool == true && Fake.probes == 1, "legacy successful checks remain cached")
|
|
174
|
+
let revoked = try validator.command(["cmd": "checkPermissions", "fresh": true])
|
|
175
|
+
check(revoked["screenRecordingPreflight"] as? Bool == true && revoked["screenRecordingCapturable"] as? Bool == false, "fresh bypasses success despite stale preflight")
|
|
176
|
+
check(validator.grantedPermissionStatus == nil && Fake.probes == 2, "failed fresh check removes stale success")
|
|
177
|
+
_ = try validator.command(["cmd": "checkPermissions"])
|
|
178
|
+
check(Fake.probes == 3, "failure is not cached")
|
|
179
|
+
Fake.capturable = true
|
|
180
|
+
_ = try validator.command(["cmd": "checkPermissions", "fresh": true])
|
|
181
|
+
_ = try validator.command(["cmd": "checkPermissions"])
|
|
182
|
+
check(Fake.probes == 4 && validator.grantedPermissionStatus != nil, "successful fresh check refreshes cache")
|
|
183
|
+
Fake.ax = false
|
|
184
|
+
_ = try validator.command(["cmd": "checkPermissions", "fresh": true])
|
|
185
|
+
check(validator.grantedPermissionStatus == nil, "AX revocation also invalidates success")
|
|
186
|
+
print("permission checks passed: \(passed) assertions (fake OS APIs; no installed helper launched)")
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'tangu-cu-permission-tests-'));
|
|
190
|
+
try {
|
|
191
|
+
const swift = path.join(temporary, 'main.swift');
|
|
192
|
+
const binary = path.join(temporary, 'permissions-tests');
|
|
193
|
+
fs.writeFileSync(swift, harness);
|
|
194
|
+
const triple = process.arch === 'x64' ? 'x86_64-apple-macosx14.0' : 'arm64-apple-macosx14.0';
|
|
195
|
+
execFileSync('xcrun', ['swiftc', '-target', triple, '-module-cache-path', path.join(os.tmpdir(), `tangu-cu-permission-test-cache-${process.arch}`), swift, '-o', binary], { cwd: root, stdio: 'inherit' });
|
|
196
|
+
execFileSync(binary, [], { cwd: root, stdio: 'inherit' });
|
|
197
|
+
} finally {
|
|
198
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
199
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Offline platform contract: register definitions only, never run native input or install helpers.
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
if (!process.env.CU_CONTRACT_PLATFORM) {
|
|
7
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
8
|
+
const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], {
|
|
9
|
+
env: { ...process.env, CU_CONTRACT_PLATFORM: platform }, encoding: 'utf8', timeout: 15_000,
|
|
10
|
+
});
|
|
11
|
+
assert.equal(r.status, 0, r.stderr || r.error?.message);
|
|
12
|
+
process.stdout.write(r.stdout);
|
|
13
|
+
}
|
|
14
|
+
} else {
|
|
15
|
+
const platform = process.env.CU_CONTRACT_PLATFORM;
|
|
16
|
+
Object.defineProperty(process, 'platform', { value: platform });
|
|
17
|
+
let meta;
|
|
18
|
+
const plugin = (await import('../tangu-plugins/computer-use/dist/index.js')).default;
|
|
19
|
+
plugin.activate({ registerPlugin: (m) => { meta = m; }, registerCommand() {}, log() {}, sdk: { pluginStore: { isPluginEnabledSync: () => true } } });
|
|
20
|
+
const visible = meta.toolProvider.tools().filter((t) => t.isEnabledFor({ capabilities: { hostExec: true } }));
|
|
21
|
+
for (const name of ['find_roots', 'observe_ui', 'search_ui', 'expand_ui', 'inspect_ui', 'act_ui']) assert(visible.some((t) => t.name === name), `${platform}: missing ${name}`);
|
|
22
|
+
assert.equal(visible.some((t) => t.name === 'ensure_app'), platform === 'darwin');
|
|
23
|
+
const prompt = meta.promptSection({ execMode: 'host' });
|
|
24
|
+
assert.match(prompt, /already available|directly callable/i);
|
|
25
|
+
if (platform !== 'darwin') assert.doesNotMatch(prompt, /call ensure_app first/);
|
|
26
|
+
const skill = readFileSync(new URL('../skills/computer-use/SKILL.md', import.meta.url), 'utf8');
|
|
27
|
+
assert.match(skill, /macOS only/);
|
|
28
|
+
assert.match(skill, /Windows.*Linux/);
|
|
29
|
+
console.log(`${platform}: Computer Use tool and prompt contract passed`);
|
|
30
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// macOS runtime setup only installs sealed apps. Certificate discovery/import,
|
|
3
|
+
// private-key signing and native compilation belong exclusively to build time.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { constants as fsConstants, realpathSync } from "node:fs";
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { resolveMacosHelperAppPath } from "../src/vendor/platform/macos/helper-path.mjs";
|
|
12
|
+
import { installMacosApp, macosHelperIsCurrent } from "./macos-bundle.mjs";
|
|
13
|
+
|
|
14
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const helperAppPath = resolveMacosHelperAppPath();
|
|
16
|
+
const windowsCrateDir = path.join(rootDir, "native", "windows", "bridge-rs");
|
|
17
|
+
const windowsHelperDestPath = process.env.PI_COMPUTER_USE_WINDOWS_HELPER_PATH || path.join(os.homedir(), ".pi", "agent", "helpers", "tangu-computer-use", "windows-bridge.exe");
|
|
18
|
+
const linuxCrateDir = path.join(rootDir, "native", "linux", "bridge-rs");
|
|
19
|
+
const linuxHelperDestPath = process.env.PI_COMPUTER_USE_LINUX_HELPER_PATH || path.join(os.homedir(), ".pi", "agent", "helpers", "tangu-computer-use", "linux-bridge");
|
|
20
|
+
const args = new Set(process.argv.slice(2));
|
|
21
|
+
const isPostinstall = args.has("--postinstall");
|
|
22
|
+
const allowBuildFallback = args.has("--allow-build") || args.has("--runtime") || process.env.PI_COMPUTER_USE_ALLOW_BUILD === "1";
|
|
23
|
+
const allowLinuxBuildFallback = args.has("--allow-build") || process.env.PI_COMPUTER_USE_ALLOW_BUILD === "1";
|
|
24
|
+
|
|
25
|
+
function getArg(name) {
|
|
26
|
+
const index = process.argv.indexOf(name);
|
|
27
|
+
if (index >= 0 && index + 1 < process.argv.length) return process.argv[index + 1];
|
|
28
|
+
}
|
|
29
|
+
function normalizeArch(arch) {
|
|
30
|
+
if (arch === "arm64" || arch === "x64") return arch;
|
|
31
|
+
throw new Error(`Unsupported architecture '${arch}'. Supported: arm64, x64.`);
|
|
32
|
+
}
|
|
33
|
+
async function exists(filePath) {
|
|
34
|
+
try { await fs.access(filePath, fsConstants.F_OK); return true; } catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
async function hashFile(filePath) {
|
|
37
|
+
return createHash("sha256").update(await fs.readFile(filePath)).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function copyIfChanged(sourcePath, destinationPath) {
|
|
41
|
+
const destinationExists = await exists(destinationPath);
|
|
42
|
+
if (destinationExists) {
|
|
43
|
+
const [sourceHash, destinationHash] = await Promise.all([hashFile(sourcePath), hashFile(destinationPath)]);
|
|
44
|
+
if (sourceHash === destinationHash) {
|
|
45
|
+
await fs.chmod(destinationPath, 0o755);
|
|
46
|
+
return { changed: false };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
51
|
+
const tempPath = `${destinationPath}.tmp-${process.pid}-${Date.now()}`;
|
|
52
|
+
await fs.copyFile(sourcePath, tempPath);
|
|
53
|
+
await fs.chmod(tempPath, 0o755);
|
|
54
|
+
try {
|
|
55
|
+
await fs.rename(tempPath, destinationPath);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
await fs.rm(tempPath, { force: true }).catch(() => {});
|
|
58
|
+
if (err.code === "EPERM") {
|
|
59
|
+
throw new Error(`Cannot update helper at ${destinationPath} — the existing helper process is still running. Close the helper process and re-run this script.`);
|
|
60
|
+
}
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
return { changed: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function run(command, commandArgs) {
|
|
67
|
+
await new Promise((resolve, reject) => {
|
|
68
|
+
const child = spawn(command, commandArgs, { stdio: "inherit" });
|
|
69
|
+
child.on("error", reject);
|
|
70
|
+
child.on("close", (code) => {
|
|
71
|
+
if (code === 0) {
|
|
72
|
+
resolve();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
reject(new Error(`Command failed (${code}): ${command} ${commandArgs.join(" ")}`));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function windowsBinaryPath() {
|
|
81
|
+
const releaseDir = path.join(windowsCrateDir, "target", "release");
|
|
82
|
+
return {
|
|
83
|
+
exePath: path.join(releaseDir, "windows-bridge.exe"),
|
|
84
|
+
binPath: path.join(releaseDir, "windows-bridge"),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function setupWindowsHelper() {
|
|
89
|
+
const prebuiltPath = path.join(rootDir, "prebuilt", "windows", "windows-bridge.exe");
|
|
90
|
+
if (await exists(prebuiltPath)) {
|
|
91
|
+
const { changed } = await copyIfChanged(prebuiltPath, windowsHelperDestPath);
|
|
92
|
+
console.log(changed
|
|
93
|
+
? `[tangu-computer-use] installed Windows helper from prebuilt to ${windowsHelperDestPath}`
|
|
94
|
+
: `[tangu-computer-use] Windows helper already up to date at ${windowsHelperDestPath}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (allowBuildFallback) {
|
|
99
|
+
console.log("[tangu-computer-use] Windows prebuilt helper missing; attempting source build with cargo...");
|
|
100
|
+
await run("cargo", ["build", "--release", "--manifest-path", path.join(windowsCrateDir, "Cargo.toml")]);
|
|
101
|
+
const { exePath, binPath } = windowsBinaryPath();
|
|
102
|
+
const cargoOutput = (await exists(exePath)) ? exePath : (await exists(binPath)) ? binPath : exePath;
|
|
103
|
+
const { changed } = await copyIfChanged(cargoOutput, windowsHelperDestPath);
|
|
104
|
+
console.log(changed
|
|
105
|
+
? `[tangu-computer-use] built and installed Windows helper at ${windowsHelperDestPath}`
|
|
106
|
+
: `[tangu-computer-use] Windows helper already up to date at ${windowsHelperDestPath}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error(
|
|
111
|
+
`No Windows prebuilt helper found at ${prebuiltPath}. ` +
|
|
112
|
+
"Run 'node scripts/build-native.mjs --platform windows' to build, or set PI_COMPUTER_USE_ALLOW_BUILD=1 to build at install time.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function setupLinuxHelper() {
|
|
117
|
+
const arch = normalizeArch(process.arch);
|
|
118
|
+
const prebuiltPath = path.join(rootDir, "prebuilt", "linux", arch, "linux-bridge");
|
|
119
|
+
if (await exists(prebuiltPath)) {
|
|
120
|
+
const { changed } = await copyIfChanged(prebuiltPath, linuxHelperDestPath);
|
|
121
|
+
console.log(changed ? `[tangu-computer-use] installed Linux helper (${arch}) from prebuilt to ${linuxHelperDestPath}` : `[tangu-computer-use] Linux helper already up to date at ${linuxHelperDestPath}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (allowLinuxBuildFallback) {
|
|
125
|
+
if (process.platform !== "linux") throw new Error("The Linux helper source fallback must be built on Linux.");
|
|
126
|
+
console.log("[tangu-computer-use] Linux prebuilt helper missing; attempting source build with cargo...");
|
|
127
|
+
await run("cargo", ["build", "--release", "--manifest-path", path.join(linuxCrateDir, "Cargo.toml")]);
|
|
128
|
+
const cargoOutput = path.join(linuxCrateDir, "target", "release", "linux-bridge");
|
|
129
|
+
const { changed } = await copyIfChanged(cargoOutput, linuxHelperDestPath);
|
|
130
|
+
console.log(changed ? `[tangu-computer-use] built and installed Linux helper at ${linuxHelperDestPath}` : `[tangu-computer-use] Linux helper already up to date at ${linuxHelperDestPath}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`No Linux prebuilt helper found for ${arch} at ${prebuiltPath}. Run node scripts/build-native.mjs --platform linux to build, or set PI_COMPUTER_USE_ALLOW_BUILD=1 to build at install time.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function setup() {
|
|
137
|
+
const explicitPlatform = getArg("--platform");
|
|
138
|
+
if (explicitPlatform === "windows" || (!explicitPlatform && process.platform === "win32")) return setupWindowsHelper();
|
|
139
|
+
if (explicitPlatform === "linux" || (!explicitPlatform && process.platform === "linux")) return setupLinuxHelper();
|
|
140
|
+
if (process.platform !== "darwin") throw new Error("Computer Use helper setup requires macOS, Windows or Linux.");
|
|
141
|
+
if (args.has("--check")) {
|
|
142
|
+
// 10 = missing, stale or damaged. Other nonzero exits are package errors.
|
|
143
|
+
process.exitCode = macosHelperIsCurrent(rootDir, helperAppPath) ? 0 : 10;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const changed = await installMacosApp(rootDir, helperAppPath);
|
|
147
|
+
console.log(`[tangu-computer-use] ${changed ? "installed pre-signed" : "verified current"} helper app at ${helperAppPath}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isMain = process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url));
|
|
151
|
+
if (isMain) setup().catch(error => {
|
|
152
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
153
|
+
process.exitCode = isPostinstall ? 0 : 1;
|
|
154
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>com.apple.security.device.screen-capture</key>
|
|
6
|
+
<true/>
|
|
7
|
+
<key>com.apple.security.automation.apple-events</key>
|
|
8
|
+
<true/>
|
|
9
|
+
</dict>
|
|
10
|
+
</plist>
|