mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-04-05 13:09:06 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user