mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-04-05 04:59:05 +08:00
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
53
sources/vphone-cli/VPhoneMenuInstall.swift
Normal file
53
sources/vphone-cli/VPhoneMenuInstall.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user