Add JB finalizer script; remove IPA signing UI

Add scripts/cfw_install_jb_post.sh — an idempotent SSH-based finalizer to complete JB bootstrap on a normally-booted vphone (creates /var/jb symlink, fixes ownership, runs prep_bootstrap, creates markers, installs Sileo, and runs apt; requires sshpass). Add Makefile help, .PHONY and target cfw_install_jb_finalize to invoke the script. Remove host-side IPA signing/installing and related UI: delete VPhoneSigner, VPhoneIPAInstaller, VPhoneMenuInstall and remove signer/ipaInstaller fields and menu items/callbacks from the vphone-cli UI (also removed the DevMode enable WIP flow). Misc: minor table/formatting tweaks in AGENTS.md and research docs.
This commit is contained in:
Lakr
2026-03-07 18:34:49 +08:00
parent 048f4c7cc1
commit cfee3ea076
13 changed files with 246 additions and 525 deletions

View File

@@ -135,10 +135,6 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
mc.locationProvider = provider
}
mc.screenRecorder = VPhoneScreenRecorder()
if let signer = VPhoneSigner() {
mc.signer = signer
mc.ipaInstaller = VPhoneIPAInstaller(signer: signer)
}
menuController = mc
// Wire location toggle through onConnect/onDisconnect

View File

@@ -261,24 +261,12 @@ class VPhoneControl {
let enabled: Bool
}
struct DevModeEnableResult {
let alreadyEnabled: Bool
let message: String
}
func sendDevModeStatus() async throws -> DevModeStatus {
let (resp, _) = try await sendRequest(["t": "devmode", "action": "status"])
let enabled = resp["enabled"] as? Bool ?? false
return DevModeStatus(enabled: enabled)
}
func sendDevModeEnable() async throws -> DevModeEnableResult {
let (resp, _) = try await sendRequest(["t": "devmode", "action": "enable"])
let alreadyEnabled = resp["already_enabled"] as? Bool ?? false
let message = resp["msg"] as? String ?? ""
return DevModeEnableResult(alreadyEnabled: alreadyEnabled, message: message)
}
func sendPing() async throws {
_ = try await sendRequest(["t": "ping"])
}

View File

