fix(computer-use): harden native macOS automation runtime

This commit is contained in:
程序员阿江(Relakkes)
2026-09-01 20:06:25 +08:00
parent ad7321f7cc
commit 8a37865536
44 changed files with 2317 additions and 281 deletions
+2 -2
View File
@@ -170,7 +170,7 @@ socket, which avoids a connect race against `bind()`/`listen()`.
| Verb | Effect | `result` |
|----------------|-----------------------------------------------------------------------------------------|----------|
| `overlay_show` | `cursor.show()` + `glow.show(over: frontmost app)`. Reveals the virtual cursor + glow. | `true` |
| `overlay_hide` | `cursor.hide()` + `glow.hide(animated: true)`. Parks the cursor, fades the glow. | `true` |
| `overlay_hide` | Parks the cursor and resets turn-owned AX/input/focus state. The keyed `SCStream` remains warm until target/config change, disconnect, or daemon teardown. | `true` |
| `ping` | Liveness probe. | `"pong"` |
| `shutdown` | Returns `true`, then `NSApp.terminate(nil)` for a graceful exit. | `true` |
@@ -263,7 +263,7 @@ invocation*, swapping the Python interpreter + `mac_helper.py` for the signed
- **bundled (Tauri):** the sidecar resolved from `binaries/cu-helper` (§3.5).
`pythonBridge.ts` is **not** edited; the Windows path (`win_helper.py`) is
untouched (this helper is macOS-only — `Package.swift` targets `.macOS("14.0")`).
untouched (this helper is macOS-only — `Package.swift` targets `.macOS("14.4")`).
### 3.3 `src/utils/computerUse/wrapper.tsx` — `acquireCuLock` fresh branch
+1 -1
View File
@@ -32,7 +32,7 @@
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<string>14.4</string>
<key>NSScreenCaptureUsageDescription</key>
<string>Claude 需要屏幕录制权限来截取屏幕,以便在你的电脑上执行操作。</string>
</dict>
+5 -5
View File
@@ -16,15 +16,15 @@
// (Swift 6.3.2 / Xcode 26.5 / macOS 26.4.1, Apple Silicon arm64) compiles the
// `@MainActor` AppKit/ScreenCaptureKit code under full isolation checking.
//
// platforms .macOS("14.0"): SCShareableContent / SCContentFilter /
// SCScreenshotManager (the modern screenshot path) require macOS 14+. The build
// host (26.5) far exceeds this; the floor only constrains the availability
// annotations the capture code must carry.
// platforms .macOS("14.4"): match the reference Computer Use service's runtime
// floor. Keeping this subsystem floor above the desktop host's floor lets the
// app show a deterministic unsupported state instead of launching a helper the
// OS loader will reject.
import PackageDescription
let package = Package(
name: "cu-helper",
platforms: [.macOS("14.0")],
platforms: [.macOS("14.4")],
targets: [
// Tiny C shim exposing the private `responsibility_spawnattrs_setdisclaim`
// self-re-exec (see Sources/CDisclaim/disclaim.c). Lets cu-helper become its
@@ -105,7 +105,6 @@ public final class CommandRouter {
Self.lastCaptureDigest.removeAll()
MutationClock.reset()
ClipboardPasteReceipt.resetForTurn()
windowCaptureProvider?.invalidate()
// Apps we told they were focused must be told they are not, or the
// belief outlives the session that needed it.
SyntheticWindowFocus.relinquishAll()
@@ -221,6 +221,7 @@ public final class Daemon {
// never strand a stuck modifier/button when teardown is followed
// immediately by exit() (shutdown verb + signal handlers).
Injection.releaseAllHeldSync()
router.invalidateWindowCaptureStream()
router.resetSessionState()
displaySleepAssertion.release()
inputMonitor.stop()
@@ -437,6 +438,7 @@ public final class Daemon {
private func cleanupDisconnectedSession() {
stopOverlaySession()
Injection.releaseAllHeldSync()
router.invalidateWindowCaptureStream()
router.resetSessionState()
turnGate.reset()
displaySleepAssertion.release()
@@ -603,12 +605,13 @@ public final class Daemon {
explicitOverlayTarget = nil
Injection.clearResolvedTarget()
cursor.hide()
router.invalidateWindowCaptureStream()
}
/// Codex-parity turn boundary. The helper process stays warm, but no AX
/// snapshot, coordinate transform, focus belief, held input, mutation clock,
/// clipboard diagnostic, or capture stream may leak into the next turn.
/// Codex-parity turn boundary. The helper process and its SCStream consumer
/// stay warm, while AX snapshots, coordinate transforms, focus belief,
/// held input, mutation clocks, and clipboard diagnostics are reset. The
/// stream key itself proves process/window/config identity and retires on
/// any target change, screen reconfiguration, disconnect, or shutdown.
private func endTurn() {
stopOverlaySession()
Injection.releaseAllHeldSync()
@@ -215,21 +215,27 @@ final class WindowCaptureStreamManager: WindowCaptureProviding {
}
/// Match the reference's two separate lifetimes: SCStream remains a
/// consumer while covered; every state read runs an on-demand Skyshot/SCK
/// capture. An idle stream's cached frame is not evidence of the current UI.
/// daemon-lifetime consumer while covered; every state read runs an
/// on-demand Skyshot/SCK capture. The stream must have produced a real
/// pixel frame before its on-demand screenshot may be treated as live.
func captureSnapshot(
for target: WindowCaptureStreamTarget,
scale: Double,
newerThanUptime: TimeInterval? = nil
) async -> WindowShot? {
if let newerThanUptime {
// The stream is a long-lived render/freshness consumer, not the
// model screenshot source. Before the post-action Skyshot, observe
// a stream frame newer than the action when possible. `frame`
// performs one bounded rebuild for a silently starved stream; a
// static/no-op UI may legitimately emit no changed frame, so the
// authoritative on-demand screenshot still runs after the bound.
_ = await frame(for: target, newerThanUptime: newerThanUptime)
// The stream is a long-lived render/freshness consumer, not the model
// screenshot source. A brand-new source must deliver its first pixel
// frame before we trust an on-demand screenshot for a covered window.
// After an input mutation, the frame must additionally be newer than
// the action watermark. `frame` performs one bounded rebuild for a
// silently starved stream; if neither source produces qualifying
// pixels, fail closed instead of labelling compositor-cached pixels as
// stream-backed.
guard await frame(
for: target,
newerThanUptime: newerThanUptime
) != nil else {
return nil
}
for _ in 0..<2 {
guard let source = await source(for: target) else { continue }
@@ -534,9 +540,10 @@ final class ScreenCaptureKitWindowStreamSource: WindowCaptureStreamSource {
guard let stream else { return }
self.stream = nil
// Do not await SCK shutdown on overlay_hide/disconnect. The mailbox is
// already inert, and retaining the stream in this completion closure
// lets ScreenCaptureKit finish cleanup without delaying the turn.
// Do not await SCK shutdown on target replacement, disconnect, or
// daemon teardown. The mailbox is already inert, and retaining the
// stream in this completion closure lets ScreenCaptureKit finish
// cleanup without delaying the request.
Task { @MainActor in
try? await stream.stopCapture()
}
@@ -126,7 +126,7 @@ final class CommandRouterSafetyTests: XCTestCase {
}
}
func testSessionResetAlsoInvalidatesTheWindowCaptureProvider() {
func testTurnStateResetPreservesTheDaemonLifetimeWindowCaptureProvider() {
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
let provider = WindowCaptureProviderSpy()
let router = CommandRouter(
@@ -138,6 +138,8 @@ final class CommandRouterSafetyTests: XCTestCase {
router.resetSessionState()
XCTAssertEqual(provider.invalidateCount, 0)
router.invalidateWindowCaptureStream()
XCTAssertEqual(provider.invalidateCount, 1)
}
@@ -52,7 +52,7 @@ final class DaemonOverlayTargetTests: XCTestCase {
))
}
func testEveryOverlayStopAlsoInvalidatesTheLongLivedWindowStream() throws {
func testTurnEndPreservesTheLongLivedWindowStreamUntilDaemonTeardown() throws {
let sourceURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
@@ -68,6 +68,17 @@ final class DaemonOverlayTargetTests: XCTestCase {
}
)
XCTAssertTrue(body.contains("router.invalidateWindowCaptureStream()"))
XCTAssertFalse(body.contains("router.invalidateWindowCaptureStream()"))
XCTAssertTrue(body.contains("router.resetSessionState()"))
let teardownBody = try XCTUnwrap(
source.range(of: "private func teardown()").flatMap { start in
source.range(
of: "private func bindAndListen",
range: start.upperBound..<source.endIndex
).map { end in String(source[start.lowerBound..<end.lowerBound]) }
}
)
XCTAssertTrue(teardownBody.contains("router.invalidateWindowCaptureStream()"))
}
}
@@ -48,10 +48,10 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertEqual(refreshed.latestFrameAgeSeconds, 1)
XCTAssertEqual(refreshed.sampleCount, 3)
XCTAssertEqual(refreshed.latestSampleStatus, SCFrameStatus.complete.rawValue)
XCTAssertEqual(source.latestReadCount, 0)
XCTAssertGreaterThan(source.latestReadCount, 0)
XCTAssertEqual(source.startCount, 1)
XCTAssertEqual(source.retireCount, 0)
XCTAssertEqual(captures, 1, "Inspecting metadata must not take screenshots")
XCTAssertEqual(captures, 0, "A source without a pixel frame must not take a screenshot")
}
func testDiagnosticFailureAndInvalidationDoNotRebuildOrExposeRetiredFrames() async throws {
@@ -80,7 +80,7 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertNil(retired.latestFrameSequence)
XCTAssertNil(retired.latestFrameAgeSeconds)
XCTAssertNil(retired.latestSampleStatus)
XCTAssertEqual(source.latestReadCount, 0)
XCTAssertGreaterThan(source.latestReadCount, 0)
XCTAssertEqual(source.retireCount, 1)
}
@@ -116,7 +116,14 @@ final class WindowCaptureStreamTests: XCTestCase {
func testEveryStateReadTakesANewSnapshotWhileReusingTheLongLivedStream() async throws {
let target = makeTarget(windowID: 84)
let factory = FakeWindowCaptureStreamFactory { _, _ in }
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var captures = 0
let manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { target, _ in
captures += 1
@@ -129,7 +136,7 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertEqual(captures, 2)
XCTAssertEqual(factory.sources.count, 1)
XCTAssertEqual(factory.sources[0].startCount, 1)
XCTAssertEqual(factory.sources[0].latestReadCount, 0, "A cached stream frame must not become the model's screenshot")
XCTAssertGreaterThan(factory.sources[0].latestReadCount, 0)
}
func testPostMutationSnapshotConsumesFreshStreamWatermarkBeforeSkyshot() async throws {
@@ -161,6 +168,39 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertEqual(factory.sources[0].retireCount, 0)
}
func testPostMutationSnapshotFailsClosedWhenNoFreshStreamFrameArrives() async {
let target = makeTarget(windowID: 88)
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var captures = 0
let manager = WindowCaptureStreamManager(
factory: factory,
frameWaitAttempts: 0,
frameWaitNanoseconds: 0,
takeSnapshot: { target, _ in
captures += 1
return self.makeSnapshot(target, pixels: "must-not-run")
}
)
let shot = await manager.captureSnapshot(
for: target,
scale: 0.5,
newerThanUptime: 11
)
XCTAssertNil(shot)
XCTAssertEqual(captures, 0)
XCTAssertEqual(factory.sources.count, 2, "One bounded stream rebuild is attempted")
XCTAssertEqual(factory.sources[0].retireCount, 1)
}
func testSnapshotFailureDoesNotFallBackToCachedStreamPixels() async {
let target = makeTarget(windowID: 85)
let factory = FakeWindowCaptureStreamFactory { source, _ in
@@ -169,12 +209,19 @@ final class WindowCaptureStreamTests: XCTestCase {
let manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { _, _ in nil })
let shot = await manager.captureSnapshot(for: target, scale: 0.5)
XCTAssertNil(shot)
XCTAssertEqual(factory.sources[0].latestReadCount, 0)
XCTAssertGreaterThan(factory.sources[0].latestReadCount, 0)
}
func testSnapshotFinishingAfterSessionInvalidationIsDiscarded() async {
let target = makeTarget(windowID: 86)
let factory = FakeWindowCaptureStreamFactory { _, _ in }
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var manager: WindowCaptureStreamManager!
manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { target, _ in
manager.invalidate()
@@ -198,7 +245,14 @@ final class WindowCaptureStreamTests: XCTestCase {
MutationClock.resetForTests()
defer { MutationClock.resetForTests() }
let target = makeTarget(windowID: 81)
let factory = FakeWindowCaptureStreamFactory { _, _ in }
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var captures = 0
var captureTimes: [TimeInterval] = []
let manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { target, _ in
@@ -229,14 +283,21 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertNil(MutationClock.lastMutation(), "The settle marker is one-shot")
}
XCTAssertEqual(factory.sources.count, 1)
XCTAssertEqual(factory.sources[0].latestReadCount, 0)
XCTAssertGreaterThan(factory.sources[0].latestReadCount, 0)
}
func testPartiallyFailedDispatchAlsoSettlesBeforeTheOnDemandSnapshot() async throws {
MutationClock.resetForTests()
defer { MutationClock.resetForTests() }
let target = makeTarget(windowID: 83)
let factory = FakeWindowCaptureStreamFactory { _, _ in }
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var capturedAt: TimeInterval?
let manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { target, _ in
capturedAt = ProcessInfo.processInfo.systemUptime
@@ -263,7 +324,7 @@ final class WindowCaptureStreamTests: XCTestCase {
pendingMutation,
capturedAt: try XCTUnwrap(capturedAt)
)
XCTAssertEqual(factory.sources[0].latestReadCount, 0)
XCTAssertGreaterThan(factory.sources[0].latestReadCount, 0)
}
private func assertMutationHasSettledBeforeCapture(
+58 -39
View File
@@ -11,7 +11,7 @@
# CU_HELPER_TIMESTAMP_MODE
# (default: auto; secure for Developer ID, none for local development)
#
# Output: prints "built: <abs path to .build/release/cc-haha-computer-use.app>"
# Output: prints "built: <arch-specific abs path>/cc-haha-computer-use.app"
#
# Stable-identity contract: same cert + same --identifier on every build,
# --options runtime, a secure timestamp for Developer ID distribution, no ad-hoc.
@@ -46,25 +46,19 @@ PKG_DIR="$(cd -P "$(dirname "$SCRIPT_SOURCE")" >/dev/null 2>&1 && pwd)"
BUILD_CONFIG="release"
BUILD_DIR="$PKG_DIR/.build"
# Output binary name == the SwiftPM executable-target name (see Package.swift).
# This is the brand-facing name macOS shows in the Privacy lists.
BIN_PATH="$BUILD_DIR/$BUILD_CONFIG/cc-haha-computer-use"
# After build+sign we wrap the binary in a minimal .app bundle. WHY: macOS Screen
# Recording (ScreenCaptureKit / TCC kTCCServiceScreenCapture) only grants
# EFFECTIVE access to a real .app bundle process — a bare Mach-O can be toggled
# ON in the Privacy list but CGPreflightScreenCaptureAccess() still reads false.
# Accessibility tolerates a bare binary (works), Screen Recording does NOT. So
# the shipped/dragged artifact is the .app; the inner binary is what we spawn.
APP_PATH="$BUILD_DIR/$BUILD_CONFIG/cc-haha-computer-use.app"
# Reuse the desktop brand asset so both Privacy lists show the product logo.
APP_ICON_PATH="$PKG_DIR/../../desktop/src-tauri/icons/icon.icns"
# Records the (identity, identifier) actually used, so we can detect rotation
# across rebuilds and warn that TCC grants will have been dropped.
SIGN_STAMP="$BUILD_DIR/.cu-helper.signid"
BUNDLE_ID="${CU_HELPER_BUNDLE_ID:-dev.cchaha.cu-helper}"
ARCH="${CU_HELPER_ARCH:-$(uname -m)}"
SWIFT_SCRATCH_PATH="$BUILD_DIR/$ARCH"
BIN_DIR=""
BIN_PATH=""
APP_PATH=""
RESOURCE_BUNDLE_PATH=""
# Records the (identity, identifier) actually used, so we can detect rotation
# across rebuilds and warn that TCC grants will have been dropped.
SIGN_STAMP="$BUILD_DIR/.cu-helper.$ARCH.signid"
# ---------------------------------------------------------------------------
# Logging helpers — everything diagnostic goes to STDERR so the final
@@ -84,6 +78,7 @@ preflight() {
fi
command -v swift >/dev/null 2>&1 || die "swift not found on PATH. Install Xcode / Command Line Tools."
command -v lipo >/dev/null 2>&1 || die "lipo not found on PATH. Install Xcode / Command Line Tools."
command -v codesign >/dev/null 2>&1 || die "codesign not found on PATH. Install Xcode / Command Line Tools."
command -v security >/dev/null 2>&1 || die "security tool not found on PATH (needed to enumerate signing identities)."
@@ -102,12 +97,12 @@ preflight() {
# 2. Resolve a STABLE signing identity.
#
# Priority:
# a) $CU_HELPER_IDENTITY (explicit override — trusted verbatim)
# b) the first real 'Apple Development: ...' identity in the keychain
# (preferred for fast, offline local iteration)
# a) $CC_HAHA_SIGN_IDENTITY (shared host/sidecar/helper build identity)
# b) $CU_HELPER_IDENTITY (legacy helper-only override for direct builds)
# c) the first 'Developer ID Application: ...' identity (release/CI)
# d) a self-signed 'cu-helper-dev' identity if one exists
# e) NONE -> print one-time create instructions and FAIL (never ad-hoc).
# d) the first real 'Apple Development: ...' identity in the keychain
# e) a self-signed 'cu-helper-dev' identity if one exists
# f) NONE -> print one-time create instructions and FAIL (never ad-hoc).
#
# Sets globals: SIGN_IDENTITY (string passed to codesign --sign)
# ---------------------------------------------------------------------------
@@ -182,7 +177,21 @@ EOF
}
resolve_identity() {
# a) explicit override.
# a) shared build-wide override. The helper, the sidecar and the Electron host
# must end up on ONE certificate or the helper's client attestation rejects
# every call (see desktop/scripts/sign-identity.ts). It deliberately wins
# over the legacy helper-only variable so stale shell state cannot split a
# signed app across two certificates.
if [ -n "${CC_HAHA_SIGN_IDENTITY:-}" ]; then
SIGN_IDENTITY="$CC_HAHA_SIGN_IDENTITY"
if [ "$SIGN_IDENTITY" = "-" ]; then
die "CC_HAHA_SIGN_IDENTITY='-' (ad-hoc) is refused. Ad-hoc signing rotates the TCC identity every build. Use a stable cert."
fi
log "identity: $SIGN_IDENTITY (from CC_HAHA_SIGN_IDENTITY)"
return 0
fi
# b) legacy explicit helper-only override for direct build.sh use.
if [ -n "${CU_HELPER_IDENTITY:-}" ]; then
SIGN_IDENTITY="$CU_HELPER_IDENTITY"
# Best-effort sanity check; do not hard-fail on an override the user insists on,
@@ -198,20 +207,7 @@ resolve_identity() {
return 0
fi
# a2) shared build-wide override. The helper, the sidecar and the Electron host
# must end up on ONE certificate or the helper's client attestation rejects
# every call (see desktop/scripts/sign-identity.ts). This variable is how
# the whole build agrees on which one.
if [ -n "${CC_HAHA_SIGN_IDENTITY:-}" ]; then
SIGN_IDENTITY="$CC_HAHA_SIGN_IDENTITY"
if [ "$SIGN_IDENTITY" = "-" ]; then
die "CC_HAHA_SIGN_IDENTITY='-' (ad-hoc) is refused. Ad-hoc signing rotates the TCC identity every build. Use a stable cert."
fi
log "identity: $SIGN_IDENTITY (from CC_HAHA_SIGN_IDENTITY)"
return 0
fi
# b) Developer ID distribution identity — PREFERRED. It is long-lived and
# c) Developer ID distribution identity — PREFERRED. It is long-lived and
# notarizable, and TCC grants are keyed to the signing identity: an
# Apple Development cert expires in about a year and its replacement
# silently drops the user's Accessibility + Screen Recording grants.
@@ -226,7 +222,7 @@ resolve_identity() {
return 0
fi
# c) real Apple Development identity.
# d) real Apple Development identity.
local apple_dev
apple_dev="$(first_apple_development_identity || true)"
if [ -n "$apple_dev" ]; then
@@ -235,14 +231,14 @@ resolve_identity() {
return 0
fi
# d) self-signed fallback cert.
# e) self-signed fallback cert.
if identity_exists "$SELF_SIGNED_NAME"; then
SIGN_IDENTITY="$SELF_SIGNED_NAME"
log "identity: $SIGN_IDENTITY (auto-detected self-signed Code Signing cert)"
return 0
fi
# e) nothing usable -> instructions + fail. NEVER ad-hoc.
# f) nothing usable -> instructions + fail. NEVER ad-hoc.
print_self_signed_instructions
die "no stable code-signing identity available (refusing to ad-hoc sign)."
}
@@ -302,6 +298,23 @@ resolve_timestamp_mode() {
# ---------------------------------------------------------------------------
# 4. Build (release, requested target architecture).
# ---------------------------------------------------------------------------
resolve_build_paths() {
# `.build/release` is a mutable SwiftPM convenience symlink. It can point at
# the host architecture after a cross-build, so resolve the bin directory
# with the exact target arguments and keep each architecture in its own
# scratch tree.
BIN_DIR="$(swift build \
-c "$BUILD_CONFIG" \
--arch "$ARCH" \
--package-path "$PKG_DIR" \
--scratch-path "$SWIFT_SCRATCH_PATH" \
--show-bin-path)"
[ -n "$BIN_DIR" ] || die "swift build --show-bin-path returned an empty path for $ARCH"
BIN_PATH="$BIN_DIR/cc-haha-computer-use"
APP_PATH="$BIN_DIR/cc-haha-computer-use.app"
RESOURCE_BUNDLE_PATH="$BIN_DIR/cu-helper_cc-haha-computer-use.bundle"
}
build() {
log ""
log "==> swift build -c $BUILD_CONFIG --arch $ARCH (+embed Info.plist)"
@@ -314,13 +327,19 @@ build() {
# Screen Recording. Done here (not in Package.swift) so the path is an absolute
# build-time value, not a hardcoded machine path in the manifest. The section
# is created before sign() runs, so the signature seals it.
resolve_build_paths
swift build \
-c "$BUILD_CONFIG" \
--arch "$ARCH" \
--package-path "$PKG_DIR" \
--scratch-path "$SWIFT_SCRATCH_PATH" \
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$PKG_DIR/Info.plist" 1>&2
[ -x "$BIN_PATH" ] || die "expected product not found or not executable at: $BIN_PATH"
if ! lipo "$BIN_PATH" -verify_arch "$ARCH" 1>&2; then
die "built product at $BIN_PATH does not contain required architecture $ARCH"
fi
log "verified architecture: $ARCH"
# Hard assertion: the Info.plist section MUST be embedded, or Screen Recording
# grants silently fail (Accessibility would still work, masking the bug).
@@ -425,7 +444,7 @@ wrap_app() {
# Standard .app location is Contents/Resources/ (Bundle.main.resourceURL). Do
# NOT also put it in MacOS/ — a nested .bundle there breaks codesign with an
# "In subcomponent" error. Overlay degrades to a procedural ring if unresolved.
local res_bundle="$BUILD_DIR/$BUILD_CONFIG/cu-helper_cc-haha-computer-use.bundle"
local res_bundle="${RESOURCE_BUNDLE_PATH:-$BUILD_DIR/$BUILD_CONFIG/cu-helper_cc-haha-computer-use.bundle}"
if [ -d "$res_bundle" ]; then
cp -R "$res_bundle" "$APP_PATH/Contents/Resources/"
fi
+57
View File
@@ -7,6 +7,40 @@ const buildScript = path.resolve(import.meta.dirname, 'build.sh')
const productIcon = path.resolve(import.meta.dirname, '../../desktop/src-tauri/icons/icon.icns')
const fixtureDirectories: string[] = []
function resolveArchitectureSpecificBuildPaths(arch: 'arm64' | 'x86_64') {
const directory = mkdtempSync(path.join(tmpdir(), 'cu-helper-build-path-'))
fixtureDirectories.push(directory)
const binDir = path.join(directory, arch, `${arch}-apple-macosx`, 'release')
const result = Bun.spawnSync([
'bash',
'-c',
`
source "$1"
ARCH="$2"
BUILD_DIR="$3"
SWIFT_SCRATCH_PATH="$BUILD_DIR/$ARCH"
EXPECTED_BIN_DIR="$4"
swift() {
printf '%s\\n' "$EXPECTED_BIN_DIR"
}
resolve_build_paths
printf '%s\\n%s\\n%s\\n%s\\n' "$BIN_DIR" "$BIN_PATH" "$APP_PATH" "$RESOURCE_BUNDLE_PATH"
`,
'cu-helper-build-path-test',
buildScript,
arch,
directory,
binDir,
])
return {
exitCode: result.exitCode,
lines: result.stdout.toString().trim().split('\n'),
stderr: result.stderr.toString(),
binDir,
}
}
afterEach(() => {
for (const directory of fixtureDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
@@ -127,6 +161,29 @@ describe('cu-helper build signing identity', () => {
})
})
describe('cu-helper architecture-specific build output', () => {
test.each(['arm64', 'x86_64'] as const)(
'resolves %s products from the matching SwiftPM bin directory',
(arch) => {
const result = resolveArchitectureSpecificBuildPaths(arch)
expect(result.exitCode).toBe(0)
expect(result.lines).toEqual([
result.binDir,
path.join(result.binDir, 'cc-haha-computer-use'),
path.join(result.binDir, 'cc-haha-computer-use.app'),
path.join(result.binDir, 'cu-helper_cc-haha-computer-use.bundle'),
])
},
)
test('verifies the requested Mach-O architecture before signing', () => {
const source = readFileSync(buildScript, 'utf8')
expect(source).toContain('lipo "$BIN_PATH" -verify_arch "$ARCH"')
expect(source.indexOf('lipo "$BIN_PATH" -verify_arch "$ARCH"'))
.toBeLessThan(source.indexOf('\nsign() {'))
})
})
describe.skipIf(process.platform !== 'darwin')('cu-helper permission-list app icon', () => {
test('declares and bundles the product icon before signing the helper app', () => {
const result = wrapFixtureApp()