diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index 75fe9fe..1ebbdc0 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -721,6 +721,22 @@ } } }, + "Couldn’t Shut Down Simulator": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t Shut Down Simulator" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法关闭模拟器" + } + } + } + }, "Couldn’t Start Simulator": { "localizations": { "en": { @@ -769,6 +785,22 @@ } } }, + "Couldn’t shut down %@.\n%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t shut down %1$@.\n%2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法关闭 %1$@。\n%2$@" + } + } + } + }, "Couldn’t start %@.\n%@": { "localizations": { "en": { @@ -961,6 +993,22 @@ } } }, + "Finish or cancel app operations before shutting down this simulator.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finish or cancel app operations before shutting down this simulator." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先完成或取消应用操作,再关闭此模拟器。" + } + } + } + }, "Games": { "localizations": { "en": { @@ -1809,6 +1857,22 @@ } } }, + "Shut Down": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shut Down" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关机" + } + } + } + }, "Shutdown": { "localizations": { "en": { @@ -1841,6 +1905,22 @@ } } }, + "Shutting Down…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shutting Down…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在关机…" + } + } + } + }, "SimStore Is Up to Date": { "localizations": { "en": { diff --git a/src/AppInstallationStore.swift b/src/AppInstallationStore.swift index ac5d2d8..76f4137 100644 --- a/src/AppInstallationStore.swift +++ b/src/AppInstallationStore.swift @@ -48,6 +48,11 @@ final class AppInstallationStore { errors[Key(appID: appID, deviceID: deviceID)] } + func hasOperations(deviceID: String) -> Bool { + phases.keys.contains { $0.deviceID == deviceID } + || updateBatches[deviceID]?.isRunning == true + } + func install(app: CatalogApp, version: CatalogVersion, on device: SimulatorDevice) { startInstallation(app: app, version: version, on: device, completion: didInstall) } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index dbf759d..d0f23a7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable(SimStore MACOSX_BUNDLE SimulatorStore.swift SimulatorDevicePresentation.swift SimulatorSelectionViews.swift + SimulatorPowerButton.swift ContentView.swift LibrarySection.swift AppCatalog.swift diff --git a/src/CatalogBrowserView.swift b/src/CatalogBrowserView.swift index 2067168..5e8a8e9 100644 --- a/src/CatalogBrowserView.swift +++ b/src/CatalogBrowserView.swift @@ -7,9 +7,9 @@ struct CatalogBrowserView: View { let installedApps: InstalledAppsStore let installations: AppInstallationStore let device: SimulatorDevice - let isStarting: Bool let deviceManagerName: String? let startSimulator: () -> Void + let shutdownSimulator: () -> Void let openDeviceManager: () -> Void @State private var query = "" @@ -115,8 +115,9 @@ struct CatalogBrowserView: View { } .popover(isPresented: $showsSimulator) { SimulatorDetailView( - device: device, isStarting: isStarting, deviceManagerName: deviceManagerName, - start: startSimulator, openDeviceManager: openDeviceManager + device: device, hasAppOperations: installations.hasOperations(deviceID: device.id), + deviceManagerName: deviceManagerName, + start: startSimulator, shutdown: shutdownSimulator, openDeviceManager: openDeviceManager ) .frame(width: 480) } @@ -144,14 +145,15 @@ struct CatalogBrowserView: View { .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { Text(verbatim: device.name).fontWeight(.medium) - Text(verbatim: "\(device.runtimeLabel) · \(device.stateLabel(isStarting: isStarting))") + Text(verbatim: "\(device.runtimeLabel) · \(device.stateLabel())") .font(.system(size: 11)).foregroundStyle(.secondary) } .lineLimit(1) Spacer(minLength: 12) - if device.canStart || isStarting { - SimulatorStartButton( - isStarting: isStarting || device.state == "Booting", action: startSimulator) + if device.canStart || device.canShutdown || device.isTransitioning { + SimulatorPowerButton( + device: device, hasAppOperations: installations.hasOperations(deviceID: device.id), + start: startSimulator, shutdown: shutdownSimulator) } Button { showsSimulator = true diff --git a/src/ContentView.swift b/src/ContentView.swift index ed8c16d..a8322fd 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -21,6 +21,9 @@ struct ContentView: View { boot: @escaping @Sendable (String) async throws -> Void = { try await SimulatorControl.boot($0) }, + shutdown: @escaping @Sendable (String) async throws -> Void = { + try await SimulatorControl.shutdown($0) + }, catalogClient: CatalogClient = .live, installer: AppInstaller = AppInstaller(), showDevice: (@MainActor (SimulatorDevice) async throws -> Void)? = nil, @@ -40,15 +43,17 @@ struct ContentView: View { } var installer = installer var boot = boot + var shutdown = shutdown var loadInstalledApps = loadInstalledApps if case .success(.some(.incompatible)) = preview { installer.download = { _, _, _ in try PreviewScenario.rejectDeviceAction() } installer.command = { _, _ in try PreviewScenario.rejectDeviceAction() } boot = { _ in try PreviewScenario.rejectDeviceAction() } + shutdown = { _ in try PreviewScenario.rejectDeviceAction() } loadInstalledApps = { _ in [] } } #endif - let simulators = SimulatorStore(discover: discover, boot: boot) + let simulators = SimulatorStore(discover: discover, shutdown: shutdown, boot: boot) _simulators = State(initialValue: simulators) _catalog = State(initialValue: CatalogStore(client: catalogClient)) let installedApps = InstalledAppsStore(load: loadInstalledApps) @@ -103,7 +108,7 @@ struct ContentView: View { discovery: discovery, isChecking: simulators.isChecking, needsXcode: needsXcode, section: $librarySection, updateCount: updateCount, selectedDeviceID: simulators.selectedDeviceID, - startingDeviceIDs: simulators.startingDeviceIDs, selectDevice: simulators.selectDevice, + selectDevice: simulators.selectDevice, openRepository: openRepository ) .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) @@ -181,6 +186,17 @@ struct ContentView: View { } message: { Text(simulators.startError ?? "") } + .alert( + "Couldn’t Shut Down Simulator", + isPresented: Binding( + get: { simulators.shutdownError != nil }, + set: { if !$0 { simulators.shutdownError = nil } } + ) + ) { + Button("OK", role: .cancel) { simulators.shutdownError = nil } + } message: { + Text(simulators.shutdownError ?? "") + } } @ViewBuilder @@ -198,9 +214,9 @@ struct ContentView: View { section: librarySection, store: catalog, installedApps: installedApps, installations: installations, device: device, - isStarting: simulators.isStartingSelectedDevice, deviceManagerName: deviceManager?.name, startSimulator: startSimulator, + shutdownSimulator: shutdownSimulator, openDeviceManager: openDeviceManager ) .id(librarySection) @@ -223,17 +239,25 @@ struct ContentView: View { } private func startSimulator() { - let id = simulators.selectedDeviceID + guard let id = simulators.selectedDeviceID else { return } Task { - let started = await simulators.startSelectedDevice() + let started = await simulators.startDevice(id) if started, let device = simulators.selectedDevice, device.id == id { openDeviceManager() } } } + private func shutdownSimulator() { + guard let id = simulators.selectedDeviceID, !installations.hasOperations(deviceID: id) else { + return + } + simulators.shutdownDevice(id) + } + private var shouldMonitorState: Bool { !simulators.startingDeviceIDs.isEmpty + || !simulators.shuttingDownDeviceIDs.isEmpty || discovery?.devices.contains(where: \.isTransitioning) == true } diff --git a/src/InstalledAppsStatusView.swift b/src/InstalledAppsStatusView.swift index 855caf2..61c7bcf 100644 --- a/src/InstalledAppsStatusView.swift +++ b/src/InstalledAppsStatusView.swift @@ -8,7 +8,12 @@ struct InstalledAppsStatusView: View { var body: some View { Group { - if device.state != "Booted" { + if device.isTransitioning { + VStack(spacing: 14) { + ProgressView() + Text(verbatim: device.stateLabel()).foregroundStyle(.secondary) + } + } else if device.state != "Booted" { ContentUnavailableView { Label("Simulator Not Running", systemImage: "power") } description: { diff --git a/src/SidebarView.swift b/src/SidebarView.swift index 0617447..aa2ef54 100644 --- a/src/SidebarView.swift +++ b/src/SidebarView.swift @@ -9,7 +9,6 @@ struct SidebarView: View { @Binding var section: LibrarySection let updateCount: Int? let selectedDeviceID: String? - let startingDeviceIDs: Set let selectDevice: (String) -> Void let openRepository: () -> Void @@ -53,7 +52,7 @@ struct SidebarView: View { VStack(alignment: .leading, spacing: 4) { Text(device.name).font(.system(size: 12, weight: .medium)) Text( - "\(device.runtimeLabel) · \(device.stateLabel(isStarting: startingDeviceIDs.contains(device.id)))" + "\(device.runtimeLabel) · \(device.stateLabel())" ) .font(.system(size: 10)) .foregroundStyle(.secondary) diff --git a/src/SimulatorControl.swift b/src/SimulatorControl.swift index 48abac6..5b879d8 100644 --- a/src/SimulatorControl.swift +++ b/src/SimulatorControl.swift @@ -11,4 +11,15 @@ enum SimulatorControl { }.value try Task.checkCancellation() } + + static func shutdown( + _ deviceID: String, + command: @escaping Command.Runner = Command.run + ) async throws { + try Task.checkCancellation() + try await Task.detached(priority: .userInitiated) { + _ = try Command.simctl(["shutdown", deviceID], using: command) + }.value + try Task.checkCancellation() + } } diff --git a/src/SimulatorDevicePresentation.swift b/src/SimulatorDevicePresentation.swift index b49c0b8..6cd4cbe 100644 --- a/src/SimulatorDevicePresentation.swift +++ b/src/SimulatorDevicePresentation.swift @@ -19,8 +19,8 @@ extension SimulatorDevice { var runtimeLabel: String { String(localized: "iOS \(runtime)") } - func stateLabel(isStarting: Bool = false) -> String { - switch isStarting ? "Booting" : state { + func stateLabel() -> String { + switch state { case "Booted": String(localized: "Booted") case "Shutdown": String(localized: "Shutdown") case "Booting": String(localized: "Booting") diff --git a/src/SimulatorDiscovery.swift b/src/SimulatorDiscovery.swift index 7bea8ad..5151192 100644 --- a/src/SimulatorDiscovery.swift +++ b/src/SimulatorDiscovery.swift @@ -14,6 +14,8 @@ struct SimulatorDevice: Identifiable, Equatable, Sendable { var canStart: Bool { state == "Shutdown" || state == "Booting" } + var canShutdown: Bool { state == "Booted" } + var isTransitioning: Bool { state == "Booting" || state == "Shutting Down" } } diff --git a/src/SimulatorPowerButton.swift b/src/SimulatorPowerButton.swift new file mode 100644 index 0000000..c40f2f9 --- /dev/null +++ b/src/SimulatorPowerButton.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct SimulatorPowerButton: View { + let device: SimulatorDevice + let hasAppOperations: Bool + let start: () -> Void + let shutdown: () -> Void + + private enum Action: Hashable { + case start, starting, shutdown, shuttingDown + + var title: String { + switch self { + case .start: String(localized: "Start Simulator") + case .starting: String(localized: "Starting…") + case .shutdown: String(localized: "Shut Down") + case .shuttingDown: String(localized: "Shutting Down…") + } + } + } + + private var action: Action { + switch device.state { + case "Booted": .shutdown + case "Booting": .starting + case "Shutting Down": .shuttingDown + default: .start + } + } + + private var states: [Action] { + switch action { + case .start, .starting: [.start, .starting] + case .shutdown, .shuttingDown: [.shutdown, .shuttingDown] + } + } + + private var enabled: Bool { + switch action { + case .start: device.canStart + case .shutdown: device.canShutdown && !hasAppOperations + case .starting, .shuttingDown: false + } + } + + var body: some View { + Group { + if action == .start { + button.buttonStyle(.borderedProminent) + } else { + button.buttonStyle(.bordered) + } + } + .accessibilityIdentifier("simulator-power") + } + + private var button: some View { + Button(action: action == .shutdown ? shutdown : start) { + Label { + StableSizeView(action, states: states) { value in + Text(verbatim: value.title) + } + } icon: { + Image(systemName: "power") + } + } + .disabled(!enabled) + .accessibilityLabel(Text(verbatim: "\(action.title) \(device.name)")) + .help(helpText) + } + + private var helpText: Text { + if action == .shutdown && hasAppOperations { + Text("Finish or cancel app operations before shutting down this simulator.") + } else { + Text(verbatim: action.title) + } + } +} diff --git a/src/SimulatorSelectionViews.swift b/src/SimulatorSelectionViews.swift index 6a0cbbe..c065a75 100644 --- a/src/SimulatorSelectionViews.swift +++ b/src/SimulatorSelectionViews.swift @@ -49,9 +49,10 @@ struct SimulatorPicker: View { @MainActor struct SimulatorDetailView: View { let device: SimulatorDevice - let isStarting: Bool + let hasAppOperations: Bool let deviceManagerName: String? let start: () -> Void + let shutdown: () -> Void let openDeviceManager: () -> Void var body: some View { @@ -74,7 +75,7 @@ struct SimulatorDetailView: View { .fill(device.state == "Booted" ? Color.green : Color.secondary) .frame(width: 6, height: 6) .accessibilityHidden(true) - Text(verbatim: device.stateLabel(isStarting: isStarting)) + Text(verbatim: device.stateLabel()) } .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) @@ -98,11 +99,9 @@ struct SimulatorDetailView: View { .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) + if device.canStart || device.canShutdown || device.isTransitioning { + SimulatorPowerButton( + device: device, hasAppOperations: hasAppOperations, start: start, shutdown: shutdown) } DeviceManagerButton(name: deviceManagerName, action: openDeviceManager) .buttonStyle(.bordered) @@ -113,29 +112,6 @@ struct SimulatorDetailView: View { } } -@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? diff --git a/src/SimulatorStore.swift b/src/SimulatorStore.swift index ad0ceeb..6bec6e2 100644 --- a/src/SimulatorStore.swift +++ b/src/SimulatorStore.swift @@ -8,35 +8,56 @@ final class SimulatorStore { private(set) var isChecking = false private(set) var selectedDeviceID: String? private(set) var startingDeviceIDs: Set = [] + private(set) var shuttingDownDeviceIDs: Set = [] var startError: String? + var shutdownError: String? @ObservationIgnored private let discover: @MainActor () async throws -> SimulatorDiscovery @ObservationIgnored private let boot: @Sendable (String) async throws -> Void @ObservationIgnored + private let shutdown: @Sendable (String) async throws -> Void + @ObservationIgnored private var refreshWaiters: [CheckedContinuation] = [] init( discover: @escaping @MainActor () async throws -> SimulatorDiscovery, + shutdown: @escaping @Sendable (String) async throws -> Void = { + try await SimulatorControl.shutdown($0) + }, boot: @escaping @Sendable (String) async throws -> Void ) { self.discover = discover self.boot = boot + self.shutdown = shutdown } - var discovery: SimulatorDiscovery? { + private var snapshot: SimulatorDiscovery? { guard case .success(let discovery) = result else { return nil } return discovery } - var selectedDevice: SimulatorDevice? { - discovery?.devices.first { $0.id == selectedDeviceID } + var discovery: SimulatorDiscovery? { + guard let snapshot else { return nil } + return SimulatorDiscovery( + developerDirectory: snapshot.developerDirectory, + devices: snapshot.devices.map { device in + let state: String + if shuttingDownDeviceIDs.contains(device.id) { + state = "Shutting Down" + } else if startingDeviceIDs.contains(device.id) { + state = "Booting" + } else { + return device + } + return SimulatorDevice( + id: device.id, name: device.name, runtime: device.runtime, state: state) + }) } - var isStartingSelectedDevice: Bool { - guard let selectedDeviceID else { return false } - return startingDeviceIDs.contains(selectedDeviceID) + var selectedDevice: SimulatorDevice? { + discovery?.devices.first { $0.id == selectedDeviceID } } func selectDevice(_ id: String) { @@ -61,9 +82,9 @@ final class SimulatorStore { try Task.checkCancellation() result = .success(discovery) if !discovery.devices.contains(where: { $0.id == selectedDeviceID }) { + let devices = self.discovery?.devices ?? [] selectedDeviceID = - discovery.devices.first(where: { $0.state == "Booted" })?.id - ?? discovery.devices.first?.id + devices.first(where: { $0.state == "Booted" })?.id ?? devices.first?.id } } catch is CancellationError { } catch { @@ -72,22 +93,18 @@ final class SimulatorStore { } @discardableResult - func startSelectedDevice() async -> Bool { - guard let device = selectedDevice, + func startDevice(_ id: String) async -> Bool { + guard let device = snapshot?.devices.first(where: { $0.id == id }), device.canStart, + !shuttingDownDeviceIDs.contains(device.id), 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" }) + try await refreshAfterControl() + return snapshot?.devices.contains(where: { $0.id == device.id && $0.state == "Booted" }) == true } catch is CancellationError { return false @@ -98,6 +115,39 @@ final class SimulatorStore { } } + @discardableResult + func shutdownDevice(_ id: String) -> Task? { + guard let device = snapshot?.devices.first(where: { $0.id == id }), device.canShutdown, + !startingDeviceIDs.contains(device.id), shuttingDownDeviceIDs.insert(device.id).inserted + else { return nil } + shutdownError = nil + return Task { [self] in + defer { shuttingDownDeviceIDs.remove(device.id) } + do { + try Task.checkCancellation() + try await shutdown(device.id) + try await refreshAfterControl() + return snapshot?.devices.contains(where: { $0.id == device.id && $0.state == "Shutdown" }) + == true + } catch is CancellationError { + return false + } catch { + shutdownError = String( + localized: "Couldn’t shut down \(device.name).\n\(error.localizedDescription)") + return false + } + } + } + + private func refreshAfterControl() async throws { + try Task.checkCancellation() + // A scan begun before the operation completed may contain the previous power state. + if isChecking { await waitForRefresh() } + try Task.checkCancellation() + await refresh() + try Task.checkCancellation() + } + private func waitForRefresh() async { await withCheckedContinuation { continuation in refreshWaiters.append(continuation) diff --git a/tests/AppUpdateBatchTests.swift b/tests/AppUpdateBatchTests.swift index e796aa8..15f23ef 100644 --- a/tests/AppUpdateBatchTests.swift +++ b/tests/AppUpdateBatchTests.swift @@ -49,6 +49,10 @@ enum AppUpdateBatchTests { "Only installed user apps with newer compatible versions may be queued.") store.updateAll(in: catalog, installedApps: inventory, on: device) let firstID = store.updateBatch(deviceID: device.id)?.id + try InstallationFixtures.require( + store.hasOperations(deviceID: device.id) + && !store.hasOperations(deviceID: "another-device"), + "Queued updates must block shutdown only for their own device.") try InstallationFixtures.require( store.phase(appID: "bravo", deviceID: device.id) == .queued, "Reserve queued apps before returning to the UI.") @@ -78,6 +82,9 @@ enum AppUpdateBatchTests { "The next download starts only after the previous installation has finished.") await downloads.finish(1) try await finished(store, device.id) + try InstallationFixtures.require( + !store.hasOperations(deviceID: device.id), + "Completed batch results must not prevent simulator shutdown.") try InstallationFixtures.require( store.updateBatch(deviceID: device.id)?.updated == 2 && store.updateBatch(deviceID: device.id)?.completed == 2, @@ -154,6 +161,9 @@ enum AppUpdateBatchTests { store.replace(app: fixture.apps[1], version: fixture.apps[1].latestVersion, on: device) try await downloads.waitForCount(2) store.cancelAllUpdates(deviceID: device.id) + try InstallationFixtures.require( + store.hasOperations(deviceID: device.id), + "Canceling must keep shutdown blocked until active operations finish cleaning up.") try InstallationFixtures.require( store.phase(appID: "alpha", deviceID: device.id) == .canceling && store.phase(appID: "charlie", deviceID: device.id) == nil, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8a0f309..168f6fc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -108,9 +108,10 @@ target_compile_options(SimStoreInstalledAppsTest PRIVATE -parse-as-library) add_test(NAME installed-apps COMMAND SimStoreInstalledAppsTest) set_tests_properties(installed-apps PROPERTIES TIMEOUT 15) -add_executable(SimStoreStoreTest SimulatorStoreTests.swift +add_executable(SimStoreStoreTest SimulatorStoreTests.swift SimulatorShutdownTests.swift "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorStore.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorControl.swift" "${PROJECT_SOURCE_DIR}/src/Command.swift" ) target_compile_options(SimStoreStoreTest PRIVATE -parse-as-library) @@ -129,6 +130,16 @@ target_compile_options(SimStoreStableSizeTest PRIVATE -parse-as-library) add_test(NAME stable-button-size COMMAND SimStoreStableSizeTest "$") set_tests_properties(stable-button-size PROPERTIES TIMEOUT 15 RUN_SERIAL TRUE) +add_executable(SimStorePowerButtonTest SimulatorPowerButtonTests.swift + "${PROJECT_SOURCE_DIR}/src/SimulatorPowerButton.swift" + "${PROJECT_SOURCE_DIR}/src/StableSizeView.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" + "${PROJECT_SOURCE_DIR}/src/Command.swift" +) +target_compile_options(SimStorePowerButtonTest PRIVATE -parse-as-library) +add_test(NAME simulator-power-button COMMAND SimStorePowerButtonTest) +set_tests_properties(simulator-power-button PROPERTIES TIMEOUT 15 RUN_SERIAL TRUE) + if(CMAKE_BUILD_TYPE STREQUAL "Debug") add_executable(SimStorePreviewScenarioTest PreviewScenarioTests.swift "${PROJECT_SOURCE_DIR}/src/PreviewScenario.swift" diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift index 093a206..919731b 100644 --- a/tests/LocalizationTests.swift +++ b/tests/LocalizationTests.swift @@ -36,6 +36,14 @@ struct LocalizationTests { ("Updates", "Updates", "更新"), ("Start Simulator", "Start Simulator", "启动模拟器"), ("Starting…", "Starting…", "正在启动…"), + ("Shut Down", "Shut Down", "关机"), + ("Shutting Down…", "Shutting Down…", "正在关机…"), + ("Couldn’t Shut Down Simulator", "Couldn’t Shut Down Simulator", "无法关闭模拟器"), + ( + "Finish or cancel app operations before shutting down this simulator.", + "Finish or cancel app operations before shutting down this simulator.", + "请先完成或取消应用操作,再关闭此模拟器。" + ), ("Booted", "Booted", "已启动"), ("Shutdown", "Shutdown", "已关机"), ("Booting", "Booting", "正在启动"), @@ -104,6 +112,10 @@ struct LocalizationTests { 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: "Couldn’t shut down \(deviceName).\n\(detail)", bundle: bundle, locale: locale), + english ? "Couldn’t shut down \(deviceName).\n\(detail)" : "无法关闭 \(deviceName)。\n\(detail)") try expect( String( localized: diff --git a/tests/SimulatorControlTests.swift b/tests/SimulatorControlTests.swift index 954edf0..6072788 100644 --- a/tests/SimulatorControlTests.swift +++ b/tests/SimulatorControlTests.swift @@ -9,8 +9,12 @@ struct SimulatorControlTests { try await commandFailure() try await cancelledBeforeBoot() try await cancelledDuringBoot() + try await shutdownCommand() + try await shutdownFailureAndCancellation() try presentationRequests() - print("PASS: simulator boot command, background execution, command errors, and cancellation.") + print( + "PASS: targeted simulator boot/shutdown, background execution, diagnostics, and cancellation." + ) } catch { FileHandle.standardError.write( Data("Simulator control test failed: \(error.localizedDescription)\n".utf8)) @@ -89,6 +93,54 @@ struct SimulatorControlTests { semaphore.wait(timeout: .now() + 5) == .success } + static func shutdownCommand() async throws { + let id = "0E67E0B9-7C5B-44E6-954C-B2AB9B955E9F" + try await SimulatorControl.shutdown(id) { executable, arguments in + try require(executable == Command.xcrunExecutable, "Use the common xcrun wrapper.") + try require( + arguments == ["simctl", "shutdown", id], "Shut down only the exact requested UDID.") + try require(!Thread.isMainThread, "Shutdown must not block the UI.") + return Data() + } + } + + static func shutdownFailureAndCancellation() async throws { + let expected = failure("Shutdown failed") + do { + try await SimulatorControl.shutdown("DEVICE") { _, _ in throw expected } + throw failure("Shutdown failure must throw.") + } catch { + try require((error as NSError) == expected, "Preserve the shutdown diagnostic.") + } + let canceled = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await SimulatorControl.shutdown("DEVICE") { _, _ in + throw failure("An already canceled shutdown must not invoke simctl.") + } + } + do { + try await canceled.value + throw failure("An already canceled shutdown must throw.") + } catch is CancellationError {} + let started = DispatchSemaphore(value: 0) + let finish = DispatchSemaphore(value: 0) + let task = Task { + try await SimulatorControl.shutdown("DEVICE") { _, _ in + started.signal() + try require(finish.wait(timeout: .now() + 5) == .success, "Release the shutdown command.") + return Data() + } + } + let didStart = await Task.detached { waitForCommand(started) }.value + task.cancel() + finish.signal() + try require(didStart, "Wait for the shutdown command before canceling.") + do { + try await task.value + throw failure("Canceled shutdown must not be reported as successful.") + } catch is CancellationError {} + } + static func presentationRequests() throws { let managerURL = URL( fileURLWithPath: "/Applications/Selected Xcode.app/Contents/Applications/DeviceHub.app") diff --git a/tests/SimulatorPowerButtonTests.swift b/tests/SimulatorPowerButtonTests.swift new file mode 100644 index 0000000..9fdcb98 --- /dev/null +++ b/tests/SimulatorPowerButtonTests.swift @@ -0,0 +1,86 @@ +import AppKit +import SwiftUI + +@main +@MainActor +struct SimulatorPowerButtonTests { + static func main() async { + do { + NSApplication.shared.setActivationPolicy(.regular) + let window = NSWindow( + contentRect: NSRect(x: 200, y: 200, width: 420, height: 100), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + var starts = 0 + var shutdowns = 0 + for size: ControlSize in [.regular, .small] { + var results: [(frame: CGRect, enabled: Bool)] = [] + for (state, busy) in [ + ("Shutdown", false), ("Booting", false), ("Booted", false), + ("Shutting Down", false), ("Booted", true), + ] { + let device = SimulatorDevice(id: "A", name: "iPhone", runtime: "27.0", state: state) + let host = NSHostingView( + rootView: SimulatorPowerButton( + device: device, hasAppOperations: busy, + start: { starts += 1 }, shutdown: { shutdowns += 1 } + ) + .controlSize(size) + .frame(width: 420, height: 100, alignment: .leading)) + window.contentView = host + window.orderFront(nil) + let button = try await nativeButton(in: host) + results.append((host.convert(button.bounds, from: button), button.isEnabled)) + button.performClick(nil) + } + try require( + results.map(\.enabled) == [true, false, true, false, false], + "Only idle devices may start or shut down, and app operations must block shutdown.") + for (idle, pending) in [(0, 1), (2, 3), (2, 4)] { + let a = results[idle].frame + let b = results[pending].frame + try require( + abs(a.minX - b.minX) < 0.5 && abs(a.minY - b.minY) < 0.5 + && abs(a.width - b.width) < 0.5 && abs(a.height - b.height) < 0.5, + "Power actions must keep stable bounds while starting, shutting down, or busy.") + } + } + try require( + starts == 2 && shutdowns == 2, + "Native buttons must invoke the matching action and ignore disabled clicks.") + print("PASS: native simulator power actions, busy gating, and stable transition bounds.") + } catch { + FileHandle.standardError.write( + Data("Simulator power button test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + private static func nativeButton(in view: NSView) async throws -> NSButton { + for _ in 0..<25 { + view.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(20)) + if let button = findButton(in: view) { return button } + } + throw failure("The power action must expose a native button.") + } + + private static func findButton(in view: NSView) -> NSButton? { + if let button = view as? NSButton { return button } + for child in view.subviews { + if let button = findButton(in: child) { return button } + } + return nil + } + + private static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + private static func failure(_ message: String) -> NSError { + NSError( + domain: "SimulatorPowerButtonTests", code: 1, + userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/tests/SimulatorShutdownTests.swift b/tests/SimulatorShutdownTests.swift new file mode 100644 index 0000000..f944ab4 --- /dev/null +++ b/tests/SimulatorShutdownTests.swift @@ -0,0 +1,176 @@ +import Foundation + +extension SimulatorStoreTests { + static func shutdownTests() async throws { + try await shuttingDownAndSelectionChanges() + try await independentShutdowns() + try await shutdownWithOverlappingRefresh() + try await shutdownFailuresAndCancellation() + try await confirmedShutdownAndInvalidStates() + try await mutuallyExclusivePowerOperations() + print( + "PASS: shutdown reservation, state overlays, independent targets, stale scans, failures, and cancellation." + ) + } + + private static func shuttingDownAndSelectionChanges() async throws { + let fixture = Inventory([device("A", state: "Booted"), device("B", state: "Booted")]) + let probe = PowerProbe() + let store = SimulatorStore( + discover: fixture.scan, shutdown: { try await probe.run($0) }, boot: { _ in }) + await store.refresh() + guard let job = store.shutdownDevice("A") else { + throw failure("Accept an idle running device.") + } + try require( + store.shuttingDownDeviceIDs == ["A"] && store.selectedDevice?.state == "Shutting Down", + "Reserve shutdown synchronously and prevent app operations before the first await.") + try require( + store.shutdownDevice("A") == nil, "Repeated clicks cannot issue a duplicate shutdown.") + try require( + await store.startDevice("A") == false, "Do not boot a simulator while shutdown is pending.") + try await eventually { await probe.calls == ["A"] } + await store.refresh() + try require( + store.selectedDevice?.state == "Shutting Down", + "A stale Booted scan cannot re-enable app operations during shutdown.") + store.selectDevice("B") + fixture.devices = [device("A"), device("B", state: "Booted")] + await probe.finish("A") + try require(await job.value, "Confirm shutdown from the fresh inventory.") + try require( + store.selectedDeviceID == "B" && store.shuttingDownDeviceIDs.isEmpty, + "Completion must preserve a later device selection and clear only its own progress.") + try require( + store.discovery?.devices.first?.state == "Shutdown", "Show the confirmed power state.") + try require(store.shutdownDevice("A") == nil, "Do not shut down an already stopped simulator.") + } + + private static func independentShutdowns() async throws { + let fixture = Inventory([device("A", state: "Booted"), device("B", state: "Booted")]) + let probe = PowerProbe() + let store = SimulatorStore( + discover: fixture.scan, shutdown: { try await probe.run($0) }, boot: { _ in }) + await store.refresh() + guard let a = store.shutdownDevice("A"), let b = store.shutdownDevice("B") else { + throw failure("Different devices may shut down independently.") + } + try await eventually { await probe.calls.count == 2 } + try require(store.shuttingDownDeviceIDs == ["A", "B"], "Track each shutdown separately.") + fixture.devices = [device("A"), device("B", state: "Booted")] + await probe.finish("A") + try require(await a.value, "Confirm the first device independently.") + try require( + store.shuttingDownDeviceIDs == ["B"] + && store.discovery?.devices.last?.state == "Shutting Down", + "One completed shutdown cannot clear another device's progress.") + fixture.devices = [device("A"), device("B")] + await probe.finish("B") + try require(await b.value && store.shuttingDownDeviceIDs.isEmpty, "Complete both shutdowns.") + } + + private static func shutdownWithOverlappingRefresh() async throws { + let fixture = Inventory([device("A", state: "Booted")]) + let probe = PowerProbe() + let store = SimulatorStore( + discover: fixture.scan, shutdown: { try await probe.run($0) }, boot: { _ in }) + await store.refresh() + guard let shutdown = store.shutdownDevice("A") else { throw failure("Begin shutdown.") } + try await eventually { await probe.calls == ["A"] } + fixture.deferNextScan = true + let refresh = Task { await store.refresh() } + try await eventually { fixture.pendingScan != nil } + await probe.finish("A") + fixture.devices = [device("A")] + fixture.finishScan(with: [device("A", state: "Booted")]) + await refresh.value + try require(await shutdown.value, "Do not use a scan begun before shutdown completed.") + try require( + fixture.calls == 3 && store.selectedDevice?.state == "Shutdown", + "Read a new inventory after the overlapping scan.") + } + + private static func shutdownFailuresAndCancellation() async throws { + for canceled in [false, true] { + let fixture = Inventory([device("A", state: "Booted")]) + let store = SimulatorStore( + discover: fixture.scan, + shutdown: { _ in + if canceled { throw CancellationError() } + throw failure("Shutdown denied") + }, boot: { _ in }) + await store.refresh() + guard let job = store.shutdownDevice("A") else { throw failure("Begin shutdown.") } + try require( + await job.value == false && store.shuttingDownDeviceIDs.isEmpty, + "Failure and cancellation must not report success or leave progress stuck.") + try require( + store.selectedDevice?.state == "Booted", "A failed command cannot imply Shutdown.") + if canceled { + try require(store.shutdownError == nil, "Keep cancellation silent.") + } else { + try require( + store.shutdownError?.contains("iPhone A") == true + && store.shutdownError?.contains("Shutdown denied") == true, + "Identify the affected simulator and preserve the command's diagnostic.") + } + } + let fixture = Inventory([device("A", state: "Booted")]) + let probe = PowerProbe() + let store = SimulatorStore( + discover: fixture.scan, shutdown: { try await probe.run($0) }, boot: { _ in }) + await store.refresh() + guard let job = store.shutdownDevice("A") else { throw failure("Begin shutdown.") } + job.cancel() + try require( + await job.value == false && store.shutdownError == nil, "Cancel before invoking a command.") + try require(await probe.calls.isEmpty, "An already canceled request cannot invoke shutdown.") + } + + private static func confirmedShutdownAndInvalidStates() async throws { + for state in ["Shutdown", "Booting", "Shutting Down", "Unknown"] { + let fixture = Inventory([device("A", state: state)]) + let store = SimulatorStore( + discover: fixture.scan, shutdown: { _ in throw failure("Unexpected shutdown") }, + boot: { _ in }) + await store.refresh() + try require( + store.shutdownDevice("A") == nil && store.shutdownDevice("missing") == nil, + "Only a known, fully booted device can shut down.") + } + for refreshed in [[device("A", state: "Booted")], [device("A", state: "Shutting Down")], []] { + let fixture = Inventory([device("A", state: "Booted")]) + let probe = PowerProbe() + let store = SimulatorStore( + discover: fixture.scan, shutdown: { try await probe.run($0) }, boot: { _ in }) + await store.refresh() + guard let job = store.shutdownDevice("A") else { throw failure("Begin shutdown.") } + try await eventually { await probe.calls == ["A"] } + fixture.devices = refreshed + await probe.finish("A") + try require( + await job.value == false, + "Command success alone does not confirm a stopped or missing simulator.") + } + } + + private static func mutuallyExclusivePowerOperations() async throws { + let fixture = Inventory([device("A")]) + let probe = PowerProbe() + let store = SimulatorStore( + discover: fixture.scan, shutdown: { _ in throw failure("Do not interrupt startup") }, + boot: { try await probe.run($0) }) + await store.refresh() + let boot = Task { await store.startDevice("A") } + try await eventually { await probe.calls == ["A"] } + fixture.devices = [device("A", state: "Booted")] + await store.refresh() + try require( + store.selectedDevice?.state == "Booting" && store.shutdownDevice("A") == nil, + "A Booted snapshot cannot allow shutdown or app installs before boot readiness finishes.") + await probe.finish("A") + try require( + await boot.value && store.selectedDevice?.state == "Booted", + "Expose readiness after bootstatus completes.") + } +} diff --git a/tests/SimulatorStoreTests.swift b/tests/SimulatorStoreTests.swift index 4573079..4b1d9a0 100644 --- a/tests/SimulatorStoreTests.swift +++ b/tests/SimulatorStoreTests.swift @@ -12,6 +12,7 @@ struct SimulatorStoreTests { try await startFailureAndCancellation() try await startupRequiresConfirmedReadiness() try await failedBootAfterBootedPoll() + try await shutdownTests() print("PASS: simulator selection, refresh, concurrent startup, errors, and cancellation.") } catch { FileHandle.standardError.write( @@ -49,17 +50,19 @@ struct SimulatorStoreTests { 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) }) + let probe = PowerProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.run($0) }) await store.refresh() - let startup = Task { await store.startSelectedDevice() } + let startup = Task { await store.startDevice("A") } 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.") + store.selectedDevice?.state == "Booting", "Expose startup progress for the selected device.") + try require( + await store.startDevice("A") == 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.selectedDevice?.state != "Booting", "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")] @@ -75,16 +78,16 @@ struct SimulatorStoreTests { 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.") + await store.startDevice("A") == 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) }) + let probe = PowerProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.run($0) }) await store.refresh() - let startup = Task { await store.startSelectedDevice() } + let startup = Task { await store.startDevice("A") } try await eventually { await probe.calls == ["A"] } fixture.deferNextScan = true let refresh = Task { await store.refresh() } @@ -103,18 +106,19 @@ struct SimulatorStoreTests { 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.") + try require( + !store.isChecking && store.selectedDevice?.state != "Booting", "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) }) + let probe = PowerProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.run($0) }) await store.refresh() - let first = Task { await store.startSelectedDevice() } + let first = Task { await store.startDevice("A") } try await eventually { await probe.calls == ["A"] } store.selectDevice("B") - let second = Task { await store.startSelectedDevice() } + let second = Task { await store.startDevice("B") } try await eventually { await probe.calls == ["A", "B"] } try require( store.startingDeviceIDs == ["A", "B"], "Track each simulator's startup separately.") @@ -124,7 +128,8 @@ struct SimulatorStoreTests { 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.") + store.selectedDevice?.state == "Booting", + "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.") @@ -142,7 +147,7 @@ struct SimulatorStoreTests { } await store.refresh() try require( - await store.startSelectedDevice() == false, "A failed or cancelled start must return false." + await store.startDevice("A") == false, "A failed or cancelled start must return false." ) try require( store.startingDeviceIDs.isEmpty, "A failed or cancelled start must clear progress.") @@ -163,17 +168,17 @@ struct SimulatorStoreTests { throw failure("Do not start a device while it is shutting down") } await store.refresh() - await store.startSelectedDevice() + await store.startDevice("A") 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) }) + let probe = PowerProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.run($0) }) await store.refresh() - let startup = Task { await store.startSelectedDevice() } + let startup = Task { await store.startDevice("A") } try await eventually { await probe.calls == ["A"] } fixture.devices = refreshedDevices await probe.finish() @@ -185,10 +190,10 @@ struct SimulatorStoreTests { 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) }) + let probe = PowerProbe() + let store = SimulatorStore(discover: fixture.scan, boot: { try await probe.run($0) }) await store.refresh() - let startup = Task { await store.startSelectedDevice() } + let startup = Task { await store.startDevice("A") } try await eventually { await probe.calls == ["A"] } store.selectDevice("B") fixture.devices = [device("A", state: "Booted"), device("B")] @@ -257,11 +262,11 @@ struct SimulatorStoreTests { } } - actor BootProbe { + actor PowerProbe { private(set) var calls: [String] = [] private var continuations: [String: CheckedContinuation] = [:] - func boot(_ id: String) async throws { + func run(_ id: String) async throws { calls.append(id) try await withCheckedThrowingContinuation { continuations[id] = $0 } }