@@ -1,164 +0,0 @@
import Foundation
// MARK: - IPA Installer
/// Host-side IPA installer. Uses VPhoneSigner for re-signing,
/// ideviceinstaller for USB installation via usbmuxd.
@MainActor
class VPhoneIPAInstaller {
let signer: VPhoneSigner
private let ideviceInstallerURL: URL
private let ideviceIdURL: URL
init?(signer: VPhoneSigner, bundle: Bundle = .main) {
guard let execURL = bundle.executableURL else { return nil }
let macosDir = execURL.deletingLastPathComponent()
ideviceInstallerURL = macosDir.appendingPathComponent("ideviceinstaller")
ideviceIdURL = macosDir.appendingPathComponent("idevice_id")
let fm = FileManager.default
guard fm.fileExists(atPath: ideviceInstallerURL.path),
fm.fileExists(atPath: ideviceIdURL.path)
else { return nil }
self.signer = signer
}
// MARK: - Install
/// Install an IPA. If `resign` is true, re-sign all Mach-O binaries
/// preserving their original entitlements before installing.
func install(ipaURL: URL, resign: Bool) async throws {
let udid = try await getUDID()
print("[ipa] device UDID: \(udid)")
var installURL = ipaURL
var tempDir: URL?
if resign {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("vphone-cli-resign-\(UUID().uuidString)")
tempDir = dir
installURL = try await resignIPA(ipaURL: ipaURL, tempDir: dir)
}
defer {
if let tempDir {
try? FileManager.default.removeItem(at: tempDir)
}
}
print("[ipa] installing \(installURL.lastPathComponent) to \(udid)...")
let result = try await signer.run(
ideviceInstallerURL,
arguments: ["-u", udid, "install", installURL.path]
)
guard result.status == 0 else {
let msg = result.stderr.isEmpty ? result.stdout : result.stderr
throw IPAError.installFailed(msg.trimmingCharacters(in: .whitespacesAndNewlines))
}
print("[ipa] installed successfully")
}
// MARK: - UDID Discovery
private func getUDID() async throws -> String {
let result = try await signer.run(ideviceIdURL, arguments: ["-l"])
guard result.status == 0 else {
throw IPAError.noDevice
}
let udids = result.stdout
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
guard let first = udids.first else {
throw IPAError.noDevice
}
return first
}
// MARK: - Re-sign IPA
private func resignIPA(ipaURL: URL, tempDir: URL) async throws -> URL {
let fm = FileManager.default
try fm.createDirectory(at: tempDir, withIntermediateDirectories: true)
// Unzip
print("[ipa] extracting \(ipaURL.lastPathComponent)...")
let unzip = try await signer.run(
URL(fileURLWithPath: "/usr/bin/unzip"),
arguments: ["-o", ipaURL.path, "-d", tempDir.path]
)
guard unzip.status == 0 else {
throw IPAError.extractFailed(unzip.stderr)
}
// Remove macOS resource fork files that break iOS installd
_ = try? await signer.run(
URL(fileURLWithPath: "/usr/bin/find"),
arguments: [tempDir.path, "-name", "._*", "-delete"]
)
_ = try? await signer.run(
URL(fileURLWithPath: "/usr/bin/find"),
arguments: [tempDir.path, "-name", ".DS_Store", "-delete"]
)
// Find Payload/*.app
let payloadDir = tempDir.appendingPathComponent("Payload")
guard fm.fileExists(atPath: payloadDir.path) else {
throw IPAError.invalidIPA("no Payload directory")
}
let contents = try fm.contentsOfDirectory(atPath: payloadDir.path)
guard let appName = contents.first(where: { $0.hasSuffix(".app") }) else {
throw IPAError.invalidIPA("no .app bundle in Payload")
}
let appDir = payloadDir.appendingPathComponent(appName)
// Walk and re-sign all Mach-O files
let machoFiles = signer.findMachOFiles(in: appDir)
print("[ipa] re-signing \(machoFiles.count) Mach-O binaries...")
for file in machoFiles {
do {
try await signer.signFile(at: file, tempDir: tempDir)
} catch {
print("[ipa] warning: \(error)")
}
}
// Re-zip (use zip from the temp dir so Payload/ is at the root)
let outputIPA = tempDir.appendingPathComponent("resigned.ipa")
print("[ipa] re-packaging...")
let zip = try await signer.run(
URL(fileURLWithPath: "/usr/bin/zip"),
arguments: ["-r", "-y", outputIPA.path, "Payload"],
currentDirectory: tempDir
)
guard zip.status == 0 else {
throw IPAError.repackFailed(zip.stderr)
}
return outputIPA
}
// MARK: - Errors
enum IPAError: Error, CustomStringConvertible {
case noDevice
case extractFailed(String)
case invalidIPA(String)
case repackFailed(String)
case installFailed(String)
var description: String {
switch self {
case .noDevice: "no device found (is the VM running?)"
case let .extractFailed(msg): "failed to extract IPA: \(msg)"
case let .invalidIPA(msg): "invalid IPA: \(msg)"
case let .repackFailed(msg): "failed to repackage IPA: \(msg)"
case let .installFailed(msg): "install failed: \(msg)"
}
}
}
}

View File

