mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-10-02 08:04:32 +08:00
vphoned reports `setup_pending` in /v1/health and serves `setup.status` and `setup.skip`. Device › Skip Setup Assistant… is enabled while the guest reports it pending, and asks before it runs. SpringBoard decides whether to run Setup once, when it starts. It does while `SetupDone` in com.apple.purplebuddy is not true, and runs the flow shown after a software update while `SetupVersion` is below SetupAssistant.framework's BYBuddyIOSCurrentVersion (an int32, 11 on 26.4 and 27.0). Writing the keys while Setup is on screen does not dismiss it, and killing Setup only makes SpringBoard start it again. So the skip writes `SetupDone`, `SetupFinishedAllSteps` and `SetupVersion` through cfprefsd for user mobile, then restarts SpringBoard. With every other key in the domain removed, those three still reach the Home Screen, and no later panes appear. The experiments are in Research/Guest/setup_assistant_skip.md, including how to send a guest back to Setup for testing. MCInstall's SetCloudConfiguration, which pymobiledevice3, go-ios and cfgutil use, works only on an erased device, so it does not fit here. Verified on test-26.4 with this bundle: after deleting `SetupDone`, Setup's hello screen appeared and setup.status reported pending true and running true. setup.skip without force was refused. With force it returned pending false, SpringBoard restarted and unlocked to the Home Screen. Deleting `SetupVersion` then skipping also reached the Home Screen. Afterwards the domain matched its state before the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
9.7 KiB
Swift
251 lines
9.7 KiB
Swift
import AppKit
|
|
import LocalAuthentication
|
|
import VPhoneCoreKit
|
|
|
|
// MARK: - Device Menu
|
|
|
|
/// The phone's buttons and input, the UDID it gives provisioning profile
|
|
/// checks, and restarting it. Sensor overrides live
|
|
/// in the Features menu.
|
|
extension VPhoneMenuController {
|
|
func buildDeviceMenu() -> NSMenuItem {
|
|
let item = NSMenuItem(title: "Device", action: nil, keyEquivalent: "")
|
|
let menu = NSMenu(title: "Device")
|
|
menu.autoenablesItems = false
|
|
menu.addItem(makePanelItem(.controls, "Controls", keyEquivalent: "k", symbol: "slider.horizontal.3"))
|
|
menu.addItem(NSMenuItem.separator())
|
|
menu.addItem(makeItem(
|
|
"Home Screen",
|
|
action: #selector(sendHome),
|
|
keyEquivalent: "h",
|
|
modifiers: [.command, .shift],
|
|
symbol: "house",
|
|
))
|
|
// iOS has no back key, so this item and the Esc key both replay the
|
|
// system back gesture. The key equivalent is here so the menu can show
|
|
// it; VPhoneApplication intercepts Esc before the menu is consulted,
|
|
// because AppKit's matching for a bare Esc missed presses.
|
|
menu.addItem(makeItem(
|
|
"Back",
|
|
action: #selector(sendBack),
|
|
keyEquivalent: "\u{1b}",
|
|
modifiers: [],
|
|
symbol: "arrow.uturn.backward",
|
|
))
|
|
menu.addItem(makeItem("Power", action: #selector(sendPower), symbol: "power"))
|
|
menu.addItem(makeItem("Volume Up", action: #selector(sendVolumeUp), symbol: "speaker.plus"))
|
|
menu.addItem(makeItem("Volume Down", action: #selector(sendVolumeDown), symbol: "speaker.minus"))
|
|
menu.addItem(NSMenuItem.separator())
|
|
let rotateLeft = makeItem(
|
|
"Rotate Left",
|
|
action: #selector(rotateLeft),
|
|
keyEquivalent: String(UnicodeScalar(NSLeftArrowFunctionKey)!),
|
|
symbol: "rotate.left",
|
|
)
|
|
let rotateRight = makeItem(
|
|
"Rotate Right",
|
|
action: #selector(rotateRight),
|
|
keyEquivalent: String(UnicodeScalar(NSRightArrowFunctionKey)!),
|
|
symbol: "rotate.right",
|
|
)
|
|
let orientationItem = NSMenuItem(title: "Orientation", action: nil, keyEquivalent: "")
|
|
orientationItem.image = menuSymbol("rectangle.portrait.rotate")
|
|
orientationItem.submenu = buildOrientationMenu()
|
|
// Disabled until the agent connects, so ⌘← and ⌘→ reach the guest.
|
|
for rotate in [rotateLeft, rotateRight, orientationItem] {
|
|
rotate.isEnabled = false
|
|
menu.addItem(rotate)
|
|
}
|
|
rotateMenuItems = [rotateLeft, rotateRight, orientationItem]
|
|
control.observeInterfaceOrientation { [weak self] orientation in
|
|
self?.updateOrientationChecks(orientation)
|
|
}
|
|
menu.addItem(NSMenuItem.separator())
|
|
menu.addItem(makeItem("Open Guest Spotlight", action: #selector(sendSpotlight), symbol: "magnifyingglass"))
|
|
// Trackpad scroll and pinch arrive as ordinary NSEvents; the view turns
|
|
// them into guest touches. Off hands both back to AppKit untouched.
|
|
let trackpadItem = makeItem(
|
|
"Trackpad Scroll & Pinch to Touch",
|
|
action: #selector(toggleTrackpadGestures),
|
|
symbol: "hand.draw",
|
|
)
|
|
trackpadItem.state = VPhoneTrackpadGestures.isEnabled ? .on : .off
|
|
trackpadGesturesItem = trackpadItem
|
|
menu.addItem(trackpadItem)
|
|
let tidItem = makeItem("Touch ID Home Forwarding", action: #selector(toggleTouchIDForwarding))
|
|
if hasTouchID {
|
|
let tidEnabled = !UserDefaults.standard.bool(forKey: "touchIDForwardingDisabled")
|
|
tidItem.state = tidEnabled ? .on : .off
|
|
} else {
|
|
tidItem.isEnabled = false
|
|
tidItem.state = .off
|
|
}
|
|
touchIDMenuItem = tidItem
|
|
menu.addItem(tidItem)
|
|
menu.addItem(NSMenuItem.separator())
|
|
addUDIDItems(to: menu)
|
|
menu.addItem(NSMenuItem.separator())
|
|
addSetupAssistantItem(to: menu)
|
|
let restart = makeItem("Restart Guest…", action: #selector(restartGuest), symbol: "arrow.clockwise")
|
|
restart.isEnabled = false
|
|
restartGuestItem = restart
|
|
menu.addItem(restart)
|
|
item.submenu = menu
|
|
return item
|
|
}
|
|
|
|
@objc func sendHome() {
|
|
keySender.sendHome()
|
|
}
|
|
|
|
/// iOS has no back key. Esc and this item both replay the system back
|
|
/// gesture instead of forwarding a keystroke the guest would only read as
|
|
/// "cancel" (or, in Safari, "stop loading").
|
|
@objc func sendBack() {
|
|
captureView?.performBackGesture()
|
|
}
|
|
|
|
@objc func sendPower() {
|
|
keySender.sendPower()
|
|
}
|
|
|
|
@objc func sendVolumeUp() {
|
|
keySender.sendVolumeUp()
|
|
}
|
|
|
|
@objc func sendVolumeDown() {
|
|
keySender.sendVolumeDown()
|
|
}
|
|
|
|
@objc func sendSpotlight() {
|
|
keySender.sendSpotlight()
|
|
}
|
|
|
|
// MARK: - Rotate
|
|
|
|
/// The four interface orientations, checked by the one the window last
|
|
/// read from the guest.
|
|
private func buildOrientationMenu() -> NSMenu {
|
|
let menu = NSMenu(title: "Orientation")
|
|
let orientations: [(VPhoneDisplayOrientation, String)] = [
|
|
(.portrait, "Portrait"),
|
|
(.landscapeLeft, "Landscape Left"),
|
|
(.landscapeRight, "Landscape Right"),
|
|
(.upsideDown, "Upside Down"),
|
|
]
|
|
for (orientation, title) in orientations {
|
|
let item = makeItem(title, action: #selector(chooseOrientation(_:)))
|
|
item.representedObject = orientation.rawValue
|
|
menu.addItem(item)
|
|
}
|
|
return menu
|
|
}
|
|
|
|
private func updateOrientationChecks(_ orientation: VPhoneDisplayOrientation?) {
|
|
guard let menu = rotateMenuItems.last?.submenu else { return }
|
|
for item in menu.items {
|
|
item.state = (item.representedObject as? Int) == orientation?.rawValue ? .on : .off
|
|
}
|
|
}
|
|
|
|
@objc func chooseOrientation(_ sender: NSMenuItem) {
|
|
guard let degrees = sender.representedObject as? Int,
|
|
let orientation = VPhoneDisplayOrientation(degrees: degrees)
|
|
else { return }
|
|
Task {
|
|
do {
|
|
try await control.rotate(toFirstOf: [orientation])
|
|
} catch {
|
|
VPhoneAlert.present(
|
|
title: "Unable to Rotate",
|
|
message: "The app in front does not support this orientation.",
|
|
style: .warning,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func rotateLeft() {
|
|
rotate(clockwise: false)
|
|
}
|
|
|
|
@objc func rotateRight() {
|
|
rotate(clockwise: true)
|
|
}
|
|
|
|
/// Turns the guest a quarter turn from its interface orientation, the
|
|
/// one already turning to when pressed again. An orientation the app in
|
|
/// front refuses, such as upside down on the Home Screen, is skipped for
|
|
/// the one after it. The window turns with the guest.
|
|
private func rotate(clockwise: Bool) {
|
|
Task {
|
|
var current = control.interfaceOrientation
|
|
if current == nil {
|
|
let method = control.guestCapabilities.contains("display_orientation")
|
|
? "display.orientation" : "display.rotation"
|
|
let degrees = try? await (control.call(method)["degrees"] as? NSNumber)?.intValue
|
|
current = degrees.flatMap(VPhoneDisplayOrientation.init(degrees:))
|
|
}
|
|
guard let current else { return }
|
|
let next = current.turned(clockwise: clockwise)
|
|
try? await control.rotate(toFirstOf: [next, next.turned(clockwise: clockwise)])
|
|
}
|
|
}
|
|
|
|
/// Replays trackpad scroll and pinch inside the guest instead of letting
|
|
/// AppKit scroll the window. Persisted, so the choice survives relaunches.
|
|
@objc func toggleTrackpadGestures() {
|
|
let enabled = !VPhoneTrackpadGestures.isEnabled
|
|
VPhoneTrackpadGestures.isEnabled = enabled
|
|
trackpadGesturesItem?.state = enabled ? .on : .off
|
|
captureView?.trackpadGesturesEnabled = enabled
|
|
}
|
|
|
|
// MARK: - Restart
|
|
|
|
func updateRestartAvailability(available: Bool) {
|
|
restartGuestItem?.isEnabled = available
|
|
}
|
|
|
|
@objc func restartGuest() {
|
|
VPhoneAlert.present(
|
|
title: "Restart the guest?",
|
|
message: "Apps in the guest quit. The guest agent reconnects after the guest starts up.",
|
|
style: .warning,
|
|
buttons: ["Restart Guest", "Cancel"],
|
|
) { response in
|
|
guard response == .alertFirstButtonReturn else { return }
|
|
Task {
|
|
do {
|
|
try await self.control.restartGuest()
|
|
} catch let VPhoneGuestControl.ControlError.guestError(message) {
|
|
print("[restart] guest refused: \(message)")
|
|
VPhoneAlert.present(
|
|
title: "Unable to Restart Guest",
|
|
message: "The guest refused the restart request.",
|
|
style: .warning,
|
|
)
|
|
} catch {
|
|
// The guest restarts before it replies, so the connection
|
|
// drops. onDisconnect updates the menus.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func toggleTouchIDForwarding() {
|
|
guard let monitor = touchIDMonitor, let item = touchIDMenuItem else { return }
|
|
monitor.isEnabled.toggle()
|
|
item.state = monitor.isEnabled ? .on : .off
|
|
UserDefaults.standard.set(!monitor.isEnabled, forKey: "touchIDForwardingDisabled")
|
|
}
|
|
}
|
|
|
|
private extension VPhoneMenuController {
|
|
var hasTouchID: Bool {
|
|
let ctx = LAContext()
|
|
ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
|
return ctx.biometryType == .touchID
|
|
}
|
|
}
|