fix: resolve all paths correctly for the bundled / brew-installed app

A whole-codebase audit surfaced path bugs that only bite the bundled .app —
especially when brew puts vphone-cli on $PATH via a symlink, so the process
launches by a bare name from an arbitrary CWD:

- HIGH (crash): CommandLine.arguments[0] was used to locate the running binary
  and the resource base (VPhoneResources.resolve default, vm launch bootBinary,
  vm create selfExe). Under a bare-name PATH launch argv[0] is just
  "vphone-cli", which URL(fileURLWithPath:) resolves against the CWD (e.g.
  $HOME/vphone-cli) — so `vm launch` errored "not found" and `vm create` couldn't
  respawn to boot. Add VPhoneResources.runningExecutable() using
  Bundle.main.executableURL (the kernel-provided path, correct regardless of
  argv[0]/CWD/symlink) and route all three through it. One helper fixes every
  .resolve() consumer.

- MEDIUM (silent): the extra-deb feature resolved its cache + manifest under the
  read-only bundle (Resources/debs, Resources/debs.list). Honor VPHONE_DEBS_DIR
  (a writable ~/.vphone/debs the app now sets, mirroring IPSW_DIR/VPHONE_SEAL_DIR)
  and bundle debs.list.

- LOW (cosmetic): fw_prepare read ../README.md (absent in the bundle → firmwares
  labeled "Not Tested"). Bundle README.md.

