mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(computer-use): harden native macOS automation runtime
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user