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:
Lakr
2026-03-10 23:39:15 +08:00
parent df5d5449b5
commit 7a5a8edd27
14 changed files with 1570 additions and 1681 deletions
+2 -12
View File
@@ -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
+163 -163
View File
@@ -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
}
}
+19 -79
View File
@@ -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
+198 -149
View File
@@ -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)
}
}
}
}
+1 -1
View File
@@ -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)
}
}
}
}
+215
View File
@@ -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) {
+66 -71
View File
@@ -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)
}
}
}
}
+6
View File
@@ -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()
}
}
+1 -1
View File
@@ -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")
-154
View File
@@ -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)
}
}
}
}
-17
View File
@@ -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()
}
}