Merge pull request #127 from lbr77/main

ipa install
This commit is contained in:
LiBr
2026-03-07 23:51:33 +08:00
committed by GitHub
parent 122f2aaf0c
commit 56451c4d53
12 changed files with 1497 additions and 8 deletions

View File

@@ -25,6 +25,8 @@ class VPhoneControl {
private(set) var isConnected = false
private(set) var guestName = ""
private(set) var guestCaps: [String] = []
static let ipaInstallUnavailableMessage =
"Guest is not jailbroken or no IPA installer is available. Skipping IPA install."
/// Path to the signed vphoned binary. When set, enables auto-update.
var guestBinaryURL: URL?
@@ -92,6 +94,104 @@ class VPhoneControl {
}
}
var canInstallIPA: Bool {
isConnected
}
private static func bundleIdentifier(fromIPA url: URL) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
process.arguments = [
"-c",
"""
import plistlib, sys, zipfile
ipa_path = sys.argv[1]
with zipfile.ZipFile(ipa_path, "r") as zf:
plist_name = next((name for name in zf.namelist() if name.startswith("Payload/") and name.endswith(".app/Info.plist")), None)
if not plist_name:
raise SystemExit("missing app Info.plist in IPA")
info = plistlib.loads(zf.read(plist_name))
bundle_id = info.get("CFBundleIdentifier")
if not bundle_id:
raise SystemExit("missing CFBundleIdentifier in IPA")
print(bundle_id)
""",
url.path,
]
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
try process.run()
process.waitUntilExit()
let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if process.terminationStatus == 0, !output.isEmpty {
return output
}
let errorOutput = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let detail = errorOutput.isEmpty ? "failed to parse CFBundleIdentifier from IPA" : errorOutput
throw ControlError.protocolError(detail)
}
private static func signCertURL() -> URL? {
let fm = FileManager.default
let candidates = [
Bundle.main.resourceURL?.appendingPathComponent("signcert.p12"),
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/signcert.p12"),
URL(fileURLWithPath: fm.currentDirectoryPath).appendingPathComponent("scripts/vphoned/signcert.p12"),
URL(fileURLWithPath: fm.currentDirectoryPath).appendingPathComponent("../scripts/vphoned/signcert.p12"),
]
for candidate in candidates.compactMap({ $0 }) {
if fm.fileExists(atPath: candidate.path) {
return candidate
}
}
return nil
}
private static func runHostProcess(executableURL: URL, arguments: [String]) throws -> String {
let process = Process()
process.executableURL = executableURL
process.arguments = arguments
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
try process.run()
process.waitUntilExit()
let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let errorOutput = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
if process.terminationStatus == 0 {
return output
}
let detail = errorOutput.trimmingCharacters(in: .whitespacesAndNewlines)
throw ControlError.protocolError(detail.isEmpty ? "host tool failed: \(executableURL.lastPathComponent)" : detail)
}
private static func buildInstallArchive(fromIPA ipaURL: URL) throws -> URL {
let fm = FileManager.default
let tempRoot = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let extractDir = tempRoot.appendingPathComponent("extract", isDirectory: true)
try fm.createDirectory(at: extractDir, withIntermediateDirectories: true)
let dittoURL = URL(fileURLWithPath: "/usr/bin/ditto")
_ = try runHostProcess(executableURL: dittoURL, arguments: ["-x", "-k", ipaURL.path, extractDir.path])
let tarURL = tempRoot.appendingPathComponent("package.tar")
let tarTool = URL(fileURLWithPath: "/usr/bin/tar")
_ = try runHostProcess(executableURL: tarTool, arguments: ["-cf", tarURL.path, "-C", extractDir.path, "."])
return tarURL
}
// MARK: - Guest Binary Hash
private func loadGuestBinary() {
@@ -385,6 +485,103 @@ class VPhoneControl {
_ = try await sendRequest(["t": "file_rename", "from": from, "to": to])
}
func installIPA(localURL: URL) async throws -> String {
do {
return try await installIPAWithBuiltInInstaller(localURL: localURL)
} catch let ControlError.guestError(message) where message == "unknown type: ipa_install" {
throw ControlError.guestError(
"Guest vphoned does not support ipa_install yet. Reconnect or reboot the guest so the updated daemon can take over."
)
}
}
private func installIPAWithBuiltInInstaller(localURL: URL) async throws -> String {
let archiveURL = try Self.buildInstallArchive(fromIPA: localURL)
let data: Data
do {
data = try Data(contentsOf: archiveURL)
} catch {
throw ControlError.protocolError("failed to read install archive: \(error)")
}
defer {
try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent())
}
let remoteDir = "/var/mobile/Documents/vphone-installs"
let remoteName = "\(UUID().uuidString)-\(localURL.deletingPathExtension().lastPathComponent).tar"
let remotePath = "\(remoteDir)/\(remoteName)"
var cleanupPaths = [remotePath]
defer {
Task {
for cleanupPath in cleanupPaths {
try? await deleteFile(path: cleanupPath)
}
}
}
try await createDirectory(path: remoteDir)
try await uploadFile(path: remotePath, data: data)
var request: [String: Any] = [
"t": "ipa_install",
"path": remotePath,
"registration": "User",
"package_format": "tar",
]
if let signCertURL = Self.signCertURL() {
let signCertData = try Data(contentsOf: signCertURL)
let certRemotePath = "\(remoteDir)/\(UUID().uuidString)-signcert.p12"
cleanupPaths.append(certRemotePath)
try await uploadFile(path: certRemotePath, data: signCertData)
request["cert_path"] = certRemotePath
}
let (resp, _) = try await sendRequest(request)
if let detail = resp["msg"] as? String, !detail.isEmpty {
return detail
}
return "Installed \(localURL.lastPathComponent) through the built-in IPA installer."
}
func installIPAWithTrollStoreLite(localURL: URL) async throws -> String {
let data: Data
do {
data = try Data(contentsOf: localURL)
} catch {
throw ControlError.protocolError("failed to read IPA: \(error)")
}
let bundleIdentifier = try Self.bundleIdentifier(fromIPA: localURL)
let remoteDir = "/var/mobile/Documents/vphone-installs"
let remoteName = "\(UUID().uuidString)-\(localURL.lastPathComponent)"
let remotePath = "\(remoteDir)/\(remoteName)"
defer {
Task {
try? await deleteFile(path: remotePath)
}
}
try await createDirectory(path: remoteDir)
try await uploadFile(path: remotePath, data: data)
do {
let (resp, _) = try await sendRequest([
"t": "tslite_install",
"path": remotePath,
"bundle_id": bundleIdentifier,
"registration": "User",
])
if let detail = resp["msg"] as? String, !detail.isEmpty {
return detail
}
return "Installed \(localURL.lastPathComponent) through TrollStore Lite."
} catch {
throw error
}
}
// MARK: - Location
func sendLocation(
@@ -517,7 +714,7 @@ class VPhoneControl {
private static func timeoutForRequest(type: String) -> TimeInterval {
switch type {
case "file_get", "file_put":
case "file_get", "file_put", "tslite_install", "ipa_install":
transferRequestTimeout
case "devmode", "file_list", "file_delete", "file_rename", "file_mkdir":
slowRequestTimeout

View File

@@ -45,6 +45,7 @@ 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

@@ -0,0 +1,53 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
// MARK: - Install Menu
extension VPhoneMenuController {
func buildInstallMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Install")
menu.addItem(makeItem("Install IPA...", action: #selector(installIPAFromDisk)))
item.submenu = menu
return item
}
@objc func installIPAFromDisk() {
guard control.isConnected else {
showAlert(title: "Install IPA", message: "Guest is not connected.", style: .warning)
return
}
guard control.canInstallIPA else {
showAlert(
title: "Install IPA",
message: VPhoneControl.ipaInstallUnavailableMessage,
style: .warning
)
return
}
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.allowedContentTypes = [
UTType(filenameExtension: "ipa") ?? .data,
]
panel.prompt = "Install"
panel.message = "Choose an IPA to install in the guest."
let response = panel.runModal()
guard response == .OK, let url = panel.url else { return }
Task {
do {
let result = try await control.installIPA(localURL: url)
print("[install] \(result)")
} catch {
showAlert(title: "Install IPA", message: "\(error)", style: .warning)
}
}
}
}

View File

@@ -5,8 +5,10 @@ import Virtualization
class VPhoneVirtualMachineView: VZVirtualMachineView {
var keyHelper: VPhoneKeyHelper?
weak var control: VPhoneControl?
private var currentTouchSwipeAim: Int = 0
private var isDragHighlightVisible = false
// MARK: - Private API Accessors
@@ -42,6 +44,7 @@ class VPhoneVirtualMachineView: VZVirtualMachineView {
super.viewDidMoveToWindow()
// Ensure keyboard events route to VM view right after window attach.
window?.makeFirstResponder(self)
registerForDraggedTypes([.fileURL])
}
override func mouseDown(with event: NSEvent) {
@@ -82,6 +85,88 @@ class VPhoneVirtualMachineView: VZVirtualMachineView {
return super.performKeyEquivalent(with: event)
}
// MARK: - Drag and Drop Install
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard droppedIPAURL(from: sender) != nil else { return [] }
updateDragHighlight(true)
return .copy
}
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
_ = sender
updateDragHighlight(false)
}
override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
droppedIPAURL(from: sender) != nil
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
updateDragHighlight(false)
guard let url = droppedIPAURL(from: sender) else { return false }
Task { @MainActor in
guard let control else {
showAlert(title: "Install IPA", message: "Guest is not connected.", style: .warning)
return
}
guard control.isConnected else {
showAlert(title: "Install IPA", message: "Guest is not connected.", style: .warning)
return
}
guard control.canInstallIPA else {
showAlert(
title: "Install IPA",
message: VPhoneControl.ipaInstallUnavailableMessage,
style: .warning
)
return
}
do {
let result = try await control.installIPA(localURL: url)
print("[install] \(result)")
} catch {
showAlert(title: "Install IPA", message: "\(error)", style: .warning)
}
}
return true
}
private func droppedIPAURL(from sender: any NSDraggingInfo) -> URL? {
let options: [NSPasteboard.ReadingOptionKey: Any] = [
.urlReadingFileURLsOnly: true,
]
guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: options) as? [URL] else {
return nil
}
return urls.first {
let ext = $0.pathExtension.lowercased()
return ext == "ipa" || ext == "tipa"
}
}
private func updateDragHighlight(_ visible: Bool) {
guard isDragHighlightVisible != visible else { return }
isDragHighlightVisible = visible
wantsLayer = true
layer?.borderWidth = visible ? 4 : 0
layer?.borderColor = visible ? NSColor.systemGreen.cgColor : NSColor.clear.cgColor
}
private func showAlert(title: String, message: String, style: NSAlert.Style) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = style
if let window {
alert.beginSheetModal(for: window)
} else {
alert.runModal()
}
}
// MARK: - Legacy Touch Injection (macOS 15)
@discardableResult

View File

@@ -20,6 +20,7 @@ class VPhoneWindowController: NSObject, NSToolbarDelegate {
view.virtualMachine = vm
view.capturesSystemKeys = true
view.keyHelper = keyHelper
view.control = control
let vmView: NSView = view
let scale = CGFloat(screenScale)