Verified: Bundle.main.executableURL yields the real binary under a bare-name
symlink launch (argv[0] → $HOME); build clean; 79 VPhoneCore tests pass.
This commit is contained in:
zqxwce
2026-07-28 15:21:29 +03:00
committed by zqxwce
parent 70ff75067a
commit 084e239c36
9 changed files with 33 additions and 8 deletions
+5 -1
View File
@@ -95,7 +95,11 @@ done
# requirements.txt lets the app provision its own ~/.vphone/venv on first run # requirements.txt lets the app provision its own ~/.vphone/venv on first run
# (see VPhoneResources.pythonExecutable) — the app carries no venv itself. # (see VPhoneResources.pythonExecutable) — the app carries no venv itself.
cp -f requirements.txt "${RES}/requirements.txt" cp -f requirements.txt "${RES}/requirements.txt"
echo " bundled: scripts/ (patchers+resources), tools/, .tools/bin/{trustcache,insert_dylib}, vphoned.signed, requirements.txt" # debs.list = extra-deb manifest (fetch_debs.sh reads $base/debs.list); README.md
# = the Tested-Environments table fw_prepare.sh reads to label Supported firmwares.
cp -f debs.list "${RES}/debs.list"
cp -f README.md "${RES}/README.md"
echo " bundled: scripts/ (patchers+resources), tools/, .tools/bin/{trustcache,insert_dylib}, vphoned.signed, requirements.txt, debs.list, README.md"
# Re-sign: codesign seals Contents/Resources at sign time, so the earlier # Re-sign: codesign seals Contents/Resources at sign time, so the earlier
# bundle-step signature (made before these assets existed) is now stale — # bundle-step signature (made before these assets existed) is now stale —
+1 -1
View File
@@ -539,7 +539,7 @@ fi
# ── Extra debs: download from manifest, then stage the whole cache ────── # ── Extra debs: download from manifest, then stage the whole cache ──────
echo " Fetching extra debs..." echo " Fetching extra debs..."
zsh "$SCRIPT_DIR/fetch_debs.sh" || true zsh "$SCRIPT_DIR/fetch_debs.sh" || true
DEBS_CACHE="${SCRIPT_DIR:h}/debs" DEBS_CACHE="${VPHONE_DEBS_DIR:-${SCRIPT_DIR:h}/debs}"
DEBS_DEST="$MNT5/$BOOT_HASH/debs" DEBS_DEST="$MNT5/$BOOT_HASH/debs"
/bin/rm -rf "$DEBS_DEST" /bin/rm -rf "$DEBS_DEST"
deb_count=0 deb_count=0
+1 -1
View File
@@ -336,7 +336,7 @@ fi
# ── Extra debs: download from manifest, then stage the whole cache ────── # ── Extra debs: download from manifest, then stage the whole cache ──────
echo " Fetching extra debs..." echo " Fetching extra debs..."
zsh "$SCRIPT_DIR/fetch_debs.sh" || true zsh "$SCRIPT_DIR/fetch_debs.sh" || true
DEBS_CACHE="${SCRIPT_DIR:h}/debs" DEBS_CACHE="${VPHONE_DEBS_DIR:-${SCRIPT_DIR:h}/debs}"
DEBS_DEST="$MNT5/$BOOT_HASH/debs" DEBS_DEST="$MNT5/$BOOT_HASH/debs"
/bin/rm -rf "$DEBS_DEST" /bin/rm -rf "$DEBS_DEST"
deb_count=0 deb_count=0
+4 -1
View File
@@ -13,7 +13,10 @@ set -uo pipefail
SCRIPT_DIR="${0:a:h}" SCRIPT_DIR="${0:a:h}"
REPO_ROOT="${SCRIPT_DIR:h}" REPO_ROOT="${SCRIPT_DIR:h}"
CACHE_DIR="${1:-$REPO_ROOT/debs}" # VPHONE_DEBS_DIR (set by the app to a writable ~/.vphone/debs) keeps the cache
# out of the read-only .app bundle; the manifest is bundled next to this script's
# repo/Resources root, so $REPO_ROOT/debs.list resolves in both dev and .app.
CACHE_DIR="${1:-${VPHONE_DEBS_DIR:-$REPO_ROOT/debs}}"
MANIFEST="${2:-$REPO_ROOT/debs.list}" MANIFEST="${2:-$REPO_ROOT/debs.list}"
mkdir -p "$CACHE_DIR" mkdir -p "$CACHE_DIR"
+16 -2
View File
@@ -16,8 +16,21 @@ public struct VPhoneResources: Sendable {
// MARK: - Resolution // MARK: - Resolution
public static func resolve(executablePath: String = CommandLine.arguments[0]) -> VPhoneResources { /// The running executable, resolved reliably. `CommandLine.arguments[0]` is
let exe = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath() /// NOT reliable under a PATH/symlink launch (e.g. a Homebrew symlink) it's
/// a bare name that `URL(fileURLWithPath:)` resolves against the CWD, so the
/// binary/base end up under `$HOME`. `Bundle.main.executableURL` is the
/// kernel-provided executable path, correct regardless of how the process
/// was invoked; resolve symlinks so a brew symlink lands on the real binary
/// inside the .app.
public static func runningExecutable() -> URL {
if let exe = Bundle.main.executableURL { return exe.resolvingSymlinksInPath() }
return URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath()
}
public static func resolve(executablePath: String? = nil) -> VPhoneResources {
let exe = executablePath.map { URL(fileURLWithPath: $0).resolvingSymlinksInPath() }
?? runningExecutable()
let macos = exe.deletingLastPathComponent() // /Contents/MacOS let macos = exe.deletingLastPathComponent() // /Contents/MacOS
if macos.lastPathComponent == "MacOS", if macos.lastPathComponent == "MacOS",
macos.deletingLastPathComponent().lastPathComponent == "Contents" { macos.deletingLastPathComponent().lastPathComponent == "Contents" {
@@ -62,6 +75,7 @@ public struct VPhoneResources: Sendable {
} }
public var ipswCacheDir: URL { userCacheDir.appendingPathComponent("ipsws") } public var ipswCacheDir: URL { userCacheDir.appendingPathComponent("ipsws") }
public var sealVolumeCacheDir: URL { userCacheDir.appendingPathComponent("tools") } public var sealVolumeCacheDir: URL { userCacheDir.appendingPathComponent("tools") }
public var debsCacheDir: URL { userCacheDir.appendingPathComponent("debs") }
public var toolsBinDir: URL { base.appendingPathComponent(".tools/bin") } public var toolsBinDir: URL { base.appendingPathComponent(".tools/bin") }
// MARK: - Python // MARK: - Python
@@ -448,9 +448,11 @@ public struct VPhoneCreateOrchestrator {
if options.forceDSCMaxSlide { env["FORCE_DSC_MAXSLIDE"] = "1" } if options.forceDSCMaxSlide { env["FORCE_DSC_MAXSLIDE"] = "1" }
try FileManager.default.createDirectory(at: resources.ipswCacheDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: resources.ipswCacheDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: resources.debsCacheDir, withIntermediateDirectories: true)
env["VPHONE_PYTHON"] = try resources.pythonExecutable().path env["VPHONE_PYTHON"] = try resources.pythonExecutable().path
env["IPSW_DIR"] = resources.ipswCacheDir.path env["IPSW_DIR"] = resources.ipswCacheDir.path
env["VPHONE_SEAL_DIR"] = resources.sealVolumeCacheDir.path env["VPHONE_SEAL_DIR"] = resources.sealVolumeCacheDir.path
env["VPHONE_DEBS_DIR"] = resources.debsCacheDir.path
for (key, value) in sudoEnvExtras { env[key] = value } for (key, value) in sudoEnvExtras { env[key] = value }
let args = [resources.cfwInstallHostScript.path, "--variant", options.variant, bundleURL.path] let args = [resources.cfwInstallHostScript.path, "--variant", options.variant, bundleURL.path]
@@ -105,9 +105,11 @@ struct VPhoneCFWInstallCommand: ParsableCommand {
// `fw patch` path (CryptexFilesystemPatcher). // `fw patch` path (CryptexFilesystemPatcher).
try FileManager.default.createDirectory(at: resources.ipswCacheDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: resources.ipswCacheDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: resources.debsCacheDir, withIntermediateDirectories: true)
env["VPHONE_PYTHON"] = try resources.pythonExecutable().path env["VPHONE_PYTHON"] = try resources.pythonExecutable().path
env["IPSW_DIR"] = resources.ipswCacheDir.path env["IPSW_DIR"] = resources.ipswCacheDir.path
env["VPHONE_SEAL_DIR"] = resources.sealVolumeCacheDir.path env["VPHONE_SEAL_DIR"] = resources.sealVolumeCacheDir.path
env["VPHONE_DEBS_DIR"] = resources.debsCacheDir.path
let args = [resources.cfwInstallHostScript.path, "--variant", variant, bundle.url.path] let args = [resources.cfwInstallHostScript.path, "--variant", variant, bundle.url.path]
if v.tracesInternals { if v.tracesInternals {
+1 -1
View File
@@ -28,7 +28,7 @@ struct VPhoneVMCreateCommand: ParsableCommand {
func run() throws { func run() throws {
let resources = projectRoot.map { VPhoneResources(base: URL(fileURLWithPath: $0)) } ?? .resolve() let resources = projectRoot.map { VPhoneResources(base: URL(fileURLWithPath: $0)) } ?? .resolve()
let selfExe = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath() let selfExe = VPhoneResources.runningExecutable()
let orchestrator = VPhoneCreateOrchestrator( let orchestrator = VPhoneCreateOrchestrator(
library: lib.library, resources: resources, selfExecutable: selfExe) library: lib.library, resources: resources, selfExecutable: selfExe)
try orchestrator.run(.init( try orchestrator.run(.init(
+1 -1
View File
@@ -27,7 +27,7 @@ struct VPhoneVMLaunchCommand: ParsableCommand {
// The running executable is BOTH what we boot from and what preflight // The running executable is BOTH what we boot from and what preflight
// should check a bundled .app is its own boot binary. // should check a bundled .app is its own boot binary.
let bootBinary = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath() let bootBinary = VPhoneResources.runningExecutable()
guard FileManager.default.isExecutableFile(atPath: bootBinary.path) else { guard FileManager.default.isExecutableFile(atPath: bootBinary.path) else {
FileHandle.standardError.write(Data( FileHandle.standardError.write(Data(
"error: \(bootBinary.path) not found — build it first (make build/bundle).\n".utf8)) "error: \(bootBinary.path) not found — build it first (make build/bundle).\n".utf8))