From a0a8fd4b453dc09bc4b3382638c2b0b559aea713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 11 Sep 2026 15:09:50 +0800 Subject: [PATCH] fix(computer-use): load cursor resources from packaged apps Resolve optional click animation assets from the running helper bundle. Avoid SwiftPM Bundle.module's fatal fallback to a build-machine path so visible clicks can return successfully and keep the daemon alive. Keep procedural feedback when optional frames are absent or unreadable. Add an input-free resource probe and run it against relocated release and staged helper apps. Cover standard Resources copying and reject probes that load from an external build directory. Validation: 524 XCTest and 15 Swift Testing cases passed, including eight new resource regressions. Build fixtures, compiled sidecar smoke, Electron checks, local packaging and final relocated-package probes passed. --- desktop/scripts/build-sidecars.test.ts | 72 ++++++-- .../Sources/cu-helper/VirtualCursor.swift | 42 +++-- native/cu-helper/Sources/cu-helper/main.swift | 20 +++ .../VirtualCursorResourceTests.swift | 170 ++++++++++++++++++ native/cu-helper/build.sh | 62 ++++++- native/cu-helper/build.test.ts | 101 ++++++++++- 6 files changed, 425 insertions(+), 42 deletions(-) create mode 100644 native/cu-helper/Tests/CuHelperTests/VirtualCursorResourceTests.swift diff --git a/desktop/scripts/build-sidecars.test.ts b/desktop/scripts/build-sidecars.test.ts index 58ebbd78..6ddd18c4 100644 --- a/desktop/scripts/build-sidecars.test.ts +++ b/desktop/scripts/build-sidecars.test.ts @@ -1,10 +1,11 @@ // @vitest-environment node -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { readFileSync } from 'node:fs' -import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { copyFile, cp, mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path, { join as joinPath } from 'node:path' +import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' import { @@ -676,21 +677,64 @@ describe('build-sidecars cu-helper macOS gating', () => { expect(source).toContain('env: createCuHelperBuildEnv(targetTriple, process.env)') }) - it('copies the cu-helper binary and its resource bundle into the binaries dir', () => { + it('preserves the helper app signature while staging the complete signed bundle', () => { const source = readBuildScript() - // Both the bare binary AND the SwiftPM resource bundle must be copied, or the - // LensSequence overlay assets fail to load at runtime via Bundle.module. - expect(source).toContain('cu-helper_cc-haha-computer-use.bundle') - expect(source).toMatch(/binariesDir,\s*'cc-haha-computer-use'/) + const start = source.indexOf('async function buildCuHelper(') + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf('\nasync function copyPreserving(', start) + expect(end).toBeGreaterThan(start) + const buildCuHelperBody = source.slice(start, end) + expect(buildCuHelperBody).toContain('await copyPreserving(builtBinary, destApp)') + expect(buildCuHelperBody).not.toContain('signMacBinary(') + expect(buildCuHelperBody).not.toContain('codesign') }) +}) - it('does NOT ad-hoc re-sign cu-helper (would rotate its stable TCC identity)', () => { - const source = readBuildScript() - // adHocSignMacBinary must only be applied to the bun-compiled sidecar, never - // to cu-helper (build.sh already signs it with a stable hardened-runtime identity). - const buildCuHelperBody = source.slice(source.indexOf('async function buildCuHelper()')) - expect(buildCuHelperBody).not.toContain('adHocSignMacBinary') - }) +describe.skipIf(!compiledSidecarSmokeEnabled || process.platform !== 'darwin')('staged cu-helper resource smoke', () => { + it('loads optional cursor resources from a relocated staged app instead of the build tree', async context => { + const exec = promisify(execFile) + const sourceApp = path.resolve(import.meta.dirname, '../src-tauri/binaries/cc-haha-computer-use.app') + const inner = path.join('Contents', 'MacOS', 'cc-haha-computer-use') + const { stdout: architectures } = await exec('/usr/bin/lipo', ['-archs', path.join(sourceApp, inner)]) + const hostArch = process.arch === 'arm64' ? 'arm64' : 'x86_64' + if (!architectures.trim().split(/\s+/).includes(hostArch)) { + console.warn(`[cu-helper resource smoke] skipped execution: target ${architectures.trim()}, host ${hostArch}`) + context.skip() + return + } + const fixtureRoot = await mkdtemp(path.join(tmpdir(), 'cu-helper-staged-resources-')) + try { + const app = path.join(fixtureRoot, 'cc-haha-computer-use.app') + await cp(sourceApp, app, { recursive: true, verbatimSymlinks: true }) + const home = path.join(fixtureRoot, 'home') + const config = path.join(fixtureRoot, 'config') + const temp = path.join(fixtureRoot, 'tmp') + await Promise.all([home, config, temp].map(directory => mkdir(directory))) + const { stdout } = await exec(path.join(app, inner), ['--probe-cursor-resources'], { + cwd: fixtureRoot, + env: { + PATH: '/usr/bin:/bin:/usr/sbin:/sbin', HOME: home, + CFFIXED_USER_HOME: home, CLAUDE_CONFIG_DIR: config, TMPDIR: `${temp}/`, + }, + timeout: 10_000, + maxBuffer: 1024 * 1024, + }) + const report = JSON.parse(stdout) as { + resourceDirectory: string | null + frameCount: number + proceduralFallback: boolean + } + expect(report.resourceDirectory).not.toBeNull() + expect(await realpath(report.resourceDirectory!)).toBe(await realpath(path.join( + app, 'Contents', 'Resources', 'cu-helper_cc-haha-computer-use.bundle', 'LensSequence', + ))) + expect(Number.isInteger(report.frameCount)).toBe(true) + expect(report.frameCount).toBeGreaterThanOrEqual(0) + expect(report.proceduralFallback).toBe(report.frameCount === 0) + } finally { + await rm(fixtureRoot, { recursive: true, force: true }) + } + }, 20_000) }) /** diff --git a/native/cu-helper/Sources/cu-helper/VirtualCursor.swift b/native/cu-helper/Sources/cu-helper/VirtualCursor.swift index 1c00fea8..571e5c89 100644 --- a/native/cu-helper/Sources/cu-helper/VirtualCursor.swift +++ b/native/cu-helper/Sources/cu-helper/VirtualCursor.swift @@ -279,7 +279,7 @@ public final class VirtualCursor { private var glideLastTick: CFTimeInterval = 0 // Lazily-loaded raster ripple frames (nil => not bundled => procedural). - private static let rasterFrames: [CGImage]? = VirtualCursor.loadLensSequence() + private static let rasterFrames: [CGImage]? = VirtualCursor.loadLensSequence().frames // MARK: - Geometry helpers @@ -853,22 +853,28 @@ public final class VirtualCursor { /// Load optional click-ripple PNG frames bundled under /// `Resources/LensSequence`. Frames are sorted by filename so `frame_000`, - /// `frame_001`, … animate in order. Returns nil when no bundle / no frames, - /// in which case `showClick` falls back to the procedural ring. - private static func loadLensSequence() -> [CGImage]? { + /// `frame_001`, … animate in order. Missing or unreadable optional frames + /// leave `frames` nil, so `showClick` uses the procedural ring. The resource + /// probe calls this same loader without creating a cursor or posting input. + static func loadLensSequence(from bundle: Bundle = .main) -> (directory: URL?, frames: [CGImage]?) { let fm = FileManager.default - // Resolve the LensSequence directory from the SwiftPM resource bundle. - // `Bundle.module` is synthesized because Package.swift declares - // `.copy("Resources/LensSequence")`. Fall back to scanning next to the - // executable for robustness in case the resource layout differs. - var directory: URL? - if let bundleURL = Bundle.module.url(forResource: "LensSequence", withExtension: nil) { - directory = bundleURL - } else if let resourceURL = Bundle.module.resourceURL? - .appendingPathComponent("LensSequence", isDirectory: true), - fm.fileExists(atPath: resourceURL.path) { - directory = resourceURL + // SwiftPM's generated Bundle.module accessor traps when its bundle is + // absent from the app root and the absolute build-machine path. Our + // signed app instead puts resources in Contents/Resources. Never invoke + // that accessor for optional visuals: discover only runtime-relative + // locations, including the sibling bundle of a bare SwiftPM executable. + let moduleName = "cu-helper_cc-haha-computer-use.bundle" + var candidates = [bundle.resourceURL, bundle.bundleURL, bundle.executableURL?.deletingLastPathComponent()] + .compactMap { $0 } + .map { $0.appendingPathComponent(moduleName, isDirectory: true) + .appendingPathComponent("LensSequence", isDirectory: true) } + if let resources = bundle.resourceURL { + candidates.append(resources.appendingPathComponent("LensSequence", isDirectory: true)) + } + let directory = candidates.first { candidate in + var isDirectory: ObjCBool = false + return fm.fileExists(atPath: candidate.path, isDirectory: &isDirectory) && isDirectory.boolValue } guard let dir = directory, @@ -877,13 +883,13 @@ public final class VirtualCursor { includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) - else { return nil } + else { return (directory, nil) } let pngs = entries .filter { $0.pathExtension.lowercased() == "png" } .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } - guard !pngs.isEmpty else { return nil } + guard !pngs.isEmpty else { return (directory, nil) } var images: [CGImage] = [] images.reserveCapacity(pngs.count) @@ -893,7 +899,7 @@ public final class VirtualCursor { guard let cg = nsImage.cgImage(forProposedRect: &rect, context: nil, hints: nil) else { continue } images.append(cg) } - return images.isEmpty ? nil : images + return (directory, images.isEmpty ? nil : images) } } diff --git a/native/cu-helper/Sources/cu-helper/main.swift b/native/cu-helper/Sources/cu-helper/main.swift index 360d047d..b34b432e 100644 --- a/native/cu-helper/Sources/cu-helper/main.swift +++ b/native/cu-helper/Sources/cu-helper/main.swift @@ -335,6 +335,26 @@ let argv = CommandLine.arguments let entryCommand = argv.count >= 2 ? argv[1] : nil let isDaemonMode = entryCommand == "daemon" +// Public, input-free package diagnostic. Resolve and decode exactly the same +// optional assets used by the first visible click, before TCC disclaim, client +// authorization, NSApplication creation, runtime files, or injection cleanup. +// This permits testing a relocated release app with no user permissions/state. +if entryCommand == "--probe-cursor-resources" { + let resources = VirtualCursor.loadLensSequence() + let report = JSONValue.object([ + "resourceDirectory": resources.directory.map { .string($0.path) } ?? .null, + "frameCount": .int(resources.frames?.count ?? 0), + "proceduralFallback": .bool(resources.frames == nil), + ]) + do { + emitLine(try JSONEncoder().encode(report)) + exit(0) + } catch { + emitLine(Data("{\"error\":\"cursor resource diagnostic encoding failed\"}".utf8)) + exit(1) + } +} + // disclaim re-exec — for the BARE-EXEC'd modes only (one-shot CLI + onboarding // card, which the Electron app spawns directly). It re-execs us once with // responsibility_spawnattrs_setdisclaim so the process becomes its OWN TCC diff --git a/native/cu-helper/Tests/CuHelperTests/VirtualCursorResourceTests.swift b/native/cu-helper/Tests/CuHelperTests/VirtualCursorResourceTests.swift new file mode 100644 index 00000000..5b4b0991 --- /dev/null +++ b/native/cu-helper/Tests/CuHelperTests/VirtualCursorResourceTests.swift @@ -0,0 +1,170 @@ +import AppKit +import ImageIO +import UniformTypeIdentifiers +import XCTest + +@testable import cc_haha_computer_use + +final class VirtualCursorResourceTests: XCTestCase { + @MainActor + func testPackagedApplicationLoadsFramesFromContentsResourcesInNaturalOrder() throws { + let fixture = try ResourceFixture() + defer { fixture.remove() } + let sequence = try fixture.sequence(at: "Contents/Resources/cu-helper_cc-haha-computer-use.bundle") + try fixture.png(width: 10, to: sequence.appendingPathComponent("frame_10.png")) + try fixture.png(width: 2, to: sequence.appendingPathComponent("frame_2.png")) + + let resources = VirtualCursor.loadLensSequence(from: fixture.bundle) + let frames = try XCTUnwrap(resources.frames) + + XCTAssertEqual(resources.directory?.standardizedFileURL.path, sequence.standardizedFileURL.path) + XCTAssertEqual(frames.map(\.width), [2, 10]) + } + + @MainActor + func testMissingOptionalBundleFallsBackWithoutLoadingAnyBuildTreeResources() throws { + let fixture = try ResourceFixture() + defer { fixture.remove() } + + let resources = VirtualCursor.loadLensSequence(from: fixture.bundle) + XCTAssertNil(resources.directory) + XCTAssertNil(resources.frames) + } + + @MainActor + func testReadmeOnlySequenceFallsBackToProceduralRipple() throws { + let fixture = try ResourceFixture() + defer { fixture.remove() } + let sequence = try fixture.sequence(at: "Contents/Resources/cu-helper_cc-haha-computer-use.bundle") + try Data("Optional animation frames are not installed.".utf8) + .write(to: sequence.appendingPathComponent("README.md")) + + let resources = VirtualCursor.loadLensSequence(from: fixture.bundle) + XCTAssertEqual(resources.directory?.standardizedFileURL.path, sequence.standardizedFileURL.path) + XCTAssertNil(resources.frames) + } + + @MainActor + func testInvalidFramesDoNotHideLaterValidFrames() throws { + let fixture = try ResourceFixture() + defer { fixture.remove() } + let sequence = try fixture.sequence(at: "Contents/Resources/cu-helper_cc-haha-computer-use.bundle") + try Data("not a PNG".utf8).write(to: sequence.appendingPathComponent("frame_1.png")) + try fixture.png(width: 4, to: sequence.appendingPathComponent("frame_2.PNG")) + + let frames = try XCTUnwrap(VirtualCursor.loadLensSequence(from: fixture.bundle).frames) + + XCTAssertEqual(frames.map(\.width), [4]) + } + + @MainActor + func testExecutableSiblingModuleBundleRemainsSupported() throws { + let fixture = try ResourceFixture() + defer { fixture.remove() } + let sequence = try fixture.sequence(at: "Contents/MacOS/cu-helper_cc-haha-computer-use.bundle") + try fixture.png(width: 3, to: sequence.appendingPathComponent("frame_1.png")) + + let resources = VirtualCursor.loadLensSequence(from: fixture.bundle) + + XCTAssertEqual(resources.directory?.standardizedFileURL.path, sequence.standardizedFileURL.path) + XCTAssertEqual(resources.frames?.map(\.width), [3]) + } + + @MainActor + func testPackagedSequenceTakesPrecedenceOverLegacyLocationsEvenWithoutFrames() throws { + let fixture = try ResourceFixture() + defer { fixture.remove() } + let packaged = try fixture.sequence(at: "Contents/Resources/cu-helper_cc-haha-computer-use.bundle") + let sibling = try fixture.sequence(at: "Contents/MacOS/cu-helper_cc-haha-computer-use.bundle") + try fixture.png(width: 3, to: sibling.appendingPathComponent("frame_1.png")) + + let resources = VirtualCursor.loadLensSequence(from: fixture.bundle) + + XCTAssertEqual(resources.directory?.standardizedFileURL.path, packaged.standardizedFileURL.path) + XCTAssertNil(resources.frames, "An intentionally empty deployed sequence must not borrow other frames") + } + + @MainActor + func testAllInvalidFramesFallBackToProceduralRipple() throws { + let fixture = try ResourceFixture() + defer { fixture.remove() } + let sequence = try fixture.sequence(at: "Contents/Resources/cu-helper_cc-haha-computer-use.bundle") + try Data("not a PNG".utf8).write(to: sequence.appendingPathComponent("frame_1.png")) + + let resources = VirtualCursor.loadLensSequence(from: fixture.bundle) + + XCTAssertEqual(resources.directory?.standardizedFileURL.path, sequence.standardizedFileURL.path) + XCTAssertNil(resources.frames) + } + + @MainActor + func testResourcePathThatIsAFileDoesNotCrash() throws { + let fixture = try ResourceFixture() + defer { fixture.remove() } + try Data("not a directory".utf8).write(to: fixture.application + .appendingPathComponent("Contents/Resources/cu-helper_cc-haha-computer-use.bundle")) + + let resources = VirtualCursor.loadLensSequence(from: fixture.bundle) + + XCTAssertNil(resources.directory) + XCTAssertNil(resources.frames) + } +} + +private struct ResourceFixture { + let root: URL + let application: URL + let bundle: Bundle + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("cu-cursor-resources-\(UUID().uuidString)", isDirectory: true) + application = root.appendingPathComponent("Moved Helper's App.app", isDirectory: true) + let contents = application.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory( + at: contents.appendingPathComponent("MacOS", isDirectory: true), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: contents.appendingPathComponent("Resources", isDirectory: true), + withIntermediateDirectories: true + ) + let info: [String: Any] = [ + "CFBundleIdentifier": "dev.cchaha.tests.cursor-resources.\(UUID().uuidString)", + "CFBundleName": "Cursor Resource Fixture", + "CFBundlePackageType": "APPL", + "CFBundleExecutable": "fixture-helper", + ] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: contents.appendingPathComponent("Info.plist")) + try Data().write(to: contents.appendingPathComponent("MacOS/fixture-helper")) + bundle = try XCTUnwrap(Bundle(url: application)) + } + + func sequence(at relativeRoot: String) throws -> URL { + let directory = application.appendingPathComponent(relativeRoot, isDirectory: true) + .appendingPathComponent("LensSequence", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + func png(width: Int, to url: URL) throws { + let context = try XCTUnwrap(CGContext( + data: nil, width: width, height: 2, bitsPerComponent: 8, + bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.setFillColor(CGColor(gray: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: 2)) + let image = try XCTUnwrap(context.makeImage()) + let destination = try XCTUnwrap(CGImageDestinationCreateWithURL( + url as CFURL, UTType.png.identifier as CFString, 1, nil + )) + CGImageDestinationAddImage(destination, image, nil) + XCTAssertTrue(CGImageDestinationFinalize(destination)) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} diff --git a/native/cu-helper/build.sh b/native/cu-helper/build.sh index 52f6d85e..a6267f44 100755 --- a/native/cu-helper/build.sh +++ b/native/cu-helper/build.sh @@ -427,6 +427,14 @@ verify() { # APP_PATH comment). Contents/Info.plist (CFBundleIdentifier == $BUNDLE_ID) # makes the inner binary's TCC identity a proper app bundle. # --------------------------------------------------------------------------- +copy_cursor_resources() { + local res_bundle="$1" + local destination_app="$2" + [ -d "$res_bundle/LensSequence" ] || die "Cursor resource bundle not found at $res_bundle (SwiftPM must produce the declared LensSequence directory)." + mkdir -p "$destination_app/Contents/Resources" + cp -R "$res_bundle" "$destination_app/Contents/Resources/" +} + wrap_app() { [ -s "$APP_ICON_PATH" ] || die "App icon not found at $APP_ICON_PATH (needed for the Privacy lists)." log "" @@ -440,14 +448,13 @@ wrap_app() { cp "$PKG_DIR/Info.plist" "$APP_PATH/Contents/Info.plist" cp "$APP_ICON_PATH" "$APP_PATH/Contents/Resources/icon.icns" - # SwiftPM resource bundle (LensSequence overlay), loaded via Bundle.module. - # Standard .app location is Contents/Resources/ (Bundle.main.resourceURL). Do + # SwiftPM resource bundle (LensSequence overlay). 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. + # "In subcomponent" error. The optional sequence may contain only its README; + # missing PNGs are supported, a missing declared build resource is not. 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 + copy_cursor_resources "$res_bundle" "$APP_PATH" # Sign the WHOLE bundle with the SAME stable identity + hardened runtime. codesign \ @@ -473,6 +480,48 @@ wrap_app() { log "verified: .app bundle Identifier=$app_id (stable)" } +# Run the production loader from an independent .app location. Checking only a +# source-tree build can silently succeed through SwiftPM's absolute buildPath. +# The probe performs no input, GUI startup, permission checks, or user-state IO. +verify_relocated_cursor_resources() ( + local host_arch + host_arch="$(uname -m)" + if [ "$ARCH" != "$host_arch" ]; then + log "skipped: cursor resource execution probe (target $ARCH, host $host_arch); package structure was verified before signing" + return 0 + fi + + # The subshell isolates this variable. Bash 3 unwinds function-local variables + # before an EXIT trap after die(), so it must remain available for cleanup. + probe_root="$(mktemp -d "${TMPDIR:-/tmp}/cc-haha-cursor-probe.XXXXXX")" + trap 'rm -rf "$probe_root"' EXIT + local probe_app="$probe_root/cc-haha-computer-use.app" + local report="$probe_root/resources.json" + cp -R "$APP_PATH" "$probe_app" + mkdir -p "$probe_root/home" "$probe_root/config" "$probe_root/tmp" + if ! env -i \ + PATH=/usr/bin:/bin:/usr/sbin:/sbin \ + HOME="$probe_root/home" \ + CFFIXED_USER_HOME="$probe_root/home" \ + CLAUDE_CONFIG_DIR="$probe_root/config" \ + TMPDIR="$probe_root/tmp/" \ + "$probe_app/Contents/MacOS/cc-haha-computer-use" --probe-cursor-resources >"$report"; then + die "Cursor resource probe failed for relocated helper $probe_app" + fi + + local resource_directory + resource_directory="$(/usr/bin/plutil -extract resourceDirectory raw -o - "$report" 2>/dev/null)" \ + || die "Cursor resource probe did not report a resourceDirectory" + resource_directory="$(cd "$resource_directory" 2>/dev/null && pwd -P)" \ + || die "Cursor resource probe reported an unreadable resourceDirectory" + local expected_directory="$probe_app/Contents/Resources/cu-helper_cc-haha-computer-use.bundle/LensSequence" + [ -d "$expected_directory" ] || die "Cursor resource probe package is missing $expected_directory" + expected_directory="$(cd "$expected_directory" && pwd -P)" + [ "$resource_directory" = "$expected_directory" ] \ + || die "Cursor resource probe loaded '$resource_directory' instead of relocated package '$expected_directory'" + log "verified: relocated cursor resources ($resource_directory)" +) + # --------------------------------------------------------------------------- # main # --------------------------------------------------------------------------- @@ -483,6 +532,7 @@ main() { sign verify wrap_app + verify_relocated_cursor_resources # The ONE machine-readable line on STDOUT — the .app BUNDLE path. The caller # (build-sidecars.ts) copies the whole .app; the runtime resolver diff --git a/native/cu-helper/build.test.ts b/native/cu-helper/build.test.ts index 9c72fa2d..d7610336 100644 --- a/native/cu-helper/build.test.ts +++ b/native/cu-helper/build.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' 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[] = [] +const resourceBundleName = 'cu-helper_cc-haha-computer-use.bundle' function resolveArchitectureSpecificBuildPaths(arch: 'arm64' | 'x86_64') { const directory = mkdtempSync(path.join(tmpdir(), 'cu-helper-build-path-')) @@ -47,10 +48,15 @@ afterEach(() => { } }) -function wrapFixtureApp(missingIcon = false) { +function wrapFixtureApp(options: { missingIcon?: boolean, missingResources?: boolean } = {}) { const directory = mkdtempSync(path.join(tmpdir(), 'cu-helper-app-icon-')) fixtureDirectories.push(directory) writeFileSync(path.join(directory, 'fixture-binary'), 'fixture executable') + const resourceBundle = path.join(directory, resourceBundleName) + if (!options.missingResources) { + mkdirSync(path.join(resourceBundle, 'LensSequence'), { recursive: true }) + writeFileSync(path.join(resourceBundle, 'LensSequence', 'README.md'), 'optional frames fixture') + } const result = Bun.spawnSync([ 'bash', @@ -60,6 +66,7 @@ source "$1" TEST_BUNDLE_DIR="$2" BUILD_DIR="$TEST_BUNDLE_DIR/build" BIN_PATH="$TEST_BUNDLE_DIR/fixture-binary" +RESOURCE_BUNDLE_PATH="$TEST_BUNDLE_DIR/cu-helper_cc-haha-computer-use.bundle" APP_PATH="$TEST_BUNDLE_DIR/cc-haha-computer-use.app" BUNDLE_ID="dev.cchaha.cu-helper" SIGN_IDENTITY="fixture-only" @@ -78,7 +85,7 @@ wrap_app 'cu-helper-app-icon-test', buildScript, directory, - missingIcon ? 'missing' : 'present', + options.missingIcon ? 'missing' : 'present', ], { cwd: directory }) return { @@ -89,6 +96,49 @@ wrap_app } } +function probeFixtureApp(mode: 'packaged' | 'build-path' | 'invalid-json' | 'crash', crossArch = false) { + const directory = mkdtempSync(path.join(tmpdir(), 'cu-helper-resource-probe-')) + fixtureDirectories.push(directory) + const app = path.join(directory, 'source.app') + const binary = path.join(app, 'Contents', 'MacOS', 'cc-haha-computer-use') + mkdirSync(path.dirname(binary), { recursive: true }) + mkdirSync(path.join(app, 'Contents', 'Resources', resourceBundleName, 'LensSequence'), { recursive: true }) + writeFileSync(path.join(app, 'Contents', 'Resources', resourceBundleName, 'LensSequence', 'README.md'), 'optional frames fixture') + const buildTreeResources = path.join(directory, 'build-tree', resourceBundleName, 'LensSequence') + mkdirSync(buildTreeResources, { recursive: true }) + writeFileSync(path.join(app, 'Contents', 'Resources', 'outside-resource-path'), buildTreeResources) + writeFileSync(binary, `#!/bin/bash +set -eu +[ "$1" = "--probe-cursor-resources" ] || exit 40 +case '${mode}' in + crash) exit 41 ;; + invalid-json) printf 'not-json'; exit 0 ;; + build-path) resource_dir="$(cat "$(dirname "$0")/../Resources/outside-resource-path")" ;; + *) resource_dir="$(cd "$(dirname "$0")/../Resources/${resourceBundleName}/LensSequence" && pwd -P)" ;; +esac +printf '{"resourceDirectory":"%s","frameCount":0,"proceduralFallback":true}\\n' "$resource_dir" +`) + chmodSync(binary, 0o755) + const result = Bun.spawnSync([ + 'bash', '-c', ` +source "$1" +APP_PATH="$2" +ARCH="$3" +uname() { printf 'arm64\\n'; } +verify_relocated_cursor_resources +`, 'cu-helper-resource-probe-test', buildScript, app, + crossArch ? 'x86_64' : 'arm64', + ], { + env: { PATH: process.env.PATH, HOME: directory, TMPDIR: directory }, + cwd: directory, + }) + return { + exitCode: result.exitCode, + stderr: result.stderr.toString(), + leftovers: readdirSync(directory).filter(name => name.startsWith('cc-haha-cursor-probe.')), + } +} + function resolveTimestampArgument(identity: string, mode = 'auto') { const result = Bun.spawnSync([ 'bash', @@ -208,10 +258,53 @@ describe.skipIf(process.platform !== 'darwin')('cu-helper permission-list app ic }) test('refuses to sign an app when the required product icon is missing', () => { - const result = wrapFixtureApp(true) + const result = wrapFixtureApp({ missingIcon: true }) expect(result.exitCode).not.toBe(0) expect(result.stderr).toContain('App icon not found') expect(existsSync(path.join(result.directory, 'contents-at-sign'))).toBe(false) }) }) + +describe('cu-helper packaged cursor resources', () => { + test('copies the complete SwiftPM resource directory into standard Resources before signing', () => { + const result = wrapFixtureApp() + expect(result.exitCode).toBe(0) + const relative = path.join('Resources', resourceBundleName, 'LensSequence', 'README.md') + expect(readFileSync(path.join(result.contents, relative), 'utf8')).toBe('optional frames fixture') + expect(readFileSync(path.join(result.directory, 'contents-at-sign', relative), 'utf8')).toBe('optional frames fixture') + expect(existsSync(path.join(result.contents, 'MacOS', resourceBundleName))).toBe(false) + }) + + test('refuses to sign when the declared SwiftPM resource bundle was not produced', () => { + const result = wrapFixtureApp({ missingResources: true }) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('Cursor resource bundle not found') + expect(existsSync(path.join(result.directory, 'contents-at-sign'))).toBe(false) + }) +}) + +describe.skipIf(process.platform !== 'darwin')('cu-helper relocated resource probe', () => { + test('loads from the relocated app even when the optional frame directory has no PNGs', () => { + const result = probeFixtureApp('packaged') + expect(result.exitCode).toBe(0) + expect(result.stderr).toContain('verified: relocated cursor resources') + expect(result.leftovers).toEqual([]) + }) + + test.each(['build-path', 'invalid-json', 'crash'] as const)('rejects %s instead of accepting a false resource-load success', mode => { + const result = probeFixtureApp(mode) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('Cursor resource probe') + if (mode === 'build-path') expect(result.stderr).toContain('instead of relocated package') + expect(result.leftovers).toEqual([]) + }) + + test('reports cross-architecture execution as skipped without running the probe', () => { + const result = probeFixtureApp('crash', true) + expect(result.exitCode).toBe(0) + expect(result.stderr).toContain('skipped: cursor resource execution probe (target x86_64, host arm64)') + expect(result.stderr).not.toContain('verified: relocated cursor resources') + expect(result.leftovers).toEqual([]) + }) +})