@@ -9,7 +9,6 @@ extension VPhoneMenuController {
menu.addItem(makeItem("File Browser", action: #selector(openFiles)))
menu.addItem(NSMenuItem.separator())
menu.addItem(makeItem("Developer Mode Status", action: #selector(devModeStatus)))
menu.addItem(makeItem("Enable Developer Mode [WIP]", action: #selector(devModeEnable)))
menu.addItem(NSMenuItem.separator())
menu.addItem(makeItem("Ping", action: #selector(sendPing)))
menu.addItem(makeItem("Guest Version", action: #selector(queryGuestVersion)))
@@ -36,23 +35,6 @@ extension VPhoneMenuController {
}
}
@objc func devModeEnable() {
Task {
do {
let result = try await control.sendDevModeEnable()
showAlert(
title: "Developer Mode",
message: result.message.isEmpty
? (result.alreadyEnabled ? "Developer Mode already enabled." : "Developer Mode enabled.")
: result.message,
style: .informational
)
} catch {
showAlert(title: "Developer Mode", message: "\(error)", style: .warning)
}
}
}
@objc func sendPing() {
Task {
do {

View File

@@ -17,8 +17,6 @@ class VPhoneMenuController {
var locationReplayStopItem: NSMenuItem?
var screenRecorder: VPhoneScreenRecorder?
var recordingItem: NSMenuItem?
var signer: VPhoneSigner?
var ipaInstaller: VPhoneIPAInstaller?
init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) {
self.keyHelper = keyHelper
@@ -47,7 +45,6 @@ class VPhoneMenuController {
mainMenu.addItem(buildKeysMenu())
mainMenu.addItem(buildTypeMenu())
mainMenu.addItem(buildConnectMenu())
mainMenu.addItem(buildInstallMenu())
mainMenu.addItem(buildLocationMenu())
mainMenu.addItem(buildRecordMenu())
mainMenu.addItem(buildBatteryMenu())

View File

@@ -1,116 +0,0 @@
import AppKit
import UniformTypeIdentifiers
// MARK: - Install Menu
extension VPhoneMenuController {
func buildInstallMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Install")
menu.addItem(makeItem("Install Package (.ipa) [WIP]", action: #selector(installPackage)))
menu.addItem(makeItem("Install Package with Resign (.ipa) [WIP]", action: #selector(installPackageResign)))
menu.addItem(NSMenuItem.separator())
menu.addItem(makeItem("Upload Binary to Guest", action: #selector(uploadBinary)))
menu.addItem(makeItem("Upload Binary with Resign to Guest", action: #selector(uploadBinaryResign)))
item.submenu = menu
return item
}
// MARK: - IPA Install
@objc func installPackage() {
pickAndInstall(resign: false)
}
@objc func installPackageResign() {
pickAndInstall(resign: true)
}
private func pickAndInstall(resign: Bool) {
let panel = NSOpenPanel()
panel.title = "Select IPA"
panel.allowedContentTypes = [.init(filenameExtension: "ipa")!]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
guard panel.runModal() == .OK, let url = panel.url else { return }
guard let installer = ipaInstaller else {
showAlert(
title: "Install Package",
message: "IPA installer not available (bundled tools missing).",
style: .warning
)
return
}
Task {
do {
try await installer.install(ipaURL: url, resign: resign)
showAlert(
title: "Install Package",
message: "Successfully installed \(url.lastPathComponent).",
style: .informational
)
} catch {
showAlert(
title: "Install Package",
message: "\(error)",
style: .warning
)
}
}
}
// MARK: - Upload Binary
@objc func uploadBinary() {
pickAndUploadBinary(resign: false)
}
@objc func uploadBinaryResign() {
pickAndUploadBinary(resign: true)
}
private func pickAndUploadBinary(resign: Bool) {
let panel = NSOpenPanel()
panel.title = "Select Binary to Upload"
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
guard panel.runModal() == .OK, let url = panel.url else { return }
Task {
do {
var data = try Data(contentsOf: url)
if resign {
guard let signer else {
showAlert(
title: "Upload Binary",
message: "Signing tools not available (bundled tools missing).",
style: .warning
)
return
}
data = try await signer.resign(data: data, filename: url.lastPathComponent)
}
let filename = url.lastPathComponent
let remotePath = "/var/root/Library/Caches/\(filename)"
try await control.uploadFile(path: remotePath, data: data, permissions: "755")
showAlert(
title: "Upload Binary",
message: "Uploaded \(filename) to \(remotePath) (\(data.count) bytes)\(resign ? " [resigned]" : "").",
style: .informational
)
} catch {
showAlert(
title: "Upload Binary",
message: "\(error)",
style: .warning
)
}
}
}
}

View File

@@ -1,157 +0,0 @@
import Foundation
// MARK: - Code Signer
/// Host-side code signing using bundled ldid + signcert.p12.
/// Preserves existing entitlements when re-signing.
@MainActor
class VPhoneSigner {
private let ldidURL: URL
private let signcertURL: URL
init?(bundle: Bundle = .main) {
guard let execURL = bundle.executableURL else { return nil }
let macosDir = execURL.deletingLastPathComponent()
let resourcesDir = macosDir
.deletingLastPathComponent()
.appendingPathComponent("Resources")
ldidURL = macosDir.appendingPathComponent("ldid")
signcertURL = resourcesDir.appendingPathComponent("signcert.p12")
let fm = FileManager.default
guard fm.fileExists(atPath: ldidURL.path),
fm.fileExists(atPath: signcertURL.path)
else { return nil }
}
// MARK: - Sign Binary
/// Re-sign a single Mach-O binary in-memory. Preserves existing entitlements.
func resign(data: Data, filename: String) async throws -> Data {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("vphone-cli-sign-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let binaryURL = tempDir.appendingPathComponent(filename)
try data.write(to: binaryURL)
try await signFile(at: binaryURL, tempDir: tempDir)
return try Data(contentsOf: binaryURL)
}
/// Re-sign a Mach-O binary on disk in-place. Preserves existing entitlements.
func signFile(at url: URL, tempDir: URL) async throws {
let entsResult = try await run(ldidURL, arguments: ["-e", url.path])
let entsXML = entsResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
let cert = signcertURL.path
var args: [String]
if !entsXML.isEmpty, entsXML.hasPrefix("<?xml") || entsXML.hasPrefix("<!DOCTYPE") {
let entsFile = tempDir.appendingPathComponent("ents-\(UUID().uuidString).plist")
try entsXML.write(to: entsFile, atomically: true, encoding: .utf8)
args = ["-S\(entsFile.path)", "-M", "-K\(cert)", url.path]
} else {
args = ["-S", "-M", "-K\(cert)", url.path]
}
let result = try await run(ldidURL, arguments: args)
guard result.status == 0 else {
throw SignError.ldidFailed(url.lastPathComponent, result.stderr)
}
print("[sign] signed \(url.lastPathComponent)")
}
// MARK: - Mach-O Detection
/// Recursively find all Mach-O files in a directory.
func findMachOFiles(in directory: URL) -> [URL] {
let fm = FileManager.default
guard let enumerator = fm.enumerator(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else { return [] }
var results: [URL] = []
for case let url as URL in enumerator {
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey]),
values.isRegularFile == true,
Self.isMachO(at: url)
else { continue }
results.append(url)
}
return results
}
static func isMachO(at url: URL) -> Bool {
guard let fh = try? FileHandle(forReadingFrom: url) else { return false }
defer { try? fh.close() }
guard let data = try? fh.read(upToCount: 4), data.count == 4 else { return false }
let magic = data.withUnsafeBytes { $0.load(as: UInt32.self) }
return magic == 0xFEED_FACF // MH_MAGIC_64
|| magic == 0xCFFA_EDFE // MH_CIGAM_64
|| magic == 0xFEED_FACE // MH_MAGIC
|| magic == 0xCEFA_EDFE // MH_CIGAM
|| magic == 0xCAFE_BABE // FAT_MAGIC
|| magic == 0xBEBA_FECA // FAT_CIGAM
}
// MARK: - Process Runner
struct ProcessResult: Sendable {
let stdout: String
let stderr: String
let status: Int32
}
func run(
_ executable: URL,
arguments: [String],
currentDirectory: URL? = nil
) async throws -> ProcessResult {
let execPath = executable.path
let args = arguments
let dirPath = currentDirectory?.path
return try await Task.detached {
let process = Process()
process.executableURL = URL(fileURLWithPath: execPath)
process.arguments = args
if let dirPath {
process.currentDirectoryURL = URL(fileURLWithPath: dirPath)
}
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
try process.run()
process.waitUntilExit()
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
return ProcessResult(
stdout: String(data: outData, encoding: .utf8) ?? "",
stderr: String(data: errData, encoding: .utf8) ?? "",
status: process.terminationStatus
)
}.value
}
// MARK: - Errors
enum SignError: Error, CustomStringConvertible {
case ldidFailed(String, String)
var description: String {
switch self {
case let .ldidFailed(file, msg): "failed to sign \(file): \(msg)"
}
}
}
}