Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,72 @@ struct ODHubViewer: ParsableCommand {
}
}
},
restart: {
for udid in manager.openUDIDs {
runOnEveryDevice("restart", udid) { try SimctlService().restart(udid: $0) }
}
},
erase: {
// Destructive and not undoable, so it asks, names the device, and Erase is not
// the default button.
for udid in manager.openUDIDs {
guard let controller = manager.controller(for: udid) else { continue }
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Erase \(controller.deviceTitle)?"
alert.informativeText = "Every app, setting and file on this simulator is deleted. This cannot be undone, and the device is left shut down."
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: "Erase")
guard alert.runModal() == .alertSecondButtonReturn else { continue }
runOnEveryDevice("erase", udid) { try SimctlService().erase(udid: $0) }
}
},
stepTextSize: { step in
for udid in manager.openUDIDs {
runOnEveryDevice("text size", udid) {
try SimctlService().stepContentSize(step, udid: $0)
}
}
},
toggleIncreaseContrast: {
let simctl = SimctlService()
for udid in manager.openUDIDs {
let wanted = !simctl.increasesContrast(udid: udid)
runOnEveryDevice("increase contrast", udid) {
try simctl.setIncreaseContrast(wanted, udid: $0)
}
}
},
triggerICloudSync: {
for udid in manager.openUDIDs {
runOnEveryDevice("iCloud sync", udid) {
try SimctlService().triggerICloudSync(udid: $0)
}
}
},
setLocation: { scenario in
for udid in manager.openUDIDs {
runOnEveryDevice("location", udid) { device in
if let scenario {
try SimctlService().runLocation(scenario, udid: device)
} else {
try SimctlService().clearLocation(udid: device)
}
}
}
},
setCustomLocation: {
guard let point = CustomLocationPrompt.ask() else { return }
for udid in manager.openUDIDs {
runOnEveryDevice("location", udid) {
try SimctlService().setLocation(
latitude: point.latitude,
longitude: point.longitude,
udid: $0
)
}
}
},
setOrientation: { orientation in
for udid in manager.openUDIDs {
guard let controller = manager.controller(for: udid) else { continue }
Expand Down Expand Up @@ -592,6 +658,52 @@ private func swipeHome(_ session: any InputSession) async throws {
)
}

/// Off the main thread, because every one of these blocks for a second or more and they run from a
/// menu. A failure is printed rather than swallowed.
@MainActor
private func runOnEveryDevice(
_ what: String,
_ udid: String,
_ work: @escaping @Sendable (String) throws -> Void
) {
Task {
let failure = await Task.detached { () -> String? in
do {
try work(udid)
return nil
} catch {
return error.localizedDescription
}
}.value
if let failure { print("\(what) failed: \(failure)") }
}
}

/// Latitude and longitude asked for in one line. The simulator this replaces opens a map here,
/// which is a different piece of work.
@MainActor
enum CustomLocationPrompt {
static func ask() -> (latitude: Double, longitude: Double)? {
let alert = NSAlert()
alert.messageText = "Custom Location"
alert.informativeText = "Latitude and longitude, separated by a comma."
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
field.placeholderString = "37.3349, -122.0090"
alert.accessoryView = field
alert.addButton(withTitle: "Set")
alert.addButton(withTitle: "Cancel")
guard alert.runModal() == .alertFirstButtonReturn else { return nil }
guard let point = Coordinate(parsing: field.stringValue) else {
let complaint = NSAlert()
complaint.messageText = "That is not a coordinate."
complaint.informativeText = "Latitude is between -90 and 90, longitude between -180 and 180."
complaint.runModal()
return nil
}
return (point.latitude, point.longitude)
}
}

/// Recordings and screenshots land on the Desktop, falling back to a temporary folder on a machine
/// that has none.
/// Captures are written here first and only move into the capture folder when their preview goes
Expand Down
101 changes: 101 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,104 @@ extension SimctlService {
}
}
}

