diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 87e8ee9..9857335 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -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 } @@ -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 diff --git a/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift b/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift index bd1039a..e52f854 100644 --- a/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift +++ b/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift @@ -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 + ) + } + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/Coordinate.swift b/engine/Sources/OpenDeviceHubViewer/Coordinate.swift new file mode 100644 index 0000000..c9f2536 --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/Coordinate.swift @@ -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 + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 2052b8d..77368c0 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -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 @@ -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, @@ -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 @@ -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] { @@ -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) @@ -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 @@ -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) { diff --git a/engine/Tests/OpenDeviceHubEngineTests/SimctlDeviceControlTests.swift b/engine/Tests/OpenDeviceHubEngineTests/SimctlDeviceControlTests.swift new file mode 100644 index 0000000..7ed25a4 --- /dev/null +++ b/engine/Tests/OpenDeviceHubEngineTests/SimctlDeviceControlTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import OpenDeviceHubEngine + +/// Every argument list here was checked against `simctl help` on Xcode 27 before it was written. +/// The tests pin the shapes, because a wrong verb does not fail loudly: it does something else to +/// somebody's device. +final class SimctlDeviceControlTests: XCTestCase { + private let udid = "60944F68-2A87-4EE5-AED5-BC08BFADF42A" + + func testEraseNamesTheDevice() { + XCTAssertEqual(SimctlService.eraseArguments(udid: udid), ["simctl", "erase", udid]) + } + + func testICloudSync() { + XCTAssertEqual(SimctlService.iCloudSyncArguments(udid: udid), ["simctl", "icloud_sync", udid]) + } + + func testContentSizeStepsBothWays() { + XCTAssertEqual( + SimctlService.contentSizeArguments(udid: udid, step: .increment), + ["simctl", "ui", udid, "content_size", "increment"] + ) + XCTAssertEqual( + SimctlService.contentSizeArguments(udid: udid, step: .decrement), + ["simctl", "ui", udid, "content_size", "decrement"] + ) + } + + /// The words are `enabled` and `disabled`, not `on` and `off` or `true` and `false`. + func testIncreaseContrastUsesSimctlsOwnWords() { + XCTAssertEqual( + SimctlService.increaseContrastArguments(udid: udid, enabled: true), + ["simctl", "ui", udid, "increase_contrast", "enabled"] + ) + XCTAssertEqual( + SimctlService.increaseContrastArguments(udid: udid, enabled: false), + ["simctl", "ui", udid, "increase_contrast", "disabled"] + ) + } + + func testReadingContrastPassesNoValue() { + XCTAssertEqual( + SimctlService.readIncreaseContrastArguments(udid: udid), + ["simctl", "ui", udid, "increase_contrast"] + ) + } + + /// The scenario names are what `simctl location list` prints, spaces included. + func testEveryScenarioIsPassedByItsOwnName() { + XCTAssertEqual(SimctlService.LocationScenario.allCases.count, 4) + for scenario in SimctlService.LocationScenario.allCases { + XCTAssertEqual( + SimctlService.locationScenarioArguments(udid: udid, scenario: scenario), + ["simctl", "location", udid, "run", scenario.rawValue] + ) + } + XCTAssertEqual(SimctlService.LocationScenario.cityBicycleRide.rawValue, "City Bicycle Ride") + } + + /// One argument, comma separated, which is the shape simctl documents. + func testACustomLocationIsOneCommaSeparatedArgument() { + XCTAssertEqual( + SimctlService.locationSetArguments(udid: udid, latitude: 37.3349, longitude: -122.0090), + ["simctl", "location", udid, "set", "37.3349,-122.009"] + ) + } + + func testClearingTakesNoCoordinates() { + XCTAssertEqual( + SimctlService.locationClearArguments(udid: udid), + ["simctl", "location", udid, "clear"] + ) + } +} diff --git a/engine/Tests/OpenDeviceHubViewerTests/CoordinateTests.swift b/engine/Tests/OpenDeviceHubViewerTests/CoordinateTests.swift new file mode 100644 index 0000000..3b08200 --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/CoordinateTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import OpenDeviceHubViewer + +final class CoordinateTests: XCTestCase { + func testAPlainPair() { + let point = Coordinate(parsing: "37.3349, -122.0090") + XCTAssertEqual(point?.latitude, 37.3349) + XCTAssertEqual(point?.longitude, -122.009) + } + + func testSpacingDoesNotMatter() { + XCTAssertEqual(Coordinate(parsing: "37.3349,-122.009"), Coordinate(parsing: " 37.3349 , -122.009 ")) + } + + func testWholeNumbersAreFine() { + XCTAssertEqual(Coordinate(parsing: "0,0")?.latitude, 0) + } + + /// Out of range is the failure that would otherwise reach simctl and be rejected there, or worse + /// be accepted and put the device somewhere impossible. + func testOutOfRangeIsRefused() { + XCTAssertNil(Coordinate(parsing: "91, 0")) + XCTAssertNil(Coordinate(parsing: "-91, 0")) + XCTAssertNil(Coordinate(parsing: "0, 181")) + XCTAssertNil(Coordinate(parsing: "0, -181")) + } + + func testTheEdgesAreAllowed() { + XCTAssertNotNil(Coordinate(parsing: "90, 180")) + XCTAssertNotNil(Coordinate(parsing: "-90, -180")) + } + + func testNonsenseIsRefused() { + for text in ["", "37.3349", "37.3349, -122.009, 5", "here, there", "37.3349 -122.009"] { + XCTAssertNil(Coordinate(parsing: text), "accepted \(text)") + } + } +}