From c5300d0377dfd74b3b0a8c6bceb6315198933ea7 Mon Sep 17 00:00:00 2001 From: zqxwce Date: Mon, 27 Jul 2026 19:08:57 +0300 Subject: [PATCH] feat: standalone .app bundling + versioned resource resolution - scripts/build.sh builds+signs the binary, bundles a self-contained .app (mirrors scripts/patchers/resources/tools/vphoned + requirements.txt into Contents/Resources) and re-signs; the signed guest daemon stages under .build (not the repo root) - CryptexFilesystemPatcher resolves assets via VPhoneResources instead of CWD-relative paths - Makefile space-safety; .gitignore updates Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y4VDqWf5pVakcFLqB23CKe --- .gitignore | 1 + Makefile | 19 +-- scripts/build.sh | 113 ++++++++++++++++++ .../Filesystem/CryptexFilesystemPatcher.swift | 34 ++++-- 4 files changed, 148 insertions(+), 19 deletions(-) create mode 100755 scripts/build.sh diff --git a/.gitignore b/.gitignore index e2cafa0..0f7b590 100644 --- a/.gitignore +++ b/.gitignore @@ -218,6 +218,7 @@ ipython_config.py # Local scratch planning TODO.md +.superpowers/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ diff --git a/Makefile b/Makefile index 6d93c44..89c9b57 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,12 @@ VM_DIR ?= vm # (e.g. external SSD) VM_DIR values. `abspath` leaves absolute paths intact # and joins relative ones against CURDIR — use this for the VM directory arg. VM_DIR_ABS := $(abspath $(VM_DIR)) -CPU ?= 8 # CPU cores (only used during vm_new) -MEMORY ?= 8192 # Memory in MB (only used during vm_new) -DISK_SIZE ?= 64 # Disk size in GB (only used during vm_new) +# CPU cores, memory (MB), disk size (GB) — used only during vm_new. +# NB: no inline comments on these `?=` lines — make would fold the trailing +# whitespace into the value (e.g. CPU="8 ") and break numeric consumers. +CPU ?= 8 +MEMORY ?= 8192 +DISK_SIZE ?= 64 BACKUPS_DIR ?= vm.backups NAME ?= BACKUP_INCLUDE_IPSW ?= 0 @@ -252,7 +255,7 @@ vphoned: vm_new: CPU="$(CPU)" MEMORY="$(MEMORY)" \ - zsh $(SCRIPTS)/vm_create.sh --dir $(VM_DIR) --disk-size $(DISK_SIZE) + zsh $(SCRIPTS)/vm_create.sh --dir "$(VM_DIR)" --disk-size $(DISK_SIZE) vm_backup: VM_DIR="$(VM_DIR)" BACKUPS_DIR="$(BACKUPS_DIR)" NAME="$(NAME)" BACKUP_INCLUDE_IPSW="$(BACKUP_INCLUDE_IPSW)" \ @@ -321,17 +324,17 @@ boot_binary_check: $(BINARY) $(call BOOT_BINARY_CHECK,--assert-bootable) boot: bundle vphoned boot_binary_check - cd $(VM_DIR) && "$(CURDIR)/$(BUNDLE_BIN)" \ + cd "$(VM_DIR)" && "$(CURDIR)/$(BUNDLE_BIN)" \ --config ./config.plist boot_less: bundle boot_binary_check_less - cd $(VM_DIR) && "$(CURDIR)/$(BUNDLE_BIN)" \ + cd "$(VM_DIR)" && "$(CURDIR)/$(BUNDLE_BIN)" \ --config ./config.plist \ --variant less \ $(if $(filter 1 true yes YES TRUE,$(NO_VPHONED)),--no-vphoned,) boot_dfu: build boot_binary_check - cd $(VM_DIR) && "$(CURDIR)/$(BINARY)" \ + cd "$(VM_DIR)" && "$(CURDIR)/$(BINARY)" \ --config ./config.plist \ --dfu @@ -342,7 +345,7 @@ boot_dfu: build boot_binary_check .PHONY: fw_prepare fw_patch fw_patch_less fw_patch_dev fw_patch_jb fw_prepare: - cd $(VM_DIR) && bash "$(CURDIR)/$(SCRIPTS)/fw_prepare.sh" + cd "$(VM_DIR)" && bash "$(CURDIR)/$(SCRIPTS)/fw_prepare.sh" fw_patch: patcher_build "$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(VM_DIR_ABS)" --variant regular \ diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..8f86e62 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,113 @@ +#!/bin/zsh +# build.sh — Build, sign, and bundle vphone-cli (+ cross-compile vphoned). +# +# This is the bootstrap step that a running binary cannot do for itself: +# it compiles the vphone-cli binary, signs it with the PV=3 entitlements, +# wraps it in the .app bundle used for GUI boot, and cross-compiles + signs +# the vphoned guest daemon. Everything else in the project is driven by the +# resulting `vphone-cli` binary — this script is the only build entrypoint. +# +# Usage: +# ./scripts/build.sh # build + sign + bundle + vphoned +# ./scripts/build.sh --no-vphoned # skip the vphoned cross-compile +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" +PROJECT_ROOT="${SCRIPT_DIR:h}" +cd "$PROJECT_ROOT" + +BINARY=".build/release/vphone-cli" +BUNDLE=".build/vphone-cli.app" +BUNDLE_BIN="${BUNDLE}/Contents/MacOS/vphone-cli" +INFO_PLIST="sources/Info.plist" +ENTITLEMENTS="sources/vphone.entitlements" +BUILD_INFO="sources/vphone-cli/VPhoneBuildInfo.swift" +GIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" + +BUILD_VPHONED=1 +for arg in "$@"; do + case "$arg" in + --no-vphoned) BUILD_VPHONED=0 ;; + -h|--help) echo "Usage: $0 [--no-vphoned]"; exit 0 ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac +done + +# --- Build + sign the binary --- +echo "=== Building vphone-cli (${GIT_HASH}) ===" +echo '// Auto-generated — do not edit' > "$BUILD_INFO" +echo "enum VPhoneBuildInfo { static let commitHash = \"${GIT_HASH}\" }" >> "$BUILD_INFO" +swift build -c release + +echo "=== Signing with entitlements ===" +codesign --force --sign - --entitlements "$ENTITLEMENTS" "$BINARY" +echo " signed OK → ${BINARY}" + +# --- Bundle (.app used for GUI boot) --- +echo "=== Bundling ${BUNDLE} ===" +mkdir -p "${BUNDLE}/Contents/MacOS" "${BUNDLE}/Contents/Resources" +cp -f "$BINARY" "$BUNDLE_BIN" +cp -f "$INFO_PLIST" "${BUNDLE}/Contents/Info.plist" +cp -f "sources/AppIcon.icns" "${BUNDLE}/Contents/Resources/AppIcon.icns" +cp -f "scripts/vphoned/signcert.p12" "${BUNDLE}/Contents/Resources/signcert.p12" +cp -f "$(command -v ldid)" "${BUNDLE}/Contents/MacOS/ldid" +codesign --force --sign - "${BUNDLE}/Contents/MacOS/ldid" +codesign --force --sign - --entitlements "$ENTITLEMENTS" "$BUNDLE_BIN" +echo " bundled → ${BUNDLE}" + +# --- vphoned guest daemon (cross-compiled + signed for iOS arm64) --- +if [[ "$BUILD_VPHONED" -eq 1 ]]; then + command -v ldid >/dev/null 2>&1 \ + || { echo "Error: ldid not found. Run: brew install ldid-procursus" >&2; exit 1; } + echo "=== Building vphoned ===" + make -C scripts/vphoned GIT_HASH="$GIT_HASH" + echo "=== Signing vphoned ===" + mkdir -p .build + cp scripts/vphoned/vphoned .build/vphoned.signed + ldid \ + -Sscripts/vphoned/entitlements.plist \ + -M "-Kscripts/vphoned/signcert.p12" \ + .build/vphoned.signed + echo " signed → .build/vphoned.signed" +fi + +# --- Bundle the standalone runtime mini-repo into Contents/Resources --- +RES="${BUNDLE}/Contents/Resources" +echo "=== Bundling runtime assets → ${RES} ===" +rm -rf "${RES}/scripts" "${RES}/tools" "${RES}/.tools" "${RES}/vphoned.signed" +mkdir -p "${RES}/scripts" "${RES}/tools" "${RES}/.tools/bin" +# Mirror scripts/ EXCEPT the make-coupled orchestrator, toolchain source, caches. +rsync -a \ + --exclude 'setup_machine.sh' \ + --exclude 'repos' \ + --exclude '__pycache__' \ + --exclude '.git' \ + --exclude '.build' \ + scripts/ "${RES}/scripts/" +cp -f tools/apfs_snap_rename.py "${RES}/tools/apfs_snap_rename.py" +# Custom-built tools (bundled; not brew/pip). apfs_sealvolume is NOT bundled +# (it is extracted from the target IPSW at `fw prepare` time — Task 5). +for t in trustcache insert_dylib; do + if [[ -x ".tools/bin/$t" ]]; then cp -f ".tools/bin/$t" "${RES}/.tools/bin/$t" + else echo "Error: .tools/bin/$t missing — run ./scripts/setup_tools.sh first" >&2; exit 1; fi +done +[[ -f .build/vphoned.signed ]] && cp -f .build/vphoned.signed "${RES}/vphoned.signed" || true +# requirements.txt lets the app provision its own ~/.vphone/venv on first run +# (see VPhoneResources.pythonExecutable) — the app carries no venv itself. +cp -f requirements.txt "${RES}/requirements.txt" +echo " bundled: scripts/ (patchers+resources), tools/, .tools/bin/{trustcache,insert_dylib}, vphoned.signed, requirements.txt" + +# Re-sign: codesign seals Contents/Resources at sign time, so the earlier +# bundle-step signature (made before these assets existed) is now stale — +# re-signing here reseals against the final Resources tree. +echo "=== Re-signing ${BUNDLE_BIN} (resealing Resources) ===" +codesign --force --sign - --entitlements "$ENTITLEMENTS" "$BUNDLE_BIN" +echo " resealed OK" + +echo "" +echo "=== Build complete ===" +echo " binary : ${BINARY}" +echo " bundle : ${BUNDLE}" +[[ "$BUILD_VPHONED" -eq 1 ]] && echo " vphoned: .build/vphoned.signed" +echo "" +echo "Run: ${BINARY} --help" diff --git a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift index 378660a..8d13320 100644 --- a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift +++ b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift @@ -12,6 +12,7 @@ import Foundation import CryptoKit import Img4tool +import VPhoneCore enum ProcessError: Error { case failed(Int32, String) @@ -46,6 +47,7 @@ public final class CryptexFilesystemPatcher: Patcher { public let noBinpack: Bool public let noVphoned: Bool let vphoneCliDirectory = URL(filePath: "./") + let resources = VPhoneResources.resolve() var buildManiest: Data var rebuiltData: Data? @@ -134,7 +136,7 @@ public final class CryptexFilesystemPatcher: Patcher { print("- Fix Dyld Cache") try addDyldSymlinks(targetMount: targetMount) - let cfwInputOgPath = vphoneCliDirectory.appending(path: "scripts/resources/cfw_input.tar.zst") + let cfwInputOgPath = resources.resourceArchivesDir.appendingPathComponent("cfw_input.tar.zst") let cfwInputPath = try createTmpDir() _ = try runProcess("/usr/bin/tar", [ "--zstd", "-xf", cfwInputOgPath.path, "-C", cfwInputPath.path @@ -178,8 +180,8 @@ public final class CryptexFilesystemPatcher: Patcher { func patchLaunchdCacheLoader(targetMount: String, cfwInput: URL) throws { let target = URL.init(filePath: targetMount) let launchdCacheLoaderPath = target.appending(path: "/usr/libexec/launchd_cache_loader") - let pythonPath = vphoneCliDirectory.appending(path: ".venv/bin/python3") - let patcherPath = vphoneCliDirectory.appending(path: "scripts/patchers/cfw.py") + let pythonPath = try resources.pythonExecutable() + let patcherPath = resources.cfwPy _ = try runProcess(pythonPath.path, [ patcherPath.path, "patch-launchd-cache-loader", launchdCacheLoaderPath.path @@ -196,7 +198,7 @@ public final class CryptexFilesystemPatcher: Patcher { func injectLaunchDaemons(targetMount: String, cfwInput: URL, vphoned: Bool = true, cfw: Bool = true) throws { let target = URL.init(filePath: targetMount) - let scriptDir = vphoneCliDirectory.appending(path: "scripts") + let scriptDir = resources.scriptsDir let tmpDir = try createTmpDir() let launchdPath = tmpDir.appending(path: "launchd.plist") @@ -224,8 +226,9 @@ public final class CryptexFilesystemPatcher: Patcher { } } - _ = try runProcess(vphoneCliDirectory.appending(path: ".venv/bin/python3").path, [ - vphoneCliDirectory.appending(path: "scripts/patchers/cfw.py").path, "inject-daemons", + let pythonPath = try resources.pythonExecutable() + _ = try runProcess(pythonPath.path, [ + resources.cfwPy.path, "inject-daemons", launchdPath.path, launchDaemonsPath.path ]) try FileManager.default.moveItem(at: launchdPath, to: launchdOgPath) @@ -242,9 +245,12 @@ public final class CryptexFilesystemPatcher: Patcher { func addVphoned(targetMount: String, cfwInput: URL) throws { let target = URL.init(filePath: targetMount) - let scriptDir = vphoneCliDirectory.appending(path: "scripts") + let scriptDir = resources.scriptsDir let vphonedSrc = scriptDir.appendingPathComponent("vphoned") - let vphonedBin = vphonedSrc.appendingPathComponent("vphoned") + // vphonedSrc (bundled source) is read-only inside a packaged .app, so the + // compiled binary must land in a writable temp dir, not next to the source. + let buildDir = try createTmpDir() + let vphonedBin = buildDir.appendingPathComponent("vphoned") try buildVphoned(vphonedSrc: vphonedSrc, vphonedBin: vphonedBin) defer { try? FileManager.default.removeItem(at: vphonedBin) } @@ -323,8 +329,9 @@ public final class CryptexFilesystemPatcher: Patcher { func patchMobileActivation(targetMount: String, cfwInput: URL) throws { let target = URL.init(filePath: targetMount) let mobileActivationdPath = target.appending(path: "/usr/libexec/mobileactivationd") - _ = try runProcess("./.venv/bin/python3", [ - "./scripts/patchers/cfw.py", "patch-mobileactivationd", + let pythonPath = try resources.pythonExecutable() + _ = try runProcess(pythonPath.path, [ + resources.cfwPy.path, "patch-mobileactivationd", mobileActivationdPath.path ]) _ = try runProcess("/bin/chmod", ["0755", mobileActivationdPath.path]) @@ -398,7 +405,12 @@ public final class CryptexFilesystemPatcher: Patcher { private func identifyApfsSealvolume() throws -> URL { let iosVersion = try getProductVersion() - let path = self.vphoneCliDirectory.appending(path: ".tools/apfs_sealvolume_\(iosVersion)") + // VPHONE_SEAL_DIR (set by the CLI's `fw prepare`/`fw patch`) must agree with + // wherever fw_prepare.sh's download_apfs_sealvolume() wrote the file; unset + // (dev Makefile flow) falls back to the historical repo-relative `.tools/`. + let sealDir = ProcessInfo.processInfo.environment["VPHONE_SEAL_DIR"].map { URL(fileURLWithPath: $0) } + ?? self.vphoneCliDirectory.appending(path: ".tools") + let path = sealDir.appendingPathComponent("apfs_sealvolume_\(iosVersion)") guard FileManager.default.fileExists(atPath: path.path) else { throw FirmwareManifest.ManifestError.fileNotFound(path.path) }