mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
refactor: Consolidate menus and simplify CLI to manifest-only config
- Merge Type menu into Keys, Clipboard menu into Connect - Remove --rom/--disk/--nvram/--sep-rom/--sep-storage/--screen-* CLI args; all paths and hardware config now derived from config.plist manifest - Remove --no-graphics flag; DFU mode implies headless - Fix clipboard availability to be solely capability-gated, not double-set by updateConnectAvailability update
This commit is contained in:
@@ -175,22 +175,12 @@ vm_new:
|
||||
|
||||
boot: bundle vphoned
|
||||
cd $(VM_DIR) && "$(CURDIR)/$(BUNDLE_BIN)" \
|
||||
--config ./config.plist \
|
||||
--rom ./AVPBooter.vresearch1.bin \
|
||||
--disk ./Disk.img \
|
||||
--nvram ./nvram.bin \
|
||||
--sep-rom ./AVPSEPBooter.vresearch1.bin \
|
||||
--sep-storage ./SEPStorage
|
||||
--config ./config.plist
|
||||
|
||||
boot_dfu: build
|
||||
cd $(VM_DIR) && "$(CURDIR)/$(BINARY)" \
|
||||
--config ./config.plist \
|
||||
--rom ./AVPBooter.vresearch1.bin \
|
||||
--disk ./Disk.img \
|
||||
--nvram ./nvram.bin \
|
||||
--sep-rom ./AVPSEPBooter.vresearch1.bin \
|
||||
--sep-storage ./SEPStorage \
|
||||
--no-graphics --dfu
|
||||
--dfu
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Firmware pipeline
|
||||
|
||||
@@ -3,173 +3,173 @@ import Foundation
|
||||
import Virtualization
|
||||
|
||||
class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
||||
private let cli: VPhoneCLI
|
||||
private var vm: VPhoneVirtualMachine?
|
||||
private var control: VPhoneControl?
|
||||
private var windowController: VPhoneWindowController?
|
||||
private var menuController: VPhoneMenuController?
|
||||
private var fileWindowController: VPhoneFileWindowController?
|
||||
private var keychainWindowController: VPhoneKeychainWindowController?
|
||||
private var locationProvider: VPhoneLocationProvider?
|
||||
private var sigintSource: DispatchSourceSignal?
|
||||
private let cli: VPhoneCLI
|
||||
private var vm: VPhoneVirtualMachine?
|
||||
private var control: VPhoneControl?
|
||||
private var windowController: VPhoneWindowController?
|
||||
private var menuController: VPhoneMenuController?
|
||||
private var fileWindowController: VPhoneFileWindowController?
|
||||
private var keychainWindowController: VPhoneKeychainWindowController?
|
||||
private var locationProvider: VPhoneLocationProvider?
|
||||
private var sigintSource: DispatchSourceSignal?
|
||||
|
||||
init(cli: VPhoneCLI) {
|
||||
self.cli = cli
|
||||
super.init()
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
NSApp.setActivationPolicy(cli.noGraphics ? .prohibited : .regular)
|
||||
|
||||
signal(SIGINT, SIG_IGN)
|
||||
let src = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
||||
src.setEventHandler {
|
||||
print("\n[vphone] SIGINT - shutting down")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
src.activate()
|
||||
sigintSource = src
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await self.startVirtualMachine()
|
||||
} catch {
|
||||
print("[vphone] Fatal: \(error)")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func startVirtualMachine() async throws {
|
||||
let options = try cli.resolveOptions()
|
||||
|
||||
guard FileManager.default.fileExists(atPath: options.romURL.path) else {
|
||||
throw VPhoneError.romNotFound(options.romURL.path)
|
||||
init(cli: VPhoneCLI) {
|
||||
self.cli = cli
|
||||
super.init()
|
||||
}
|
||||
|
||||
print("=== vphone-cli ===")
|
||||
print("ROM : \(options.romURL.path)")
|
||||
print("Disk : \(options.diskURL.path)")
|
||||
print("NVRAM : \(options.nvramURL.path)")
|
||||
print("Config: \(options.configURL.path)")
|
||||
print("CPU : \(options.cpuCount)")
|
||||
print("Memory: \(options.memorySize / 1024 / 1024) MB")
|
||||
print(
|
||||
"Screen: \(options.screenWidth)x\(options.screenHeight) @ \(options.screenPPI) PPI (scale \(options.screenScale)x)"
|
||||
)
|
||||
if let kernelDebugPort = options.kernelDebugPort {
|
||||
print("Kernel debug stub : 127.0.0.1:\(kernelDebugPort)")
|
||||
} else {
|
||||
print("Kernel debug stub : auto-assigned")
|
||||
}
|
||||
print("SEP : enabled")
|
||||
print(" storage : \(options.sepStorageURL.path)")
|
||||
print(" rom : \(options.sepRomURL.path)")
|
||||
print("")
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
NSApp.setActivationPolicy(cli.noGraphics ? .prohibited : .regular)
|
||||
|
||||
let vm = try VPhoneVirtualMachine(options: options)
|
||||
self.vm = vm
|
||||
|
||||
try await vm.start(forceDFU: cli.dfu)
|
||||
|
||||
let control = VPhoneControl()
|
||||
self.control = control
|
||||
if !cli.dfu {
|
||||
let vphonedURL = URL(fileURLWithPath: cli.vphonedBin)
|
||||
if FileManager.default.fileExists(atPath: vphonedURL.path) {
|
||||
control.guestBinaryURL = vphonedURL
|
||||
}
|
||||
|
||||
let provider = VPhoneLocationProvider(control: control)
|
||||
locationProvider = provider
|
||||
|
||||
if let device = vm.virtualMachine.socketDevices.first as? VZVirtioSocketDevice {
|
||||
control.connect(device: device)
|
||||
}
|
||||
}
|
||||
|
||||
if !cli.noGraphics {
|
||||
let keyHelper = VPhoneKeyHelper(vm: vm, control: control)
|
||||
let wc = VPhoneWindowController()
|
||||
wc.showWindow(
|
||||
for: vm.virtualMachine,
|
||||
screenWidth: options.screenWidth,
|
||||
screenHeight: options.screenHeight,
|
||||
screenScale: options.screenScale,
|
||||
keyHelper: keyHelper,
|
||||
control: control,
|
||||
ecid: vm.ecidHex
|
||||
)
|
||||
windowController = wc
|
||||
|
||||
let fileWC = VPhoneFileWindowController()
|
||||
fileWindowController = fileWC
|
||||
|
||||
let keychainWC = VPhoneKeychainWindowController()
|
||||
keychainWindowController = keychainWC
|
||||
|
||||
let mc = VPhoneMenuController(keyHelper: keyHelper, control: control)
|
||||
mc.vm = vm
|
||||
mc.captureView = wc.captureView
|
||||
mc.onFilesPressed = { [weak fileWC, weak control] in
|
||||
guard let fileWC, let control else { return }
|
||||
fileWC.showWindow(control: control)
|
||||
}
|
||||
mc.onKeychainPressed = { [weak keychainWC, weak control] in
|
||||
guard let keychainWC, let control else { return }
|
||||
keychainWC.showWindow(control: control)
|
||||
}
|
||||
if let provider = locationProvider {
|
||||
mc.locationProvider = provider
|
||||
}
|
||||
mc.screenRecorder = VPhoneScreenRecorder()
|
||||
menuController = mc
|
||||
|
||||
// Wire location toggle through onConnect/onDisconnect
|
||||
control.onConnect = { [weak mc, weak provider = locationProvider] caps in
|
||||
mc?.updateConnectAvailability(available: true)
|
||||
mc?.updateInstallAvailability(available: true)
|
||||
mc?.updateAppsAvailability(available: caps.contains("apps"))
|
||||
mc?.updateClipboardAvailability(available: caps.contains("clipboard"))
|
||||
mc?.updateSettingsAvailability(available: true)
|
||||
if caps.contains("location") {
|
||||
mc?.updateLocationCapability(available: true)
|
||||
// Auto-resume if user had toggle on
|
||||
if mc?.locationMenuItem?.state == .on {
|
||||
provider?.startForwarding()
|
||||
}
|
||||
} else {
|
||||
print("[location] guest does not support location simulation")
|
||||
signal(SIGINT, SIG_IGN)
|
||||
let src = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
||||
src.setEventHandler {
|
||||
print("\n[vphone] SIGINT — shutting down")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
control.onDisconnect = { [weak mc, weak provider = locationProvider] in
|
||||
mc?.updateConnectAvailability(available: false)
|
||||
mc?.updateInstallAvailability(available: false)
|
||||
mc?.updateAppsAvailability(available: false)
|
||||
mc?.updateClipboardAvailability(available: false)
|
||||
mc?.updateSettingsAvailability(available: false)
|
||||
provider?.stopReplay()
|
||||
provider?.stopForwarding()
|
||||
mc?.updateLocationCapability(available: false)
|
||||
}
|
||||
} else if !cli.dfu {
|
||||
// Headless mode: auto-start location as before (no menu exists)
|
||||
control.onConnect = { [weak provider = locationProvider] caps in
|
||||
if caps.contains("location") {
|
||||
provider?.startForwarding()
|
||||
} else {
|
||||
print("[location] guest does not support location simulation")
|
||||
}
|
||||
}
|
||||
control.onDisconnect = { [weak provider = locationProvider] in
|
||||
provider?.stopReplay()
|
||||
provider?.stopForwarding()
|
||||
}
|
||||
}
|
||||
}
|
||||
src.activate()
|
||||
sigintSource = src
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
||||
!cli.noGraphics
|
||||
}
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await self.startVirtualMachine()
|
||||
} catch {
|
||||
print("[vphone] Fatal: \(error)")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func startVirtualMachine() async throws {
|
||||
let options = try cli.resolveOptions()
|
||||
|
||||
guard FileManager.default.fileExists(atPath: options.romURL.path) else {
|
||||
throw VPhoneError.romNotFound(options.romURL.path)
|
||||
}
|
||||
|
||||
print("=== vphone-cli ===")
|
||||
print("ROM : \(options.romURL.path)")
|
||||
print("Disk : \(options.diskURL.path)")
|
||||
print("NVRAM : \(options.nvramURL.path)")
|
||||
print("Config: \(options.configURL.path)")
|
||||
print("CPU : \(options.cpuCount)")
|
||||
print("Memory: \(options.memorySize / 1024 / 1024) MB")
|
||||
print(
|
||||
"Screen: \(options.screenWidth)x\(options.screenHeight) @ \(options.screenPPI) PPI (scale \(options.screenScale)x)"
|
||||
)
|
||||
if let kernelDebugPort = options.kernelDebugPort {
|
||||
print("Kernel debug stub : 127.0.0.1:\(kernelDebugPort)")
|
||||
} else {
|
||||
print("Kernel debug stub : auto-assigned")
|
||||
}
|
||||
print("SEP : enabled")
|
||||
print(" storage : \(options.sepStorageURL.path)")
|
||||
print(" rom : \(options.sepRomURL.path)")
|
||||
print("")
|
||||
|
||||
let vm = try VPhoneVirtualMachine(options: options)
|
||||
self.vm = vm
|
||||
|
||||
try await vm.start(forceDFU: cli.dfu)
|
||||
|
||||
let control = VPhoneControl()
|
||||
self.control = control
|
||||
if !cli.dfu {
|
||||
let vphonedURL = URL(fileURLWithPath: cli.vphonedBin)
|
||||
if FileManager.default.fileExists(atPath: vphonedURL.path) {
|
||||
control.guestBinaryURL = vphonedURL
|
||||
}
|
||||
|
||||
let provider = VPhoneLocationProvider(control: control)
|
||||
locationProvider = provider
|
||||
|
||||
if let device = vm.virtualMachine.socketDevices.first as? VZVirtioSocketDevice {
|
||||
control.connect(device: device)
|
||||
}
|
||||
}
|
||||
|
||||
if !cli.noGraphics {
|
||||
let keyHelper = VPhoneKeyHelper(vm: vm, control: control)
|
||||
let wc = VPhoneWindowController()
|
||||
wc.showWindow(
|
||||
for: vm.virtualMachine,
|
||||
screenWidth: options.screenWidth,
|
||||
screenHeight: options.screenHeight,
|
||||
screenScale: options.screenScale,
|
||||
keyHelper: keyHelper,
|
||||
control: control,
|
||||
ecid: vm.ecidHex
|
||||
)
|
||||
windowController = wc
|
||||
|
||||
let fileWC = VPhoneFileWindowController()
|
||||
fileWindowController = fileWC
|
||||
|
||||
let keychainWC = VPhoneKeychainWindowController()
|
||||
keychainWindowController = keychainWC
|
||||
|
||||
let mc = VPhoneMenuController(keyHelper: keyHelper, control: control)
|
||||
mc.vm = vm
|
||||
mc.captureView = wc.captureView
|
||||
mc.onFilesPressed = { [weak fileWC, weak control] in
|
||||
guard let fileWC, let control else { return }
|
||||
fileWC.showWindow(control: control)
|
||||
}
|
||||
mc.onKeychainPressed = { [weak keychainWC, weak control] in
|
||||
guard let keychainWC, let control else { return }
|
||||
keychainWC.showWindow(control: control)
|
||||
}
|
||||
if let provider = locationProvider {
|
||||
mc.locationProvider = provider
|
||||
}
|
||||
mc.screenRecorder = VPhoneScreenRecorder()
|
||||
menuController = mc
|
||||
|
||||
// Wire location toggle through onConnect/onDisconnect
|
||||
control.onConnect = { [weak mc, weak provider = locationProvider] caps in
|
||||
mc?.updateConnectAvailability(available: true)
|
||||
mc?.updateInstallAvailability(available: true)
|
||||
mc?.updateAppsAvailability(available: caps.contains("apps"))
|
||||
mc?.updateClipboardAvailability(available: caps.contains("clipboard"))
|
||||
mc?.updateSettingsAvailability(available: true)
|
||||
if caps.contains("location") {
|
||||
mc?.updateLocationCapability(available: true)
|
||||
// Auto-resume if user had toggle on
|
||||
if mc?.locationMenuItem?.state == .on {
|
||||
provider?.startForwarding()
|
||||
}
|
||||
} else {
|
||||
print("[location] guest does not support location simulation")
|
||||
}
|
||||
}
|
||||
control.onDisconnect = { [weak mc, weak provider = locationProvider] in
|
||||
mc?.updateConnectAvailability(available: false)
|
||||
mc?.updateInstallAvailability(available: false)
|
||||
mc?.updateAppsAvailability(available: false)
|
||||
mc?.updateClipboardAvailability(available: false)
|
||||
mc?.updateSettingsAvailability(available: false)
|
||||
provider?.stopReplay()
|
||||
provider?.stopForwarding()
|
||||
mc?.updateLocationCapability(available: false)
|
||||
}
|
||||
} else if !cli.dfu {
|
||||
// Headless mode: auto-start location as before (no menu exists)
|
||||
control.onConnect = { [weak provider = locationProvider] caps in
|
||||
if caps.contains("location") {
|
||||
provider?.startForwarding()
|
||||
} else {
|
||||
print("[location] guest does not support location simulation")
|
||||
}
|
||||
}
|
||||
control.onDisconnect = { [weak provider = locationProvider] in
|
||||
provider?.stopReplay()
|
||||
provider?.stopForwarding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
||||
!cli.noGraphics
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ struct VPhoneCLI: ParsableCommand {
|
||||
abstract: "Boot a virtual iPhone (PV=3)",
|
||||
discussion: """
|
||||
Creates a Virtualization.framework VM with platform version 3 (vphone)
|
||||
and boots it into DFU mode for firmware loading via irecovery.
|
||||
and boots it from a manifest plist that describes all paths and hardware.
|
||||
|
||||
Requires:
|
||||
- macOS 15+ (Sequoia or later)
|
||||
@@ -15,7 +15,7 @@ struct VPhoneCLI: ParsableCommand {
|
||||
- Signed with vphone entitlements (done automatically by wrapper script)
|
||||
|
||||
Example:
|
||||
vphone-cli --config config.plist --rom ./AVPBooter.vresearch1.bin --disk ./Disk.img
|
||||
vphone-cli --config ./config.plist
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -25,100 +25,40 @@ struct VPhoneCLI: ParsableCommand {
|
||||
)
|
||||
var config: URL
|
||||
|
||||
@Option(help: "Path to the AVPBooter / ROM binary")
|
||||
var rom: String
|
||||
|
||||
@Option(help: "Path to the disk image")
|
||||
var disk: String
|
||||
|
||||
@Option(help: "Path to NVRAM storage (created/overwritten)")
|
||||
var nvram: String = "nvram.bin"
|
||||
|
||||
@Option(help: "Number of CPU cores (overridden by --config if present)")
|
||||
var cpu: Int?
|
||||
|
||||
@Option(help: "Memory size in MB (overridden by --config if present)")
|
||||
var memory: Int?
|
||||
|
||||
@Option(help: "Path to SEP storage file (created if missing)")
|
||||
var sepStorage: String
|
||||
|
||||
@Option(help: "Path to SEP ROM binary")
|
||||
var sepRom: String
|
||||
|
||||
@Flag(help: "Boot into DFU mode")
|
||||
var dfu: Bool = false
|
||||
|
||||
@Option(help: "Display width in pixels (overridden by --config if present)")
|
||||
var screenWidth: Int?
|
||||
|
||||
@Option(help: "Display height in pixels (overridden by --config if present)")
|
||||
var screenHeight: Int?
|
||||
|
||||
@Option(help: "Display pixels per inch (overridden by --config if present)")
|
||||
var screenPpi: Int?
|
||||
|
||||
@Option(help: "Window scale divisor (default: 3.0)")
|
||||
var screenScale: Double = 3.0
|
||||
|
||||
@Option(help: "Kernel GDB debug stub port on host (omit for system-assigned port; valid: 6000...65535)")
|
||||
var kernelDebugPort: Int?
|
||||
|
||||
@Flag(help: "Run without GUI (headless)")
|
||||
var noGraphics: Bool = false
|
||||
/// DFU mode runs headless (no GUI).
|
||||
var noGraphics: Bool {
|
||||
dfu
|
||||
}
|
||||
|
||||
@Option(help: "Path to signed vphoned binary for guest auto-update")
|
||||
var vphonedBin: String = ".vphoned.signed"
|
||||
|
||||
/// Resolve final options by merging manifest with command-line overrides
|
||||
/// Resolve final options by merging manifest values
|
||||
func resolveOptions() throws -> VPhoneVirtualMachine.Options {
|
||||
// Start with command-line paths
|
||||
let romURL = URL(fileURLWithPath: rom)
|
||||
let diskURL = URL(fileURLWithPath: disk)
|
||||
let nvramURL = URL(fileURLWithPath: nvram)
|
||||
let sepStorageURL = URL(fileURLWithPath: sepStorage)
|
||||
let sepRomURL = URL(fileURLWithPath: sepRom)
|
||||
|
||||
// Default values
|
||||
var resolvedCpuCount = 8
|
||||
var resolvedMemorySize: UInt64 = 8 * 1024 * 1024 * 1024
|
||||
var resolvedScreenWidth = 1290
|
||||
var resolvedScreenHeight = 2796
|
||||
var resolvedScreenPpi = 460
|
||||
var resolvedScreenScale = 3.0
|
||||
|
||||
// Load manifest (required)
|
||||
let manifest = try VPhoneVirtualMachineManifest.load(from: config)
|
||||
print("[vphone] Loaded VM manifest from \(config.path)")
|
||||
|
||||
// Apply manifest settings
|
||||
resolvedCpuCount = Int(manifest.cpuCount)
|
||||
resolvedMemorySize = manifest.memorySize
|
||||
resolvedScreenWidth = manifest.screenConfig.width
|
||||
resolvedScreenHeight = manifest.screenConfig.height
|
||||
resolvedScreenPpi = manifest.screenConfig.pixelsPerInch
|
||||
resolvedScreenScale = manifest.screenConfig.scale
|
||||
|
||||
// Apply command-line overrides (if provided)
|
||||
if let cpuArg = cpu { resolvedCpuCount = cpuArg }
|
||||
if let memoryArg = memory { resolvedMemorySize = UInt64(memoryArg) * 1024 * 1024 }
|
||||
if let screenWidthArg = screenWidth { resolvedScreenWidth = screenWidthArg }
|
||||
if let screenHeightArg = screenHeight { resolvedScreenHeight = screenHeightArg }
|
||||
if let screenPpiArg = screenPpi { resolvedScreenPpi = screenPpiArg }
|
||||
let vmDir = config.deletingLastPathComponent()
|
||||
|
||||
return VPhoneVirtualMachine.Options(
|
||||
configURL: config,
|
||||
romURL: romURL,
|
||||
nvramURL: nvramURL,
|
||||
diskURL: diskURL,
|
||||
cpuCount: resolvedCpuCount,
|
||||
memorySize: resolvedMemorySize,
|
||||
sepStorageURL: sepStorageURL,
|
||||
sepRomURL: sepRomURL,
|
||||
screenWidth: resolvedScreenWidth,
|
||||
screenHeight: resolvedScreenHeight,
|
||||
screenPPI: resolvedScreenPpi,
|
||||
screenScale: resolvedScreenScale,
|
||||
romURL: manifest.resolve(path: manifest.romImages.avpBooter, in: vmDir),
|
||||
nvramURL: manifest.resolve(path: manifest.nvramStorage, in: vmDir),
|
||||
diskURL: manifest.resolve(path: manifest.diskImage, in: vmDir),
|
||||
cpuCount: Int(manifest.cpuCount),
|
||||
memorySize: manifest.memorySize,
|
||||
sepStorageURL: manifest.resolve(path: manifest.sepStorage, in: vmDir),
|
||||
sepRomURL: manifest.resolve(path: manifest.romImages.avpSEPBooter, in: vmDir),
|
||||
screenWidth: manifest.screenConfig.width,
|
||||
screenHeight: manifest.screenConfig.height,
|
||||
screenPPI: manifest.screenConfig.pixelsPerInch,
|
||||
screenScale: manifest.screenConfig.scale,
|
||||
kernelDebugPort: kernelDebugPort
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,185 +1,234 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Apps Menu
|
||||
|
||||
extension VPhoneMenuController {
|
||||
func buildAppsMenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "Apps")
|
||||
menu.autoenablesItems = false
|
||||
func buildAppsMenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "Apps")
|
||||
menu.autoenablesItems = false
|
||||
|
||||
let list = makeItem("List Installed Apps", action: #selector(listApps))
|
||||
list.isEnabled = false
|
||||
appsListItem = list
|
||||
menu.addItem(list)
|
||||
let list = makeItem("List Installed Apps", action: #selector(listApps))
|
||||
list.isEnabled = false
|
||||
appsListItem = list
|
||||
menu.addItem(list)
|
||||
|
||||
let running = makeItem("List Running Apps", action: #selector(listRunningApps))
|
||||
running.isEnabled = false
|
||||
appsRunningItem = running
|
||||
menu.addItem(running)
|
||||
let running = makeItem("List Running Apps", action: #selector(listRunningApps))
|
||||
running.isEnabled = false
|
||||
appsRunningItem = running
|
||||
menu.addItem(running)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let foreground = makeItem("Foreground App", action: #selector(queryForegroundApp))
|
||||
foreground.isEnabled = false
|
||||
appsForegroundItem = foreground
|
||||
menu.addItem(foreground)
|
||||
let foreground = makeItem("Foreground App", action: #selector(queryForegroundApp))
|
||||
foreground.isEnabled = false
|
||||
appsForegroundItem = foreground
|
||||
menu.addItem(foreground)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let launch = makeItem("Launch App...", action: #selector(launchApp))
|
||||
launch.isEnabled = false
|
||||
appsLaunchItem = launch
|
||||
menu.addItem(launch)
|
||||
let launch = makeItem("Launch App...", action: #selector(launchApp))
|
||||
launch.isEnabled = false
|
||||
appsLaunchItem = launch
|
||||
menu.addItem(launch)
|
||||
|
||||
let terminate = makeItem("Terminate App...", action: #selector(terminateApp))
|
||||
terminate.isEnabled = false
|
||||
appsTerminateItem = terminate
|
||||
menu.addItem(terminate)
|
||||
let terminate = makeItem("Terminate App...", action: #selector(terminateApp))
|
||||
terminate.isEnabled = false
|
||||
appsTerminateItem = terminate
|
||||
menu.addItem(terminate)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let openURL = makeItem("Open URL...", action: #selector(openURL))
|
||||
openURL.isEnabled = false
|
||||
appsOpenURLItem = openURL
|
||||
menu.addItem(openURL)
|
||||
let openURL = makeItem("Open URL...", action: #selector(openURL))
|
||||
openURL.isEnabled = false
|
||||
appsOpenURLItem = openURL
|
||||
menu.addItem(openURL)
|
||||
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
func updateAppsAvailability(available: Bool) {
|
||||
appsListItem?.isEnabled = available
|
||||
appsRunningItem?.isEnabled = available
|
||||
appsForegroundItem?.isEnabled = available
|
||||
appsLaunchItem?.isEnabled = available
|
||||
appsTerminateItem?.isEnabled = available
|
||||
appsOpenURLItem?.isEnabled = available
|
||||
}
|
||||
let install = makeItem("Install IPA/TIPA...", action: #selector(installIPAFromDisk))
|
||||
install.isEnabled = false
|
||||
installPackageItem = install
|
||||
menu.addItem(install)
|
||||
|
||||
@objc func listApps() {
|
||||
showAppList(filter: "installed")
|
||||
}
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
|
||||
@objc func listRunningApps() {
|
||||
showAppList(filter: "running")
|
||||
}
|
||||
func updateAppsAvailability(available: Bool) {
|
||||
appsListItem?.isEnabled = available
|
||||
appsRunningItem?.isEnabled = available
|
||||
appsForegroundItem?.isEnabled = available
|
||||
appsLaunchItem?.isEnabled = available
|
||||
appsTerminateItem?.isEnabled = available
|
||||
appsOpenURLItem?.isEnabled = available
|
||||
}
|
||||
|
||||
private func showAppList(filter: String) {
|
||||
Task {
|
||||
do {
|
||||
let apps = try await control.appList(filter: filter)
|
||||
if apps.isEmpty {
|
||||
showAlert(title: "Apps (\(filter))", message: "No apps found.", style: .informational)
|
||||
return
|
||||
func updateInstallAvailability(available: Bool) {
|
||||
installPackageItem?.isEnabled = available
|
||||
}
|
||||
|
||||
@objc func listApps() {
|
||||
showAppList(filter: "installed")
|
||||
}
|
||||
|
||||
@objc func listRunningApps() {
|
||||
showAppList(filter: "running")
|
||||
}
|
||||
|
||||
private func showAppList(filter: String) {
|
||||
Task {
|
||||
do {
|
||||
let apps = try await control.appList(filter: filter)
|
||||
if apps.isEmpty {
|
||||
showAlert(title: "Apps (\(filter))", message: "No apps found.", style: .informational)
|
||||
return
|
||||
}
|
||||
let lines = apps.prefix(50).map { app in
|
||||
let pidStr = app.pid > 0 ? " (pid \(app.pid))" : ""
|
||||
return "\(app.name) — \(app.bundleId) v\(app.version) [\(app.type)]\(pidStr)"
|
||||
}
|
||||
var message = lines.joined(separator: "\n")
|
||||
if apps.count > 50 {
|
||||
message += "\n... and \(apps.count - 50) more"
|
||||
}
|
||||
showAlert(
|
||||
title: "Apps (\(filter)) — \(apps.count) total", message: message, style: .informational
|
||||
)
|
||||
} catch {
|
||||
showAlert(title: "Apps", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
let lines = apps.prefix(50).map { app in
|
||||
let pidStr = app.pid > 0 ? " (pid \(app.pid))" : ""
|
||||
return "\(app.name) — \(app.bundleId) v\(app.version) [\(app.type)]\(pidStr)"
|
||||
}
|
||||
|
||||
@objc func queryForegroundApp() {
|
||||
Task {
|
||||
do {
|
||||
let fg = try await control.appForeground()
|
||||
showAlert(
|
||||
title: "Foreground App",
|
||||
message: "\(fg.name)\n\(fg.bundleId)\npid: \(fg.pid)",
|
||||
style: .informational
|
||||
)
|
||||
} catch {
|
||||
showAlert(title: "Foreground App", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
var message = lines.joined(separator: "\n")
|
||||
if apps.count > 50 {
|
||||
message += "\n... and \(apps.count - 50) more"
|
||||
}
|
||||
|
||||
@objc func launchApp() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Launch App"
|
||||
alert.informativeText = "Enter bundle ID to launch:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Launch")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
|
||||
input.placeholderString = "com.apple.mobilesafari"
|
||||
alert.accessoryView = input
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let bundleId = input.stringValue
|
||||
guard !bundleId.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
let pid = try await control.appLaunch(bundleId: bundleId)
|
||||
showAlert(
|
||||
title: "Launch App", message: "Launched \(bundleId) (pid \(pid))", style: .informational
|
||||
)
|
||||
} catch {
|
||||
showAlert(title: "Launch App", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
showAlert(
|
||||
title: "Apps (\(filter)) — \(apps.count) total", message: message, style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Apps", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func queryForegroundApp() {
|
||||
Task {
|
||||
do {
|
||||
let fg = try await control.appForeground()
|
||||
showAlert(
|
||||
title: "Foreground App",
|
||||
message: "\(fg.name)\n\(fg.bundleId)\npid: \(fg.pid)",
|
||||
style: .informational
|
||||
)
|
||||
} catch {
|
||||
showAlert(title: "Foreground App", message: "\(error)", style: .warning)
|
||||
}
|
||||
@objc func terminateApp() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Terminate App"
|
||||
alert.informativeText = "Enter bundle ID to terminate:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Terminate")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
|
||||
input.placeholderString = "com.apple.mobilesafari"
|
||||
alert.accessoryView = input
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let bundleId = input.stringValue
|
||||
guard !bundleId.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await control.appTerminate(bundleId: bundleId)
|
||||
showAlert(title: "Terminate App", message: "Terminated \(bundleId)", style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Terminate App", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func launchApp() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Launch App"
|
||||
alert.informativeText = "Enter bundle ID to launch:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Launch")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
@objc func installIPAFromDisk() {
|
||||
guard control.isConnected else {
|
||||
showAlert(title: "Install App Package", message: "Guest is not connected.", style: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
|
||||
input.placeholderString = "com.apple.mobilesafari"
|
||||
alert.accessoryView = input
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.allowedContentTypes = VPhoneInstallPackage.allowedContentTypes
|
||||
panel.prompt = "Install"
|
||||
panel.message = "Choose an IPA or TIPA package to install in the guest."
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let bundleId = input.stringValue
|
||||
guard !bundleId.isEmpty else { return }
|
||||
let response = panel.runModal()
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
let pid = try await control.appLaunch(bundleId: bundleId)
|
||||
showAlert(
|
||||
title: "Launch App", message: "Launched \(bundleId) (pid \(pid))", style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Launch App", message: "\(error)", style: .warning)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
let result = try await control.installIPA(localURL: url)
|
||||
print("[install] \(result)")
|
||||
showAlert(
|
||||
title: "Install App Package",
|
||||
message: VPhoneInstallPackage.successMessage(
|
||||
for: url.lastPathComponent,
|
||||
detail: result
|
||||
),
|
||||
style: .informational
|
||||
)
|
||||
} catch {
|
||||
showAlert(title: "Install App Package", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func terminateApp() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Terminate App"
|
||||
alert.informativeText = "Enter bundle ID to terminate:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Terminate")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
@objc func openURL() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Open URL"
|
||||
alert.informativeText = "Enter URL to open on the guest:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Open")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
|
||||
input.placeholderString = "com.apple.mobilesafari"
|
||||
alert.accessoryView = input
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 400, height: 24))
|
||||
input.placeholderString = "https://example.com"
|
||||
alert.accessoryView = input
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let bundleId = input.stringValue
|
||||
guard !bundleId.isEmpty else { return }
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let url = input.stringValue
|
||||
guard !url.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await control.appTerminate(bundleId: bundleId)
|
||||
showAlert(title: "Terminate App", message: "Terminated \(bundleId)", style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Terminate App", message: "\(error)", style: .warning)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await control.openURL(url)
|
||||
showAlert(title: "Open URL", message: "Opened \(url)", style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Open URL", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func openURL() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Open URL"
|
||||
alert.informativeText = "Enter URL to open on the guest:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Open")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 400, height: 24))
|
||||
input.placeholderString = "https://example.com"
|
||||
alert.accessoryView = input
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let url = input.stringValue
|
||||
guard !url.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await control.openURL(url)
|
||||
showAlert(title: "Open URL", message: "Opened \(url)", style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Open URL", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import AppKit
|
||||
// MARK: - Battery Menu
|
||||
|
||||
extension VPhoneMenuController {
|
||||
func buildBatteryMenu() -> NSMenuItem {
|
||||
func buildBatterySubmenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "Battery")
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: - Clipboard Menu
|
||||
|
||||
extension VPhoneMenuController {
|
||||
func buildClipboardMenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "Clipboard")
|
||||
menu.autoenablesItems = false
|
||||
|
||||
let get = makeItem("Get Clipboard", action: #selector(getClipboard))
|
||||
get.isEnabled = false
|
||||
clipboardGetItem = get
|
||||
menu.addItem(get)
|
||||
|
||||
let set = makeItem("Set Clipboard Text...", action: #selector(setClipboardText))
|
||||
set.isEnabled = false
|
||||
clipboardSetItem = set
|
||||
menu.addItem(set)
|
||||
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
|
||||
func updateClipboardAvailability(available: Bool) {
|
||||
clipboardGetItem?.isEnabled = available
|
||||
clipboardSetItem?.isEnabled = available
|
||||
}
|
||||
|
||||
@objc func getClipboard() {
|
||||
Task {
|
||||
do {
|
||||
let content = try await control.clipboardGet()
|
||||
var message = ""
|
||||
if let text = content.text {
|
||||
let truncated = text.count > 500 ? String(text.prefix(500)) + "..." : text
|
||||
message += "Text: \(truncated)\n"
|
||||
}
|
||||
message += "Types: \(content.types.joined(separator: ", "))\n"
|
||||
message += "Has Image: \(content.hasImage)\n"
|
||||
message += "Change Count: \(content.changeCount)"
|
||||
showAlert(title: "Clipboard Content", message: message, style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Clipboard", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setClipboardText() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Set Clipboard Text"
|
||||
alert.informativeText = "Enter text to set on the guest clipboard:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Set")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 80))
|
||||
input.placeholderString = "Text to copy to clipboard"
|
||||
alert.accessoryView = input
|
||||
|
||||
let response: NSApplication.ModalResponse =
|
||||
if let window = NSApp.keyWindow {
|
||||
alert.runModal()
|
||||
} else {
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
guard response == .alertFirstButtonReturn else { return }
|
||||
let text = input.stringValue
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await control.clipboardSet(text: text)
|
||||
showAlert(title: "Clipboard", message: "Text set successfully.", style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Clipboard", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,44 @@ extension VPhoneMenuController {
|
||||
connectGuestVersionItem = guestVersion
|
||||
menu.addItem(guestVersion)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let clipGet = makeItem("Get Clipboard", action: #selector(getClipboard))
|
||||
clipGet.isEnabled = false
|
||||
clipboardGetItem = clipGet
|
||||
menu.addItem(clipGet)
|
||||
|
||||
let clipSet = makeItem("Set Clipboard Text...", action: #selector(setClipboardText))
|
||||
clipSet.isEnabled = false
|
||||
clipboardSetItem = clipSet
|
||||
menu.addItem(clipSet)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let settingsGet = makeItem("Read Setting...", action: #selector(readSetting))
|
||||
settingsGet.isEnabled = false
|
||||
settingsGetItem = settingsGet
|
||||
menu.addItem(settingsGet)
|
||||
|
||||
let settingsSet = makeItem("Write Setting...", action: #selector(writeSetting))
|
||||
settingsSet.isEnabled = false
|
||||
settingsSetItem = settingsSet
|
||||
menu.addItem(settingsSet)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
menu.addItem(buildLocationSubmenu())
|
||||
menu.addItem(buildBatterySubmenu())
|
||||
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
|
||||
func updateSettingsAvailability(available: Bool) {
|
||||
settingsGetItem?.isEnabled = available
|
||||
settingsSetItem?.isEnabled = available
|
||||
}
|
||||
|
||||
func updateConnectAvailability(available: Bool) {
|
||||
connectFileBrowserItem?.isEnabled = available
|
||||
connectKeychainBrowserItem?.isEnabled = available
|
||||
@@ -94,6 +128,187 @@ extension VPhoneMenuController {
|
||||
}
|
||||
}
|
||||
|
||||
func updateClipboardAvailability(available: Bool) {
|
||||
clipboardGetItem?.isEnabled = available
|
||||
clipboardSetItem?.isEnabled = available
|
||||
}
|
||||
|
||||
// MARK: - Clipboard
|
||||
|
||||
@objc func getClipboard() {
|
||||
Task {
|
||||
do {
|
||||
let content = try await control.clipboardGet()
|
||||
var message = ""
|
||||
if let text = content.text {
|
||||
let truncated = text.count > 500 ? String(text.prefix(500)) + "..." : text
|
||||
message += "Text: \(truncated)\n"
|
||||
}
|
||||
message += "Types: \(content.types.joined(separator: ", "))\n"
|
||||
message += "Has Image: \(content.hasImage)\n"
|
||||
message += "Change Count: \(content.changeCount)"
|
||||
showAlert(title: "Clipboard Content", message: message, style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Clipboard", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setClipboardText() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Set Clipboard Text"
|
||||
alert.informativeText = "Enter text to set on the guest clipboard:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Set")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 80))
|
||||
input.placeholderString = "Text to copy to clipboard"
|
||||
alert.accessoryView = input
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let text = input.stringValue
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await control.clipboardSet(text: text)
|
||||
showAlert(title: "Clipboard", message: "Text set successfully.", style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Clipboard", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
@objc func readSetting() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Read Setting"
|
||||
alert.informativeText = "Enter preference domain and key:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Read")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let stack = NSStackView(frame: NSRect(x: 0, y: 0, width: 350, height: 56))
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 8
|
||||
|
||||
let domainField = NSTextField(frame: .zero)
|
||||
domainField.placeholderString = "com.apple.springboard"
|
||||
domainField.translatesAutoresizingMaskIntoConstraints = false
|
||||
domainField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
let keyField = NSTextField(frame: .zero)
|
||||
keyField.placeholderString = "Key (leave empty for all keys)"
|
||||
keyField.translatesAutoresizingMaskIntoConstraints = false
|
||||
keyField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
stack.addArrangedSubview(domainField)
|
||||
stack.addArrangedSubview(keyField)
|
||||
alert.accessoryView = stack
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let domain = domainField.stringValue
|
||||
guard !domain.isEmpty else { return }
|
||||
let key: String? = keyField.stringValue.isEmpty ? nil : keyField.stringValue
|
||||
|
||||
Task {
|
||||
do {
|
||||
let value = try await control.settingsGet(domain: domain, key: key)
|
||||
let display: String
|
||||
if let dict = value as? [String: Any] {
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]
|
||||
)
|
||||
display = String(data: data, encoding: .utf8) ?? "\(dict)"
|
||||
} else {
|
||||
display = "\(value ?? "nil")"
|
||||
}
|
||||
let truncated = display.count > 2000 ? String(display.prefix(2000)) + "\n..." : display
|
||||
showAlert(
|
||||
title: "Setting: \(domain)\(key.map { ".\($0)" } ?? "")",
|
||||
message: truncated,
|
||||
style: .informational
|
||||
)
|
||||
} catch {
|
||||
showAlert(title: "Read Setting", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func writeSetting() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Write Setting"
|
||||
alert.informativeText = "Enter preference domain, key, type, and value:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Write")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let stack = NSStackView(frame: NSRect(x: 0, y: 0, width: 350, height: 116))
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 8
|
||||
|
||||
let domainField = NSTextField(frame: .zero)
|
||||
domainField.placeholderString = "com.apple.springboard"
|
||||
domainField.translatesAutoresizingMaskIntoConstraints = false
|
||||
domainField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
let keyField = NSTextField(frame: .zero)
|
||||
keyField.placeholderString = "Key"
|
||||
keyField.translatesAutoresizingMaskIntoConstraints = false
|
||||
keyField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
let typeField = NSTextField(frame: .zero)
|
||||
typeField.placeholderString = "Type: boolean | string | integer | float"
|
||||
typeField.translatesAutoresizingMaskIntoConstraints = false
|
||||
typeField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
let valueField = NSTextField(frame: .zero)
|
||||
valueField.placeholderString = "Value"
|
||||
valueField.translatesAutoresizingMaskIntoConstraints = false
|
||||
valueField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
stack.addArrangedSubview(domainField)
|
||||
stack.addArrangedSubview(keyField)
|
||||
stack.addArrangedSubview(typeField)
|
||||
stack.addArrangedSubview(valueField)
|
||||
alert.accessoryView = stack
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let domain = domainField.stringValue
|
||||
let key = keyField.stringValue
|
||||
let type = typeField.stringValue
|
||||
let rawValue = valueField.stringValue
|
||||
guard !domain.isEmpty, !key.isEmpty else { return }
|
||||
|
||||
let value: Any =
|
||||
switch type.lowercased() {
|
||||
case "boolean", "bool":
|
||||
rawValue.lowercased() == "true" || rawValue == "1"
|
||||
case "integer", "int":
|
||||
Int(rawValue) ?? 0
|
||||
case "float", "double":
|
||||
Double(rawValue) ?? 0.0
|
||||
default:
|
||||
rawValue
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await control.settingsSet(
|
||||
domain: domain, key: key, value: value, type: type.isEmpty ? nil : type
|
||||
)
|
||||
showAlert(
|
||||
title: "Write Setting", message: "Set \(domain).\(key) = \(rawValue)",
|
||||
style: .informational
|
||||
)
|
||||
} catch {
|
||||
showAlert(title: "Write Setting", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alert
|
||||
|
||||
func showAlert(title: String, message: String, style: NSAlert.Style) {
|
||||
|
||||
@@ -5,83 +5,78 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
class VPhoneMenuController {
|
||||
let keyHelper: VPhoneKeyHelper
|
||||
let control: VPhoneControl
|
||||
weak var vm: VPhoneVirtualMachine?
|
||||
let keyHelper: VPhoneKeyHelper
|
||||
let control: VPhoneControl
|
||||
weak var vm: VPhoneVirtualMachine?
|
||||
|
||||
var onFilesPressed: (() -> Void)?
|
||||
var onKeychainPressed: (() -> Void)?
|
||||
var connectFileBrowserItem: NSMenuItem?
|
||||
var connectKeychainBrowserItem: NSMenuItem?
|
||||
var connectDevModeStatusItem: NSMenuItem?
|
||||
var connectPingItem: NSMenuItem?
|
||||
var connectGuestVersionItem: NSMenuItem?
|
||||
var installPackageItem: NSMenuItem?
|
||||
var clipboardGetItem: NSMenuItem?
|
||||
var clipboardSetItem: NSMenuItem?
|
||||
var appsListItem: NSMenuItem?
|
||||
var appsRunningItem: NSMenuItem?
|
||||
var appsForegroundItem: NSMenuItem?
|
||||
var appsLaunchItem: NSMenuItem?
|
||||
var appsTerminateItem: NSMenuItem?
|
||||
var appsOpenURLItem: NSMenuItem?
|
||||
var settingsGetItem: NSMenuItem?
|
||||
var settingsSetItem: NSMenuItem?
|
||||
var locationProvider: VPhoneLocationProvider?
|
||||
var locationMenuItem: NSMenuItem?
|
||||
var locationPresetMenuItem: NSMenuItem?
|
||||
var locationReplayStartItem: NSMenuItem?
|
||||
var locationReplayStopItem: NSMenuItem?
|
||||
var screenRecorder: VPhoneScreenRecorder?
|
||||
var recordingItem: NSMenuItem?
|
||||
weak var captureView: VPhoneVirtualMachineView?
|
||||
var onFilesPressed: (() -> Void)?
|
||||
var onKeychainPressed: (() -> Void)?
|
||||
var connectFileBrowserItem: NSMenuItem?
|
||||
var connectKeychainBrowserItem: NSMenuItem?
|
||||
var connectDevModeStatusItem: NSMenuItem?
|
||||
var connectPingItem: NSMenuItem?
|
||||
var connectGuestVersionItem: NSMenuItem?
|
||||
var installPackageItem: NSMenuItem?
|
||||
var clipboardGetItem: NSMenuItem?
|
||||
var clipboardSetItem: NSMenuItem?
|
||||
var appsListItem: NSMenuItem?
|
||||
var appsRunningItem: NSMenuItem?
|
||||
var appsForegroundItem: NSMenuItem?
|
||||
var appsLaunchItem: NSMenuItem?
|
||||
var appsTerminateItem: NSMenuItem?
|
||||
var appsOpenURLItem: NSMenuItem?
|
||||
var settingsGetItem: NSMenuItem?
|
||||
var settingsSetItem: NSMenuItem?
|
||||
var locationProvider: VPhoneLocationProvider?
|
||||
var locationMenuItem: NSMenuItem?
|
||||
var locationPresetMenuItem: NSMenuItem?
|
||||
var locationReplayStartItem: NSMenuItem?
|
||||
var locationReplayStopItem: NSMenuItem?
|
||||
var screenRecorder: VPhoneScreenRecorder?
|
||||
var recordingItem: NSMenuItem?
|
||||
weak var captureView: VPhoneVirtualMachineView?
|
||||
|
||||
init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) {
|
||||
self.keyHelper = keyHelper
|
||||
self.control = control
|
||||
setupMenuBar()
|
||||
}
|
||||
init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) {
|
||||
self.keyHelper = keyHelper
|
||||
self.control = control
|
||||
setupMenuBar()
|
||||
}
|
||||
|
||||
// MARK: - Menu Bar Setup
|
||||
// MARK: - Menu Bar Setup
|
||||
|
||||
private func setupMenuBar() {
|
||||
let mainMenu = NSMenu()
|
||||
private func setupMenuBar() {
|
||||
let mainMenu = NSMenu()
|
||||
|
||||
// App menu
|
||||
let appMenuItem = NSMenuItem()
|
||||
let appMenu = NSMenu(title: "vphone")
|
||||
#if canImport(VPhoneBuildInfo)
|
||||
let buildItem = NSMenuItem(
|
||||
title: "Build: \(VPhoneBuildInfo.commitHash)", action: nil, keyEquivalent: "")
|
||||
#else
|
||||
let buildItem = NSMenuItem(title: "Build: unknown", action: nil, keyEquivalent: "")
|
||||
#endif
|
||||
buildItem.isEnabled = false
|
||||
appMenu.addItem(buildItem)
|
||||
appMenu.addItem(NSMenuItem.separator())
|
||||
appMenu.addItem(
|
||||
withTitle: "Quit vphone", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"
|
||||
)
|
||||
appMenuItem.submenu = appMenu
|
||||
mainMenu.addItem(appMenuItem)
|
||||
// App menu
|
||||
let appMenuItem = NSMenuItem()
|
||||
let appMenu = NSMenu(title: "vphone")
|
||||
#if canImport(VPhoneBuildInfo)
|
||||
let buildItem = NSMenuItem(
|
||||
title: "Build: \(VPhoneBuildInfo.commitHash)", action: nil, keyEquivalent: ""
|
||||
)
|
||||
#else
|
||||
let buildItem = NSMenuItem(title: "Build: unknown", action: nil, keyEquivalent: "")
|
||||
#endif
|
||||
buildItem.isEnabled = false
|
||||
appMenu.addItem(buildItem)
|
||||
appMenu.addItem(NSMenuItem.separator())
|
||||
appMenu.addItem(
|
||||
withTitle: "Quit vphone", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"
|
||||
)
|
||||
appMenuItem.submenu = appMenu
|
||||
mainMenu.addItem(appMenuItem)
|
||||
|
||||
mainMenu.addItem(buildKeysMenu())
|
||||
mainMenu.addItem(buildTypeMenu())
|
||||
mainMenu.addItem(buildConnectMenu())
|
||||
mainMenu.addItem(buildAppsMenu())
|
||||
mainMenu.addItem(buildClipboardMenu())
|
||||
mainMenu.addItem(buildInstallMenu())
|
||||
mainMenu.addItem(buildSettingsMenu())
|
||||
mainMenu.addItem(buildLocationMenu())
|
||||
mainMenu.addItem(buildRecordMenu())
|
||||
mainMenu.addItem(buildBatteryMenu())
|
||||
mainMenu.addItem(buildConnectMenu())
|
||||
mainMenu.addItem(buildKeysMenu())
|
||||
mainMenu.addItem(buildAppsMenu())
|
||||
mainMenu.addItem(buildRecordMenu())
|
||||
|
||||
NSApp.mainMenu = mainMenu
|
||||
}
|
||||
NSApp.mainMenu = mainMenu
|
||||
}
|
||||
|
||||
func makeItem(_ title: String, action: Selector) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
||||
item.target = self
|
||||
return item
|
||||
}
|
||||
func makeItem(_ title: String, action: Selector) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
||||
item.target = self
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Install Menu
|
||||
|
||||
extension VPhoneMenuController {
|
||||
func buildInstallMenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "Install")
|
||||
menu.autoenablesItems = false
|
||||
|
||||
let install = makeItem("Install IPA/TIPA...", action: #selector(installIPAFromDisk))
|
||||
install.isEnabled = false
|
||||
installPackageItem = install
|
||||
menu.addItem(install)
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
|
||||
func updateInstallAvailability(available: Bool) {
|
||||
installPackageItem?.isEnabled = available
|
||||
}
|
||||
|
||||
@objc func installIPAFromDisk() {
|
||||
guard control.isConnected else {
|
||||
showAlert(title: "Install App Package", message: "Guest is not connected.", style: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.allowedContentTypes = VPhoneInstallPackage.allowedContentTypes
|
||||
panel.prompt = "Install"
|
||||
panel.message = "Choose an IPA or TIPA package 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)")
|
||||
showAlert(
|
||||
title: "Install App Package",
|
||||
message: VPhoneInstallPackage.successMessage(
|
||||
for: url.lastPathComponent,
|
||||
detail: result
|
||||
),
|
||||
style: .informational
|
||||
)
|
||||
} catch {
|
||||
showAlert(title: "Install App Package", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ extension VPhoneMenuController {
|
||||
menu.addItem(makeItem("Volume Down", action: #selector(sendVolumeDown)))
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
menu.addItem(makeItem("Spotlight (Cmd+Space)", action: #selector(sendSpotlight)))
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
menu.addItem(makeItem("Type ASCII from Clipboard", action: #selector(typeFromClipboard)))
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
@@ -35,4 +37,8 @@ extension VPhoneMenuController {
|
||||
@objc func sendSpotlight() {
|
||||
keyHelper.sendSpotlight()
|
||||
}
|
||||
|
||||
@objc func typeFromClipboard() {
|
||||
keyHelper.typeFromClipboard()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ private let locationReplayPoints: [VPhoneLocationProvider.ReplayPoint] = [
|
||||
// MARK: - Location Menu
|
||||
|
||||
extension VPhoneMenuController {
|
||||
func buildLocationMenu() -> NSMenuItem {
|
||||
func buildLocationSubmenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "Location")
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: - Settings Menu
|
||||
|
||||
extension VPhoneMenuController {
|
||||
func buildSettingsMenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "Settings")
|
||||
menu.autoenablesItems = false
|
||||
|
||||
let get = makeItem("Read Setting...", action: #selector(readSetting))
|
||||
get.isEnabled = false
|
||||
settingsGetItem = get
|
||||
menu.addItem(get)
|
||||
|
||||
let set = makeItem("Write Setting...", action: #selector(writeSetting))
|
||||
set.isEnabled = false
|
||||
settingsSetItem = set
|
||||
menu.addItem(set)
|
||||
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
|
||||
func updateSettingsAvailability(available: Bool) {
|
||||
settingsGetItem?.isEnabled = available
|
||||
settingsSetItem?.isEnabled = available
|
||||
}
|
||||
|
||||
@objc func readSetting() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Read Setting"
|
||||
alert.informativeText = "Enter preference domain and key:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Read")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let stack = NSStackView(frame: NSRect(x: 0, y: 0, width: 350, height: 56))
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 8
|
||||
|
||||
let domainField = NSTextField(frame: .zero)
|
||||
domainField.placeholderString = "com.apple.springboard"
|
||||
domainField.translatesAutoresizingMaskIntoConstraints = false
|
||||
domainField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
let keyField = NSTextField(frame: .zero)
|
||||
keyField.placeholderString = "Key (leave empty for all keys)"
|
||||
keyField.translatesAutoresizingMaskIntoConstraints = false
|
||||
keyField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
stack.addArrangedSubview(domainField)
|
||||
stack.addArrangedSubview(keyField)
|
||||
alert.accessoryView = stack
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let domain = domainField.stringValue
|
||||
guard !domain.isEmpty else { return }
|
||||
let key: String? = keyField.stringValue.isEmpty ? nil : keyField.stringValue
|
||||
|
||||
Task {
|
||||
do {
|
||||
let value = try await control.settingsGet(domain: domain, key: key)
|
||||
let display: String
|
||||
if let dict = value as? [String: Any] {
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
display = String(data: data, encoding: .utf8) ?? "\(dict)"
|
||||
} else {
|
||||
display = "\(value ?? "nil")"
|
||||
}
|
||||
let truncated = display.count > 2000 ? String(display.prefix(2000)) + "\n..." : display
|
||||
showAlert(
|
||||
title: "Setting: \(domain)\(key.map { ".\($0)" } ?? "")",
|
||||
message: truncated,
|
||||
style: .informational
|
||||
)
|
||||
} catch {
|
||||
showAlert(title: "Read Setting", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func writeSetting() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Write Setting"
|
||||
alert.informativeText = "Enter preference domain, key, type, and value:"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Write")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let stack = NSStackView(frame: NSRect(x: 0, y: 0, width: 350, height: 116))
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 8
|
||||
|
||||
let domainField = NSTextField(frame: .zero)
|
||||
domainField.placeholderString = "com.apple.springboard"
|
||||
domainField.translatesAutoresizingMaskIntoConstraints = false
|
||||
domainField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
let keyField = NSTextField(frame: .zero)
|
||||
keyField.placeholderString = "Key"
|
||||
keyField.translatesAutoresizingMaskIntoConstraints = false
|
||||
keyField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
let typeField = NSTextField(frame: .zero)
|
||||
typeField.placeholderString = "Type: boolean | string | integer | float"
|
||||
typeField.translatesAutoresizingMaskIntoConstraints = false
|
||||
typeField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
let valueField = NSTextField(frame: .zero)
|
||||
valueField.placeholderString = "Value"
|
||||
valueField.translatesAutoresizingMaskIntoConstraints = false
|
||||
valueField.widthAnchor.constraint(equalToConstant: 350).isActive = true
|
||||
|
||||
stack.addArrangedSubview(domainField)
|
||||
stack.addArrangedSubview(keyField)
|
||||
stack.addArrangedSubview(typeField)
|
||||
stack.addArrangedSubview(valueField)
|
||||
alert.accessoryView = stack
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let domain = domainField.stringValue
|
||||
let key = keyField.stringValue
|
||||
let type = typeField.stringValue
|
||||
let rawValue = valueField.stringValue
|
||||
guard !domain.isEmpty, !key.isEmpty else { return }
|
||||
|
||||
// Convert value based on type
|
||||
let value: Any =
|
||||
switch type.lowercased() {
|
||||
case "boolean", "bool":
|
||||
rawValue.lowercased() == "true" || rawValue == "1"
|
||||
case "integer", "int":
|
||||
Int(rawValue) ?? 0
|
||||
case "float", "double":
|
||||
Double(rawValue) ?? 0.0
|
||||
default:
|
||||
rawValue
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await control.settingsSet(
|
||||
domain: domain, key: key, value: value, type: type.isEmpty ? nil : type)
|
||||
showAlert(
|
||||
title: "Write Setting", message: "Set \(domain).\(key) = \(rawValue)",
|
||||
style: .informational)
|
||||
} catch {
|
||||
showAlert(title: "Write Setting", message: "\(error)", style: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: - Type Menu
|
||||
|
||||
extension VPhoneMenuController {
|
||||
func buildTypeMenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "Type")
|
||||
menu.addItem(makeItem("Type ASCII from Clipboard", action: #selector(typeFromClipboard)))
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
|
||||
@objc func typeFromClipboard() {
|
||||
keyHelper.typeFromClipboard()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user