mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
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.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user