extension SimctlService {
public enum ContentSizeStep: String, Sendable {
case increment
case decrement
}

/// The scenarios `simctl location list` reports, which are the same four Simulator.app offers.
public enum LocationScenario: String, CaseIterable, Sendable {
case cityRun = "City Run"
case cityBicycleRide = "City Bicycle Ride"
case freewayDrive = "Freeway Drive"
case apple = "Apple"
}

static func eraseArguments(udid: String) -> [String] {
["simctl", "erase", udid]
}

static func iCloudSyncArguments(udid: String) -> [String] {
["simctl", "icloud_sync", udid]
}

static func contentSizeArguments(udid: String, step: ContentSizeStep) -> [String] {
["simctl", "ui", udid, "content_size", step.rawValue]
}

static func increaseContrastArguments(udid: String, enabled: Bool) -> [String] {
["simctl", "ui", udid, "increase_contrast", enabled ? "enabled" : "disabled"]
}

static func readIncreaseContrastArguments(udid: String) -> [String] {
["simctl", "ui", udid, "increase_contrast"]
}

static func locationScenarioArguments(udid: String, scenario: LocationScenario) -> [String] {
["simctl", "location", udid, "run", scenario.rawValue]
}

static func locationSetArguments(udid: String, latitude: Double, longitude: Double) -> [String] {
["simctl", "location", udid, "set", "\(latitude),\(longitude)"]
}

static func locationClearArguments(udid: String) -> [String] {
["simctl", "location", udid, "clear"]
}

/// Erasing needs the device down first, and leaves it down. The caller decides whether to boot
/// it again, because erasing to then throw the device away is a reasonable thing to want.
public func erase(udid: String) throws {
try? shutdown(udid: udid)
try runChecked(Self.eraseArguments(udid: udid))
}

/// Down and up again. Shutting down a device that is already down is not an error worth
/// stopping for, which is why only the boot is checked.
public func restart(udid: String) throws {
try? shutdown(udid: udid)
try boot(udid: udid)
}

public func triggerICloudSync(udid: String) throws {
try runChecked(Self.iCloudSyncArguments(udid: udid))
}

public func stepContentSize(_ step: ContentSizeStep, udid: String) throws {
try runChecked(Self.contentSizeArguments(udid: udid, step: step))
}

public func setIncreaseContrast(_ enabled: Bool, udid: String) throws {
try runChecked(Self.increaseContrastArguments(udid: udid, enabled: enabled))
}

public func increasesContrast(udid: String) -> Bool {
let result = try? ProcessRunner.run("/usr/bin/xcrun", Self.readIncreaseContrastArguments(udid: udid))
return result?.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines) == "enabled"
}

public func runLocation(_ scenario: LocationScenario, udid: String) throws {
try runChecked(Self.locationScenarioArguments(udid: udid, scenario: scenario))
}

public func setLocation(latitude: Double, longitude: Double, udid: String) throws {
try runChecked(Self.locationSetArguments(udid: udid, latitude: latitude, longitude: longitude))
}

public func clearLocation(udid: String) throws {
try runChecked(Self.locationClearArguments(udid: udid))
}

private func runChecked(_ arguments: [String]) throws {
let result = try ProcessRunner.run("/usr/bin/xcrun", arguments)
guard result.status == 0 else {
throw EngineError.simctl(
args: Array(arguments.dropFirst()),
code: result.status,
stderr: result.standardError
)
}
}
}
23 changes: 23 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/Coordinate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

