diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index ca2812e..8eab7f5 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -49,6 +49,22 @@ } } }, + "%@ · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@" + } + } + } + }, "A simulator is where it starts.": { "localizations": { "en": { @@ -209,6 +225,22 @@ } } }, + "Couldn’t Start Simulator": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t Start Simulator" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法启动模拟器" + } + } + } + }, "Couldn’t check for simulators": { "localizations": { "en": { @@ -225,6 +257,22 @@ } } }, + "Couldn’t start %@.\n%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t start %1$@.\n%2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法启动 %1$@。\n%2$@" + } + } + } + }, "Create an iPhone or iPad simulator in %@.\nThen come back to find your next favorite app.": { "localizations": { "en": { @@ -289,34 +337,6 @@ } } }, - "Found %lld available iOS simulators.": { - "localizations": { - "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "Found %lld available iOS simulator." - } - }, - "other": { - "stringUnit": { - "state": "translated", - "value": "Found %lld available iOS simulators." - } - } - } - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "找到 %lld 台可用的 iOS 模拟器。" - } - } - } - }, "Install Xcode to add iPhone and iPad simulators.": { "localizations": { "en": { @@ -541,18 +561,18 @@ } } }, - "SIMULATORS": { + "Runtime": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "SIMULATORS" + "value": "Runtime" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "模拟器" + "value": "运行时" } } } @@ -589,6 +609,54 @@ } } }, + "Simulator": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模拟器" + } + } + } + }, + "Simulators": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulators" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模拟器" + } + } + } + }, + "Start Simulator": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Simulator" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动模拟器" + } + } + } + }, "Start with Xcode.": { "localizations": { "en": { @@ -605,6 +673,22 @@ } } }, + "Starting…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动…" + } + } + } + }, "Try Again": { "localizations": { "en": { @@ -717,34 +801,18 @@ } } }, - "Your simulators are ready": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Your simulators are ready" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "模拟器已就绪" - } - } - } - }, - "iOS %@ · %@": { + "iOS %@": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iOS %@ · %@" + "value": "iOS %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "iOS %@ · %@" + "value": "iOS %@" } } } diff --git a/cmake/Localization.cmake b/cmake/Localization.cmake index 12fa110..c4066c9 100644 --- a/cmake/Localization.cmake +++ b/cmake/Localization.cmake @@ -25,7 +25,6 @@ endforeach() set(LOCALIZATION_RESOURCES "${LOCALIZATION_OUTPUT_DIR}/en.lproj/Localizable.strings" - "${LOCALIZATION_OUTPUT_DIR}/en.lproj/Localizable.stringsdict" "${LOCALIZATION_OUTPUT_DIR}/zh-Hans.lproj/Localizable.strings" ) add_custom_command(OUTPUT ${LOCALIZATION_RESOURCES} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index deb5fb8..89e4cb6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,8 +1,13 @@ add_executable(SimStore MACOSX_BUNDLE AppDelegate.swift AppConfiguration.swift + Command.swift SimStoreApp.swift SimulatorDiscovery.swift + SimulatorControl.swift + SimulatorStore.swift + SimulatorDevicePresentation.swift + SimulatorSelectionViews.swift ContentView.swift SidebarView.swift NoSimulatorsView.swift diff --git a/src/Command.swift b/src/Command.swift new file mode 100644 index 0000000..d297306 --- /dev/null +++ b/src/Command.swift @@ -0,0 +1,43 @@ +import Foundation + +enum Command { + typealias Runner = @Sendable (String, [String]) throws -> Data + + static let xcrunExecutable = "/usr/bin/xcrun" + static let simctlTool = "simctl" + + static func run(_ executable: String, arguments: [String]) throws -> Data { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardOutput = pipe + process.standardError = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + let diagnostic = String(decoding: data, as: UTF8.self).trimmingCharacters( + in: .whitespacesAndNewlines) + throw NSError( + domain: "Command", code: Int(process.terminationStatus), + userInfo: [ + NSLocalizedDescriptionKey: diagnostic.isEmpty + ? String( + localized: + "\(process.executableURL!.lastPathComponent) exited with status \(process.terminationStatus)." + ) + : diagnostic + ]) + } + return data + } + + static func xcrun(_ arguments: [String], using command: Runner = run) throws -> Data { + try command(xcrunExecutable, arguments) + } + + static func simctl(_ arguments: [String], using command: Runner = run) throws -> Data { + try xcrun([simctlTool] + arguments, using: command) + } +} diff --git a/src/ContentView.swift b/src/ContentView.swift index 393f6a9..dcad3b7 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -3,9 +3,7 @@ import SwiftUI @MainActor struct ContentView: View { - private let discover: @MainActor () async throws -> SimulatorDiscovery - @State private var result: Result? - @State private var isChecking = false + @State private var simulators: SimulatorStore @State private var launchError: String? private static var installedXcodeURL: URL? { @@ -20,19 +18,21 @@ struct ContentView: View { } #endif return try await SimulatorDiscovery.scan(installedXcodeURL: ContentView.installedXcodeURL) + }, + boot: @escaping @Sendable (String) async throws -> Void = { + try await SimulatorControl.boot($0) } ) { - self.discover = discover + _simulators = State(initialValue: SimulatorStore(discover: discover, boot: boot)) } private var needsXcode: Bool { - guard case .failure(let error) = result else { return false } + guard case .failure(let error) = simulators.result else { return false } return (error as? SimulatorDiscoveryError) == .xcodeNotInstalled } private var discovery: SimulatorDiscovery? { - guard case .success(let value) = result else { return nil } - return value + simulators.discovery } private var deviceManager: (name: String, url: URL)? { @@ -43,7 +43,9 @@ struct ContentView: View { NavigationSplitView { GeometryReader { geometry in SidebarView( - discovery: discovery, isChecking: isChecking, needsXcode: needsXcode, + discovery: discovery, isChecking: simulators.isChecking, needsXcode: needsXcode, + selectedDeviceID: simulators.selectedDeviceID, + startingDeviceIDs: simulators.startingDeviceIDs, selectDevice: simulators.selectDevice, openRepository: openRepository ) .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) @@ -70,19 +72,18 @@ struct ContentView: View { } .toolbar { ToolbarItem(placement: .navigation) { - Label("No simulator selected", systemImage: "iphone") - .labelStyle(.titleAndIcon) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.horizontal, 8) - .fixedSize() + SimulatorPicker( + devices: discovery?.devices ?? [], selectedDevice: simulators.selectedDevice, + selectDevice: simulators.selectDevice + ) + .labelStyle(.titleAndIcon) + .font(.system(size: 12)) } ToolbarItem(placement: .primaryAction) { Button(action: checkAgain) { Label("Check for Simulators", systemImage: "arrow.clockwise") } - .disabled(isChecking) + .disabled(simulators.isChecking) .keyboardShortcut("r", modifiers: .command) .help(Text("Check for iOS simulators")) } @@ -90,7 +91,16 @@ struct ContentView: View { } .navigationSplitViewStyle(.balanced) .frame(minWidth: 900, minHeight: 600) - .task { await refresh() } + .task { await simulators.refresh() } + .task(id: shouldMonitorState) { + while shouldMonitorState && !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(2)) + try Task.checkCancellation() + await simulators.refresh() + } catch { return } + } + } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in checkAgain() @@ -106,47 +116,65 @@ struct ContentView: View { } message: { Text(launchError ?? "") } + .alert( + "Couldn’t Start Simulator", + isPresented: Binding( + get: { simulators.startError != nil }, + set: { if !$0 { simulators.startError = nil } } + ) + ) { + Button("OK", role: .cancel) { simulators.startError = nil } + } message: { + Text(simulators.startError ?? "") + } } @ViewBuilder private var discoveryContent: some View { - switch result { + switch simulators.result { case .none: SimulatorLoadingView() case .some(.success(let snapshot)) where snapshot.devices.isEmpty: NoSimulatorsView( - deviceManagerName: deviceManager?.name, isChecking: isChecking, + deviceManagerName: deviceManager?.name, isChecking: simulators.isChecking, openDeviceManager: openDeviceManager, openXcode: openXcode, checkAgain: checkAgain) - case .some(.success(let snapshot)): - SimulatorsReadyView(count: snapshot.devices.count) + case .some(.success): + if let device = simulators.selectedDevice { + SimulatorDetailView( + device: device, isStarting: simulators.isStartingSelectedDevice, + deviceManagerName: deviceManager?.name, + start: startSimulator, + openDeviceManager: openDeviceManager) + } case .some(.failure) where needsXcode: MissingXcodeView( - isChecking: isChecking, openAppStore: openAppStore, checkAgain: checkAgain) + isChecking: simulators.isChecking, openAppStore: openAppStore, checkAgain: checkAgain) case .some(.failure(let error)): SimulatorDiscoveryErrorView( - error: error, isChecking: isChecking, openXcode: openXcode, checkAgain: checkAgain) + error: error, isChecking: simulators.isChecking, openXcode: openXcode, + checkAgain: checkAgain) } } private func checkAgain() { - Task { await refresh() } + Task { await simulators.refresh() } } - private func refresh() async { - guard !isChecking else { return } - isChecking = true - defer { isChecking = false } - do { - let snapshot = try await discover() - try Task.checkCancellation() - result = .success(snapshot) - } catch is CancellationError { - return - } catch { - result = .failure(error) + private func startSimulator() { + let id = simulators.selectedDeviceID + Task { + let started = await simulators.startSelectedDevice() + if started, let device = simulators.selectedDevice, device.id == id { + openDeviceManager() + } } } + private var shouldMonitorState: Bool { + !simulators.startingDeviceIDs.isEmpty + || discovery?.devices.contains(where: \.isTransitioning) == true + } + private func openRepository() { let url = AppConfiguration.buildsRepositoryURL if !NSWorkspace.shared.open(url) { diff --git a/src/NoSimulatorsView.swift b/src/NoSimulatorsView.swift index 245ed52..a814c73 100644 --- a/src/NoSimulatorsView.swift +++ b/src/NoSimulatorsView.swift @@ -45,13 +45,8 @@ struct NoSimulatorsView: View { .padding(.top, 12) HStack(spacing: 10) { - Button( - "Open \(deviceManagerName ?? "Xcode")", - systemImage: deviceManagerName == nil ? "hammer" : "iphone.and.arrow.forward" - ) { - openDeviceManager() - } - .buttonStyle(.borderedProminent) + DeviceManagerButton(name: deviceManagerName, action: openDeviceManager) + .buttonStyle(.borderedProminent) SimulatorCheckButton(isChecking: isChecking, action: checkAgain) .buttonStyle(.bordered) } diff --git a/src/PreviewScenario.swift b/src/PreviewScenario.swift index 80e0674..4350eb4 100644 --- a/src/PreviewScenario.swift +++ b/src/PreviewScenario.swift @@ -21,8 +21,7 @@ func discover( installedXcodeURL: URL?, - command: @escaping @Sendable (String, [String]) throws -> Data = SimulatorDiscovery - .commandOutput + command: @escaping Command.Runner = Command.run ) async throws -> SimulatorDiscovery { switch self { case .noXcode: @@ -30,7 +29,9 @@ case .noSimulators: return try await SimulatorDiscovery.scan(installedXcodeURL: installedXcodeURL) { executable, arguments in - if executable == "/usr/bin/xcrun", arguments.starts(with: ["simctl", "list", "devices"]) { + if executable == Command.xcrunExecutable, + arguments.starts(with: [Command.simctlTool, "list", "devices"]) + { return Data(#"{"devices": {}}"#.utf8) } return try command(executable, arguments) diff --git a/src/SidebarView.swift b/src/SidebarView.swift index 4d5971d..276cb67 100644 --- a/src/SidebarView.swift +++ b/src/SidebarView.swift @@ -6,6 +6,9 @@ struct SidebarView: View { let discovery: SimulatorDiscovery? let isChecking: Bool let needsXcode: Bool + let selectedDeviceID: String? + let startingDeviceIDs: Set + let selectDevice: (String) -> Void let openRepository: () -> Void var body: some View { @@ -38,7 +41,8 @@ struct SidebarView: View { .padding(.horizontal, 12) .fixedSize(horizontal: false, vertical: true) - Text("SIMULATORS") + simulatorListTitle + .textCase(.uppercase) .font(.system(size: 10, weight: .semibold)) .tracking(1) .foregroundStyle(.secondary) @@ -48,29 +52,38 @@ struct SidebarView: View { .fixedSize(horizontal: false, vertical: true) if let discovery, !discovery.devices.isEmpty { - ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { - ForEach(discovery.devices) { device in - Label { - VStack(alignment: .leading, spacing: 4) { - Text(device.name).font(.system(size: 12, weight: .medium)) - Text("iOS \(device.runtime) · \(stateLabel(device.state))") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - } - .lineLimit(1) - .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - } icon: { - Image(systemName: device.name.contains("iPad") ? "ipad.landscape" : "iphone") - .foregroundStyle(.secondary) - .frame(width: 20) + List( + selection: Binding( + get: { selectedDeviceID }, + set: { if let id = $0 { selectDevice(id) } }) + ) { + ForEach(discovery.devices) { device in + Label { + VStack(alignment: .leading, spacing: 4) { + Text(device.name).font(.system(size: 12, weight: .medium)) + Text( + "\(device.runtimeLabel) · \(device.stateLabel(isStarting: startingDeviceIDs.contains(device.id)))" + ) + .font(.system(size: 10)) + .foregroundStyle(.secondary) } + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } icon: { + Image(systemName: device.symbolName) + .foregroundStyle(.secondary) + .frame(width: 20) } + .tag(device.id) + .listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) + .accessibilityElement(children: .combine) + .help(Text(verbatim: device.id)) } - .padding(.horizontal, 24) - .padding(.vertical, 4) } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .accessibilityLabel(simulatorListTitle) .frame(minHeight: 0) } else { VStack(alignment: .leading, spacing: 7) { @@ -112,15 +125,7 @@ struct SidebarView: View { } } - private func stateLabel(_ state: String) -> String { - switch state { - case "Booted": String(localized: "Booted") - case "Shutdown": String(localized: "Shutdown") - case "Booting": String(localized: "Booting") - case "Shutting Down": String(localized: "Shutting Down") - default: state - } - } + private var simulatorListTitle: Text { Text("Simulators") } private func sidebarPlaceholder(_ title: LocalizedStringKey, systemImage: String) -> some View { Label(title, systemImage: systemImage) diff --git a/src/SimulatorControl.swift b/src/SimulatorControl.swift new file mode 100644 index 0000000..48abac6 --- /dev/null +++ b/src/SimulatorControl.swift @@ -0,0 +1,14 @@ +import Foundation + +enum SimulatorControl { + static func boot( + _ deviceID: String, + command: @escaping Command.Runner = Command.run + ) async throws { + try Task.checkCancellation() + try await Task.detached(priority: .userInitiated) { + _ = try Command.simctl(["bootstatus", deviceID, "-b"], using: command) + }.value + try Task.checkCancellation() + } +} diff --git a/src/SimulatorDevicePresentation.swift b/src/SimulatorDevicePresentation.swift new file mode 100644 index 0000000..b49c0b8 --- /dev/null +++ b/src/SimulatorDevicePresentation.swift @@ -0,0 +1,31 @@ +import Foundation + +extension SimulatorDevice { + enum FormFactor: CaseIterable, Hashable { + case phone + case tablet + + var symbolName: String { + switch self { + case .phone: "iphone" + case .tablet: "ipad.landscape" + } + } + } + + var formFactor: FormFactor { name.contains("iPad") ? .tablet : .phone } + + var symbolName: String { formFactor.symbolName } + + var runtimeLabel: String { String(localized: "iOS \(runtime)") } + + func stateLabel(isStarting: Bool = false) -> String { + switch isStarting ? "Booting" : state { + case "Booted": String(localized: "Booted") + case "Shutdown": String(localized: "Shutdown") + case "Booting": String(localized: "Booting") + case "Shutting Down": String(localized: "Shutting Down") + default: state + } + } +} diff --git a/src/SimulatorDiscovery.swift b/src/SimulatorDiscovery.swift index 21a4893..7bea8ad 100644 --- a/src/SimulatorDiscovery.swift +++ b/src/SimulatorDiscovery.swift @@ -11,6 +11,10 @@ struct SimulatorDevice: Identifiable, Equatable, Sendable { let name: String let runtime: String let state: String + + var canStart: Bool { state == "Shutdown" || state == "Booting" } + + var isTransitioning: Bool { state == "Booting" || state == "Shutting Down" } } struct SimulatorDiscovery: Sendable { @@ -37,7 +41,7 @@ struct SimulatorDiscovery: Sendable { static func scan( installedXcodeURL: URL?, - command: @escaping @Sendable (String, [String]) throws -> Data = commandOutput + command: @escaping Command.Runner = Command.run ) async throws -> SimulatorDiscovery { try await Task.detached(priority: .userInitiated) { let directoryData: Data @@ -62,8 +66,8 @@ struct SimulatorDiscovery: Sendable { guard isXcodeApplication(selectedXcode) || isXcodeApplication(installedXcodeURL) else { throw SimulatorDiscoveryError.xcodeNotInstalled } - let deviceData = try command( - "/usr/bin/xcrun", ["simctl", "list", "devices", "available", "--json"]) + let deviceData = try Command.simctl( + ["list", "devices", "available", "--json"], using: command) return SimulatorDiscovery( developerDirectory: developerDirectory, devices: try decodeDevices(deviceData)) }.value @@ -101,31 +105,4 @@ struct SimulatorDiscovery: Sendable { return $0.name.localizedStandardCompare($1.name) == .orderedAscending } } - - static func commandOutput(_ executable: String, arguments: [String]) throws -> Data { - let process = Process() - let pipe = Pipe() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - process.standardOutput = pipe - process.standardError = pipe - try process.run() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - let diagnostic = String(decoding: data, as: UTF8.self).trimmingCharacters( - in: .whitespacesAndNewlines) - throw NSError( - domain: "SimulatorDiscovery", code: Int(process.terminationStatus), - userInfo: [ - NSLocalizedDescriptionKey: diagnostic.isEmpty - ? String( - localized: - "\(process.executableURL!.lastPathComponent) exited with status \(process.terminationStatus)." - ) - : diagnostic - ]) - } - return data - } } diff --git a/src/SimulatorSelectionViews.swift b/src/SimulatorSelectionViews.swift new file mode 100644 index 0000000..e31f22f --- /dev/null +++ b/src/SimulatorSelectionViews.swift @@ -0,0 +1,150 @@ +import SwiftUI + +@MainActor +struct SimulatorPicker: View { + let devices: [SimulatorDevice] + let selectedDevice: SimulatorDevice? + let selectDevice: (String) -> Void + + var body: some View { + if let selectedDevice { + Menu { + Picker( + "Simulator", + selection: Binding( + get: { selectedDevice.id }, + set: { selectDevice($0) }) + ) { + ForEach(devices) { device in + Text(verbatim: "\(device.name) · \(device.runtimeLabel) · \(device.id.suffix(8))") + .tag(device.id) + } + } + .pickerStyle(.inline) + .labelsHidden() + } label: { + Label { + Text(verbatim: selectedDevice.name) + .lineLimit(1) + .truncationMode(.tail) + } icon: { + Image(systemName: selectedDevice.symbolName) + } + .frame(maxWidth: 260, alignment: .leading) + } + .help( + Text( + verbatim: "\(selectedDevice.name) · \(selectedDevice.runtimeLabel)\n\(selectedDevice.id)") + ) + } else { + Label("No simulator selected", systemImage: "iphone") + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, 8) + .fixedSize() + } + } +} + +@MainActor +struct SimulatorDetailView: View { + let device: SimulatorDevice + let isStarting: Bool + let deviceManagerName: String? + let start: () -> Void + let openDeviceManager: () -> Void + + var body: some View { + VStack(spacing: 24) { + StableSizeView(device.formFactor, states: SimulatorDevice.FormFactor.allCases) { formFactor in + Image(systemName: formFactor.symbolName) + } + .font(.system(size: 52, weight: .light)) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + + VStack(spacing: 10) { + Text(verbatim: device.name) + .font(.system(size: 25, weight: .semibold)) + .multilineTextAlignment(.center) + .lineLimit(2, reservesSpace: true) + .help(Text(verbatim: device.name)) + HStack(spacing: 8) { + Circle() + .fill(device.state == "Booted" ? Color.green : Color.secondary) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + Text(verbatim: device.stateLabel(isStarting: isStarting)) + } + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 14) { + GridRow { + Text("Runtime").foregroundStyle(.secondary) + Text(verbatim: device.runtimeLabel) + } + GridRow(alignment: .top) { + Text(verbatim: "UDID").foregroundStyle(.secondary) + Text(verbatim: device.id) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + } + } + .font(.system(size: 12)) + .padding(20) + .frame(maxWidth: .infinity) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) + + HStack(spacing: 10) { + if device.canStart || isStarting { + SimulatorStartButton( + isStarting: isStarting || device.state == "Booting", action: start + ) + .buttonStyle(.borderedProminent) + } + DeviceManagerButton(name: deviceManagerName, action: openDeviceManager) + .buttonStyle(.bordered) + } + .controlSize(.large) + } + .frame(maxWidth: 480) + .padding(32) + } +} + +@MainActor +struct SimulatorStartButton: View { + let isStarting: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Label { + StableSizeView(isStarting, states: [false, true]) { starting in + Text(title(isStarting: starting)) + } + } icon: { + Image(systemName: "power") + } + } + .disabled(isStarting) + } + + private func title(isStarting: Bool) -> LocalizedStringKey { + isStarting ? "Starting…" : "Start Simulator" + } +} + +@MainActor +struct DeviceManagerButton: View { + let name: String? + let action: () -> Void + + var body: some View { + Button( + "Open \(name ?? "Xcode")", + systemImage: name == nil ? "hammer" : "iphone.and.arrow.forward", action: action) + } +} diff --git a/src/SimulatorStatusViews.swift b/src/SimulatorStatusViews.swift index 857ad34..d73973a 100644 --- a/src/SimulatorStatusViews.swift +++ b/src/SimulatorStatusViews.swift @@ -12,26 +12,6 @@ struct SimulatorLoadingView: View { } } -@MainActor -struct SimulatorsReadyView: View { - let count: Int - - var body: some View { - VStack(spacing: 14) { - Image(systemName: "iphone.and.arrow.forward") - .font(.system(size: 48, weight: .light)) - .foregroundStyle(Color.accentColor) - Text("Your simulators are ready") - .font(.system(size: 24, weight: .semibold)) - Text( - "Found \(count) available iOS simulators." - ) - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - } -} - @MainActor struct SimulatorDiscoveryErrorView: View { let error: Error diff --git a/src/SimulatorStore.swift b/src/SimulatorStore.swift new file mode 100644 index 0000000..ad0ceeb --- /dev/null +++ b/src/SimulatorStore.swift @@ -0,0 +1,106 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class SimulatorStore { + private(set) var result: Result? + private(set) var isChecking = false + private(set) var selectedDeviceID: String? + private(set) var startingDeviceIDs: Set = [] + var startError: String? + + @ObservationIgnored + private let discover: @MainActor () async throws -> SimulatorDiscovery + @ObservationIgnored + private let boot: @Sendable (String) async throws -> Void + @ObservationIgnored + private var refreshWaiters: [CheckedContinuation] = [] + + init( + discover: @escaping @MainActor () async throws -> SimulatorDiscovery, + boot: @escaping @Sendable (String) async throws -> Void + ) { + self.discover = discover + self.boot = boot + } + + var discovery: SimulatorDiscovery? { + guard case .success(let discovery) = result else { return nil } + return discovery + } + + var selectedDevice: SimulatorDevice? { + discovery?.devices.first { $0.id == selectedDeviceID } + } + + var isStartingSelectedDevice: Bool { + guard let selectedDeviceID else { return false } + return startingDeviceIDs.contains(selectedDeviceID) + } + + func selectDevice(_ id: String) { + guard discovery?.devices.contains(where: { $0.id == id }) == true else { return } + selectedDeviceID = id + } + + func refresh() async { + guard !isChecking else { + await waitForRefresh() + return + } + isChecking = true + defer { + isChecking = false + let waiters = refreshWaiters + refreshWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + do { + let discovery = try await discover() + try Task.checkCancellation() + result = .success(discovery) + if !discovery.devices.contains(where: { $0.id == selectedDeviceID }) { + selectedDeviceID = + discovery.devices.first(where: { $0.state == "Booted" })?.id + ?? discovery.devices.first?.id + } + } catch is CancellationError { + } catch { + result = .failure(error) + } + } + + @discardableResult + func startSelectedDevice() async -> Bool { + guard let device = selectedDevice, + device.canStart, + startingDeviceIDs.insert(device.id).inserted + else { return false } + startError = nil + defer { startingDeviceIDs.remove(device.id) } + do { + try await boot(device.id) + try Task.checkCancellation() + // An inventory scan begun before boot completed may still report Shutdown. + if isChecking { await waitForRefresh() } + try Task.checkCancellation() + await refresh() + try Task.checkCancellation() + return discovery?.devices.contains(where: { $0.id == device.id && $0.state == "Booted" }) + == true + } catch is CancellationError { + return false + } catch { + startError = String( + localized: "Couldn’t start \(device.name).\n\(error.localizedDescription)") + return false + } + } + + private func waitForRefresh() async { + await withCheckedContinuation { continuation in + refreshWaiters.append(continuation) + } + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 316df6a..d549263 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,10 +9,35 @@ add_executable(SimStoreLifecycleTest AppLifecycleTest.swift "${PROJECT_SOURCE_DI add_test(NAME app-window-close COMMAND SimStoreLifecycleTest) set_tests_properties(app-window-close PROPERTIES TIMEOUT 10 RUN_SERIAL TRUE) -add_executable(SimStoreDiscoveryTest SimulatorDiscoveryTests.swift "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift") +add_executable(SimStoreCommandTest CommandTests.swift "${PROJECT_SOURCE_DIR}/src/Command.swift") +target_compile_options(SimStoreCommandTest PRIVATE -parse-as-library) +add_test(NAME command COMMAND SimStoreCommandTest) +set_tests_properties(command PROPERTIES TIMEOUT 10) + +add_executable(SimStoreDiscoveryTest SimulatorDiscoveryTests.swift + "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" + "${PROJECT_SOURCE_DIR}/src/Command.swift" +) add_test(NAME simulator-discovery COMMAND SimStoreDiscoveryTest) set_tests_properties(simulator-discovery PROPERTIES TIMEOUT 15) +add_executable(SimStoreControlTest SimulatorControlTests.swift + "${PROJECT_SOURCE_DIR}/src/Command.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorControl.swift" +) +target_compile_options(SimStoreControlTest PRIVATE -parse-as-library) +add_test(NAME simulator-control COMMAND SimStoreControlTest) +set_tests_properties(simulator-control PROPERTIES TIMEOUT 15) + +add_executable(SimStoreStoreTest SimulatorStoreTests.swift + "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorStore.swift" + "${PROJECT_SOURCE_DIR}/src/Command.swift" +) +target_compile_options(SimStoreStoreTest PRIVATE -parse-as-library) +add_test(NAME simulator-store COMMAND SimStoreStoreTest) +set_tests_properties(simulator-store PROPERTIES TIMEOUT 15) + add_executable(SimStoreLocalizationTest LocalizationTests.swift) add_dependencies(SimStoreLocalizationTest SimStore) target_compile_options(SimStoreLocalizationTest PRIVATE -parse-as-library) @@ -29,6 +54,7 @@ if(CMAKE_BUILD_TYPE STREQUAL "Debug") add_executable(SimStorePreviewScenarioTest PreviewScenarioTests.swift "${PROJECT_SOURCE_DIR}/src/PreviewScenario.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" + "${PROJECT_SOURCE_DIR}/src/Command.swift" ) target_compile_definitions(SimStorePreviewScenarioTest PRIVATE DEBUG) target_compile_options(SimStorePreviewScenarioTest PRIVATE -parse-as-library) diff --git a/tests/CommandTests.swift b/tests/CommandTests.swift new file mode 100644 index 0000000..5aeba16 --- /dev/null +++ b/tests/CommandTests.swift @@ -0,0 +1,86 @@ +import Foundation + +@main +struct CommandTests { + static func main() { + do { + try argumentBytes() + try processFailures() + try wrapperArguments() + try wrapperFailures() + print("PASS: command output, literal arguments, exit diagnostics, and tool wrappers.") + } catch { + FileHandle.standardError.write( + Data("Command test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + static func argumentBytes() throws { + let value = "A simulator 名称\n'quoted' ; $(echo unexpected) $HOME \\" + let output = try Command.run("/usr/bin/printf", arguments: ["%s", value]) + try require( + output == Data(value.utf8), "Arguments must reach the process without shell expansion.") + } + + static func processFailures() throws { + for (script, status, diagnostic) in [ + ("printf 'simulator tools unavailable' >&2; exit 9", 9, "simulator tools unavailable"), + ("exit 7", 7, ""), + ] { + let error: NSError + do { + _ = try Command.run("/bin/sh", arguments: ["-c", script]) + throw failure("A nonzero exit must fail instead of returning output.") + } catch let caught { error = caught as NSError } + try require(error.domain == "Command" && error.code == status, "Preserve the exit status.") + if diagnostic.isEmpty { + try require( + error.localizedDescription.contains("sh") + && error.localizedDescription.contains(String(status)), + "An empty diagnostic must identify the executable and exit status.") + } else { + try require(error.localizedDescription == diagnostic, "Preserve the command's stderr.") + } + } + } + + static func wrapperArguments() throws { + let output = Data("tool output".utf8) + let arguments = ["--set", "/tmp/Devices with spaces", "list", "devices", "--json"] + let xcrunOutput = try Command.xcrun(arguments) { executable, actual in + try require(executable == "/usr/bin/xcrun", "Use the system Xcode tool resolver.") + try require(actual == arguments, "Forward xcrun arguments without changing their boundaries.") + return output + } + let simctlOutput = try Command.simctl(arguments) { executable, actual in + try require(executable == "/usr/bin/xcrun", "Resolve simctl through xcrun.") + try require(actual == ["simctl"] + arguments, "Prefix the simulator tool exactly once.") + return output + } + try require(xcrunOutput == output && simctlOutput == output, "Return the original tool output.") + } + + static func wrapperFailures() throws { + let expected = failure("Selected toolchain is unavailable.") + let runner: Command.Runner = { _, _ in throw expected } + let wrappers: [([String], Command.Runner) throws -> Data] = [Command.xcrun, Command.simctl] + for wrapper in wrappers { + do { + _ = try wrapper(["list"], runner) + } catch { + try require((error as NSError) == expected, "Forward the original tool error unchanged.") + continue + } + throw failure("A failed tool command must throw.") + } + } + + static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + static func failure(_ message: String) -> NSError { + NSError(domain: "CommandTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift index 1cee270..25a251c 100644 --- a/tests/LocalizationTests.swift +++ b/tests/LocalizationTests.swift @@ -5,7 +5,7 @@ struct LocalizationTests { static func main() { do { try verifyTranslations() - print("PASS: English and Simplified Chinese resources, interpolation, and plurals.") + print("PASS: English and Simplified Chinese resources, simulator actions, and interpolation.") } catch { FileHandle.standardError.write( Data("Localization test failed: \(error.localizedDescription)\n".utf8)) @@ -34,6 +34,12 @@ struct LocalizationTests { let labels: [(String.LocalizationValue, String, String)] = [ ("Discover", "Discover", "发现"), ("Installed", "Installed", "已安装"), ("Updates", "Updates", "更新"), + ("Start Simulator", "Start Simulator", "启动模拟器"), + ("Starting…", "Starting…", "正在启动…"), + ("Booted", "Booted", "已启动"), + ("Shutdown", "Shutdown", "已关机"), + ("Booting", "Booting", "正在启动"), + ("Shutting Down", "Shutting Down", "正在关机"), ] for (key, source, translated) in labels { try expect( @@ -52,6 +58,11 @@ struct LocalizationTests { try expect( String(localized: "\(tool) couldn’t be opened. \(detail)", bundle: bundle, locale: locale), english ? "Device Hub couldn’t be opened. \(detail)" : "无法打开 Device Hub。\(detail)") + let deviceName = "iPhone 测试" + try expect( + String( + localized: "Couldn’t start \(deviceName).\n\(detail)", bundle: bundle, locale: locale), + english ? "Couldn’t start \(deviceName).\n\(detail)" : "无法启动 \(deviceName)。\n\(detail)") try expect( String( localized: @@ -61,16 +72,9 @@ struct LocalizationTests { ? "Create an iPhone or iPad simulator in Device Hub.\nThen come back to find your next favorite app." : "在 Device Hub 中创建 iPhone 或 iPad 模拟器。\n然后回来发现你喜爱的应用。") - for count in [0, 1, 2, 40] { - let expected = - english - ? "Found \(count) available iOS \(count == 1 ? "simulator" : "simulators")." - : "找到 \(count) 台可用的 iOS 模拟器。" - try expect( - String( - localized: "Found \(count) available iOS simulators.", bundle: bundle, locale: locale), - expected) - } + let runtime = "27.0" + try expect( + String(localized: "iOS \(runtime)", bundle: bundle, locale: locale), "iOS 27.0") } } diff --git a/tests/SimulatorControlTests.swift b/tests/SimulatorControlTests.swift new file mode 100644 index 0000000..00c2a76 --- /dev/null +++ b/tests/SimulatorControlTests.swift @@ -0,0 +1,99 @@ +import Foundation + +@main +struct SimulatorControlTests { + @MainActor + static func main() async { + do { + try await bootCommand() + try await commandFailure() + try await cancelledBeforeBoot() + try await cancelledDuringBoot() + print("PASS: simulator boot command, background execution, command errors, and cancellation.") + } catch { + FileHandle.standardError.write( + Data("Simulator control test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + @MainActor + static func bootCommand() async throws { + let deviceID = "0E67E0B9-7C5B-44E6-954C-B2AB9B955E9F" + try await SimulatorControl.boot(deviceID) { executable, arguments in + try require(executable == "/usr/bin/xcrun", "Boot should use the selected Xcode's tools.") + try require( + arguments == ["simctl", "bootstatus", deviceID, "-b"], + "Boot should target the exact selected UDID and wait until it is ready.") + try require(!Thread.isMainThread, "Waiting for boot must not block the main thread.") + return Data() + } + } + + static func commandFailure() async throws { + let expected = failure("Simulator runtime is unavailable.") + do { + try await SimulatorControl.boot("DEVICE") { _, _ in throw expected } + } catch { + try require( + (error as NSError) == expected, + "A failed boot should preserve the command's diagnostic instead of reporting success.") + return + } + throw failure("A failed boot command must throw.") + } + + static func cancelledBeforeBoot() async throws { + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await SimulatorControl.boot("DEVICE") { _, _ in + throw failure("An already cancelled request must not invoke simctl.") + } + } + do { + try await task.value + } catch is CancellationError { + return + } + throw failure("An already cancelled boot must throw CancellationError.") + } + + static func cancelledDuringBoot() async throws { + let started = DispatchSemaphore(value: 0) + let finish = DispatchSemaphore(value: 0) + let task = Task { + try await SimulatorControl.boot("DEVICE") { _, _ in + started.signal() + try require( + finish.wait(timeout: .now() + 5) == .success, + "The test must release the simulated boot command.") + return Data() + } + } + let didStart = await Task.detached { + waitForCommand(started) + }.value + task.cancel() + finish.signal() + try require(didStart, "The command must begin before testing in-flight cancellation.") + do { + try await task.value + } catch is CancellationError { + return + } + throw failure("Cancellation during boot must not be reported as success.") + } + + static func waitForCommand(_ semaphore: DispatchSemaphore) -> Bool { + semaphore.wait(timeout: .now() + 5) == .success + } + + static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + static func failure(_ message: String) -> NSError { + NSError( + domain: "SimulatorControlTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/tests/SimulatorDiscoveryTests.swift b/tests/SimulatorDiscoveryTests.swift index 4403825..25a7bf2 100644 --- a/tests/SimulatorDiscoveryTests.swift +++ b/tests/SimulatorDiscoveryTests.swift @@ -7,7 +7,6 @@ struct SimulatorDiscoveryTests { try emptyInventory() try availableIOSDevices() try malformedInventory() - try commandFailure() try deviceManagerLocations() try await xcodeInstallationStates() print( @@ -70,19 +69,6 @@ struct SimulatorDiscoveryTests { } } - static func commandFailure() throws { - do { - _ = try SimulatorDiscovery.commandOutput( - "/bin/sh", arguments: ["-c", "printf 'simulator tools unavailable' >&2; exit 9"]) - } catch { - try require( - error.localizedDescription.contains("simulator tools unavailable"), - "A failed command should preserve its diagnostic.") - return - } - throw failure("A nonzero command exit must fail instead of returning an empty inventory.") - } - static func deviceManagerLocations() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "SimStore Discovery \(UUID().uuidString)") diff --git a/tests/SimulatorStoreTests.swift b/tests/SimulatorStoreTests.swift new file mode 100644 index 0000000..4573079 --- /dev/null +++ b/tests/SimulatorStoreTests.swift @@ -0,0 +1,277 @@ +import Foundation + +@main +@MainActor +struct SimulatorStoreTests { + static func main() async { + do { + try await selectionAndRefresh() + try await startingAndSelectionChanges() + try await independentStarts() + try await overlappingRefresh() + try await startFailureAndCancellation() + try await startupRequiresConfirmedReadiness() + try await failedBootAfterBootedPoll() + print("PASS: simulator selection, refresh, concurrent startup, errors, and cancellation.") + } catch { + FileHandle.standardError.write( + Data("Simulator store test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + static func selectionAndRefresh() async throws { + let fixture = Inventory([device("A"), device("B", state: "Booted")]) + let store = SimulatorStore(discover: fixture.scan, boot: { _ in }) + await store.refresh() + try require(store.selectedDeviceID == "B", "Prefer an already booted simulator initially.") + store.selectDevice("A") + store.selectDevice("missing") + await store.refresh() + try require(store.selectedDeviceID == "A", "Keep an existing selection across refreshes.") + fixture.error = failure("Discovery failed") + await store.refresh() + try require(store.discovery == nil, "A discovery error must not expose a stale inventory.") + try require(store.selectedDeviceID == "A", "Keep the selection through a discovery error.") + fixture.error = nil + fixture.devices = [device("C"), device("B", state: "Booted")] + await store.refresh() + try require( + store.selectedDeviceID == "B", "Replace a disappeared selection with a booted device.") + fixture.devices = [] + await store.refresh() + try require( + store.selectedDevice == nil && store.selectedDeviceID == nil, "Clear an empty selection.") + fixture.devices = [device("D"), device("E")] + await store.refresh() + try require(store.selectedDeviceID == "D", "Choose the first device when none is booted.") + } + + static func startingAndSelectionChanges() async throws { + let fixture = Inventory([device("A"), device("B")]) + let probe = BootProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.boot($0) }) + await store.refresh() + let startup = Task { await store.startSelectedDevice() } + try await eventually { await probe.calls == ["A"] } + try require(store.isStartingSelectedDevice, "Expose startup progress for the selected device.") + try require( + await store.startSelectedDevice() == false, "A duplicate start must not report success.") + try require(await probe.calls == ["A"], "Repeated clicks must not start a device twice.") + store.selectDevice("B") + try require(!store.isStartingSelectedDevice, "Startup progress must follow its own device.") + try require( + store.startingDeviceIDs == ["A"], "Changing selection must not lose startup tracking.") + fixture.devices = [device("A", state: "Booted"), device("B")] + await probe.finish() + try require( + await startup.value, "A successful boot confirmed by inventory should report success.") + try require( + store.selectedDeviceID == "B", "Startup completion must preserve a later selection.") + try require( + store.discovery?.devices.first?.state == "Booted", + "Read startup state from a fresh inventory.") + try require(store.startingDeviceIDs.isEmpty, "Clear progress after startup.") + try require(fixture.calls == 2, "Refresh the inventory after startup.") + store.selectDevice("A") + try require( + await store.startSelectedDevice() == false, "An ignored start must not report success.") + try require(await probe.calls == ["A"], "An already booted device needs no boot command.") + } + + static func overlappingRefresh() async throws { + let fixture = Inventory([device("A")]) + let probe = BootProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.boot($0) }) + await store.refresh() + let startup = Task { await store.startSelectedDevice() } + try await eventually { await probe.calls == ["A"] } + fixture.deferNextScan = true + let refresh = Task { await store.refresh() } + try await eventually { fixture.pendingScan != nil } + let coalesced = Task { await store.refresh() } + await Task.yield() + try require(fixture.calls == 2, "Concurrent refreshes should share one scan.") + await probe.finish() + fixture.devices = [device("A", state: "Booted")] + fixture.finishScan(with: [device("A")]) + await refresh.value + await coalesced.value + try require( + await startup.value, "Fresh readiness after an overlapping scan should report success.") + try require( + fixture.calls == 3, "Startup must request a scan newer than any in-flight inventory.") + try require( + store.selectedDevice?.state == "Booted", "An old scan must not hide successful startup.") + try require(!store.isChecking && !store.isStartingSelectedDevice, "All progress must finish.") + } + + static func independentStarts() async throws { + let fixture = Inventory([device("A"), device("B", state: "Booting")]) + let probe = BootProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.boot($0) }) + await store.refresh() + let first = Task { await store.startSelectedDevice() } + try await eventually { await probe.calls == ["A"] } + store.selectDevice("B") + let second = Task { await store.startSelectedDevice() } + try await eventually { await probe.calls == ["A", "B"] } + try require( + store.startingDeviceIDs == ["A", "B"], "Track each simulator's startup separately.") + fixture.devices = [device("A", state: "Booted"), device("B", state: "Booting")] + await probe.finish("A") + try require(await first.value, "Confirm the first simulator's startup independently.") + try require( + store.startingDeviceIDs == ["B"], "One startup must not clear another device's progress.") + try require( + store.isStartingSelectedDevice, "Keep progress for the currently selected simulator.") + fixture.devices = [device("A", state: "Booted"), device("B", state: "Booted")] + await probe.finish("B") + try require(await second.value, "Confirm the second simulator's startup independently.") + try require( + store.selectedDeviceID == "B" && store.startingDeviceIDs.isEmpty, + "Finish both independent startups.") + } + + static func startFailureAndCancellation() async throws { + for cancelled in [false, true] { + let fixture = Inventory([device("A")]) + let store = SimulatorStore(discover: fixture.scan) { _ in + if cancelled { throw CancellationError() } + throw failure("Could not start this simulator") + } + await store.refresh() + try require( + await store.startSelectedDevice() == false, "A failed or cancelled start must return false." + ) + try require( + store.startingDeviceIDs.isEmpty, "A failed or cancelled start must clear progress.") + try require( + store.selectedDevice?.state == "Shutdown", "A failed command cannot imply Booted.") + try require(fixture.calls == 1, "A failed boot should not imply a successful refresh.") + if cancelled { + try require(store.startError == nil, "Keep cancellation silent.") + } else { + try require( + store.startError?.contains("iPhone A") == true + && store.startError?.contains("Could not start this simulator") == true, + "Identify the failed simulator and preserve the boot diagnostic.") + } + } + let fixture = Inventory([device("A", state: "Shutting Down")]) + let store = SimulatorStore(discover: fixture.scan) { _ in + throw failure("Do not start a device while it is shutting down") + } + await store.refresh() + await store.startSelectedDevice() + try require(store.startError == nil, "A shutting-down device must not receive a boot command.") + } + + static func startupRequiresConfirmedReadiness() async throws { + for refreshedDevices in [[device("A")], [device("A", state: "Booting")], []] { + let fixture = Inventory([device("A")]) + let probe = BootProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.boot($0) }) + await store.refresh() + let startup = Task { await store.startSelectedDevice() } + try await eventually { await probe.calls == ["A"] } + fixture.devices = refreshedDevices + await probe.finish() + try require( + await startup.value == false, + "A successful command alone cannot confirm a missing or unready simulator.") + } + } + + static func failedBootAfterBootedPoll() async throws { + let fixture = Inventory([device("A"), device("B")]) + let probe = BootProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.boot($0) }) + await store.refresh() + let startup = Task { await store.startSelectedDevice() } + try await eventually { await probe.calls == ["A"] } + store.selectDevice("B") + fixture.devices = [device("A", state: "Booted"), device("B")] + await store.refresh() + await probe.fail("A", error: failure("Boot readiness failed")) + try require( + await startup.value == false, "Boot failure must override an intermediate Booted snapshot.") + try require( + store.selectedDeviceID == "B", "A startup error must preserve the user's later selection.") + try require( + store.startError?.contains("iPhone A") == true + && store.startError?.contains("Boot readiness failed") == true, + "Attribute the error to the device that was started, even after selection changes.") + } + + static func device(_ id: String, state: String = "Shutdown") -> SimulatorDevice { + SimulatorDevice(id: id, name: "iPhone \(id)", runtime: "27.0", state: state) + } + + static func eventually(_ condition: () async -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(3)) + while !(await condition()) { + guard clock.now < deadline else { throw failure("Timed out waiting for an async operation") } + await Task.yield() + } + } + + static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + nonisolated static func failure(_ message: String) -> NSError { + NSError(domain: "SimulatorStoreTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + + @MainActor + final class Inventory { + var devices: [SimulatorDevice] + var error: Error? + var calls = 0 + var deferNextScan = false + var pendingScan: CheckedContinuation? + + init(_ devices: [SimulatorDevice]) { self.devices = devices } + + func scan() async throws -> SimulatorDiscovery { + calls += 1 + if let error { throw error } + if deferNextScan { + deferNextScan = false + return try await withCheckedThrowingContinuation { pendingScan = $0 } + } + return snapshot(devices) + } + + func finishScan(with devices: [SimulatorDevice]) { + let continuation = pendingScan + pendingScan = nil + continuation?.resume(returning: snapshot(devices)) + } + + private func snapshot(_ devices: [SimulatorDevice]) -> SimulatorDiscovery { + SimulatorDiscovery( + developerDirectory: URL(fileURLWithPath: "/Xcode.app/Contents/Developer"), devices: devices) + } + } + + actor BootProbe { + private(set) var calls: [String] = [] + private var continuations: [String: CheckedContinuation] = [:] + + func boot(_ id: String) async throws { + calls.append(id) + try await withCheckedThrowingContinuation { continuations[id] = $0 } + } + + func finish(_ id: String = "A") { + continuations.removeValue(forKey: id)?.resume() + } + + func fail(_ id: String, error: Error) { + continuations.removeValue(forKey: id)?.resume(throwing: error) + } + } +}