/// A latitude and longitude typed by a person.
///
/// Its own type in the library rather than a helper beside the dialog, because parsing what someone
/// typed is the part that can be got wrong quietly, and it can only be tested here.
public struct Coordinate: Equatable, Sendable {
public let latitude: Double
public let longitude: Double

public init?(parsing text: String) {
let parts = text.split(separator: ",")
guard parts.count == 2,
let latitude = Double(parts[0].trimmingCharacters(in: .whitespaces)),
let longitude = Double(parts[1].trimmingCharacters(in: .whitespaces)),
(-90...90).contains(latitude),
(-180...180).contains(longitude) else {
return nil
}
self.latitude = latitude
self.longitude = longitude
}
}
64 changes: 64 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ public enum ViewerMenu {
public var toggleLatencyOverlay: () -> Void
public var pressButton: (HardwareButton) -> Void
public var rotate: (Bool) -> Void
public var restart: () -> Void
public var erase: () -> Void
public var stepTextSize: (SimctlService.ContentSizeStep) -> Void
public var toggleIncreaseContrast: () -> Void
public var triggerICloudSync: () -> Void
public var setLocation: (SimctlService.LocationScenario?) -> Void
public var setCustomLocation: () -> Void
public var setOrientation: (DeviceOrientation) -> Void
public var appSwitcher: () -> Void
public var stopRecording: () -> Void
Expand All @@ -63,6 +70,13 @@ public enum ViewerMenu {
toggleLatencyOverlay: @escaping () -> Void,
pressButton: @escaping (HardwareButton) -> Void,
rotate: @escaping (Bool) -> Void,
restart: @escaping () -> Void,
erase: @escaping () -> Void,
stepTextSize: @escaping (SimctlService.ContentSizeStep) -> Void,
toggleIncreaseContrast: @escaping () -> Void,
triggerICloudSync: @escaping () -> Void,
setLocation: @escaping (SimctlService.LocationScenario?) -> Void,
setCustomLocation: @escaping () -> Void,
setOrientation: @escaping (DeviceOrientation) -> Void,
appSwitcher: @escaping () -> Void,
stopRecording: @escaping () -> Void,
Expand All @@ -86,6 +100,13 @@ public enum ViewerMenu {
self.toggleLatencyOverlay = toggleLatencyOverlay
self.pressButton = pressButton
self.rotate = rotate
self.restart = restart
self.erase = erase
self.stepTextSize = stepTextSize
self.toggleIncreaseContrast = toggleIncreaseContrast
self.triggerICloudSync = triggerICloudSync
self.setLocation = setLocation
self.setCustomLocation = setCustomLocation
self.setOrientation = setOrientation
self.appSwitcher = appSwitcher
self.stopRecording = stopRecording
Expand Down Expand Up @@ -196,6 +217,9 @@ public enum ViewerMenu {

let deviceItem = NSMenuItem()
let deviceMenu = NSMenu(title: "Device")
deviceMenu.addItem(target.item("Restart", #selector(MenuTarget.restart), "", []))
deviceMenu.addItem(target.item("Erase All Content and Settings\u{2026}", #selector(MenuTarget.erase), "", []))
deviceMenu.addItem(.separator())
let rotateLeft = target.item("Rotate Left", #selector(MenuTarget.rotateLeft), String(UnicodeScalar(NSLeftArrowFunctionKey)!), [.command])
let rotateRight = target.item("Rotate Right", #selector(MenuTarget.rotateRight), String(UnicodeScalar(NSRightArrowFunctionKey)!), [.command])
for item in [rotateLeft, rotateRight] {
Expand Down Expand Up @@ -244,6 +268,26 @@ public enum ViewerMenu {
let featuresItem = NSMenuItem()
let featuresMenu = NSMenu(title: "Features")
featuresMenu.addItem(target.item("Toggle Appearance", #selector(MenuTarget.appearance), "a", [.command, .shift]))
featuresMenu.addItem(target.item("Toggle Increase Contrast", #selector(MenuTarget.increaseContrast(_:)), "", []))
featuresMenu.addItem(.separator())
featuresMenu.addItem(target.item("Increase Preferred Text Size", #selector(MenuTarget.textSizeUp), "+", [.command, .option]))
featuresMenu.addItem(target.item("Decrease Preferred Text Size", #selector(MenuTarget.textSizeDown), "-", [.command, .option]))
featuresMenu.addItem(.separator())
featuresMenu.addItem(target.item("Trigger iCloud Sync", #selector(MenuTarget.iCloudSync), "i", [.command, .shift]))
featuresMenu.addItem(.separator())

let locationItem = NSMenuItem(title: "Location", action: nil, keyEquivalent: "")
let locationMenu = NSMenu(title: "Location")
locationMenu.addItem(target.item("None", #selector(MenuTarget.clearLocation), "", []))
locationMenu.addItem(target.item("Custom Location\u{2026}", #selector(MenuTarget.customLocation), "", []))
locationMenu.addItem(.separator())
for scenario in SimctlService.LocationScenario.allCases {
let item = target.item(scenario.rawValue, #selector(MenuTarget.locationScenario(_:)), "", [])
item.representedObject = scenario.rawValue
locationMenu.addItem(item)
}
locationItem.submenu = locationMenu
featuresMenu.addItem(locationItem)
featuresItem.submenu = featuresMenu
bar.addItem(featuresItem)

Expand Down Expand Up @@ -356,6 +400,7 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation {
private var isDark = false
private var isSlowAnimations = false
private var isLatencyVisible = false
private var isIncreasedContrast = false

init(actions: ViewerMenu.Actions, commandLineTool: CommandLineToolMenu? = nil) {
self.actions = actions
Expand Down Expand Up @@ -444,6 +489,25 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation {
@objc func siri() { actions.pressButton(.siri) }
@objc func actionButton() { actions.pressButton(.actionButton) }
@objc func appSwitcher() { actions.appSwitcher() }
@objc func restart() { actions.restart() }
@objc func erase() { actions.erase() }
@objc func textSizeUp() { actions.stepTextSize(.increment) }
@objc func textSizeDown() { actions.stepTextSize(.decrement) }
@objc func iCloudSync() { actions.triggerICloudSync() }
@objc func clearLocation() { actions.setLocation(nil) }
@objc func customLocation() { actions.setCustomLocation() }

@objc func locationScenario(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String,
let scenario = SimctlService.LocationScenario(rawValue: raw) else { return }
actions.setLocation(scenario)
}

@objc func increaseContrast(_ sender: NSMenuItem) {
isIncreasedContrast.toggle()
sender.state = isIncreasedContrast ? .on : .off
actions.toggleIncreaseContrast()
}
@objc func stopRecording() { actions.stopRecording() }

func trackStopRecordingItem(_ item: NSMenuItem) {
Expand Down
Loading
Loading