From 73270dff900cb8fd15f418f50d39011e6d8e12ed Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 22:53:39 -0500 Subject: [PATCH 1/3] Offer simulator shutdown when app operations stall --- assets/Localizable.xcstrings | 48 +++++ src/AppInstallationStore.swift | 17 +- src/CMakeLists.txt | 2 + src/ContentView.swift | 20 +- src/InstalledAppsStore.swift | 7 +- src/SimulatorOperationMonitor.swift | 69 +++++++ src/SimulatorRecoveryAlert.swift | 105 ++++++++++ tests/AppInstallationTests.swift | 52 ++++- tests/CMakeLists.txt | 12 ++ tests/InstalledAppsStoreTests.swift | 17 ++ tests/LocalizationTests.swift | 13 ++ tests/SimulatorOperationMonitorTests.swift | 224 +++++++++++++++++++++ 12 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 src/SimulatorOperationMonitor.swift create mode 100644 src/SimulatorRecoveryAlert.swift create mode 100644 tests/SimulatorOperationMonitorTests.swift diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index 3c25cdc..d1ac3c1 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -209,6 +209,22 @@ } } }, + "An app operation on %@ hasn’t finished. Keep waiting, or shut down this simulator to stop pending operations. Shutting down closes all of its apps.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An app operation on %@ hasn’t finished. Keep waiting, or shut down this simulator to stop pending operations. Shutting down closes all of its apps." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 上的应用操作尚未完成。你可以继续等待,或关闭此模拟器以停止待处理的操作。关机会关闭其中的所有应用。" + } + } + } + }, "App Repository": { "localizations": { "en": { @@ -1201,6 +1217,22 @@ } } }, + "Keep Waiting": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Waiting" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续等待" + } + } + } + }, "License": { "localizations": { "en": { @@ -1617,6 +1649,22 @@ } } }, + "Operation Is Taking Longer Than Expected": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Operation Is Taking Longer Than Expected" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "操作耗时较长" + } + } + } + }, "Preparing App…": { "localizations": { "en": { diff --git a/src/AppInstallationStore.swift b/src/AppInstallationStore.swift index bda1dab..6bff922 100644 --- a/src/AppInstallationStore.swift +++ b/src/AppInstallationStore.swift @@ -15,6 +15,8 @@ final class AppInstallationStore { private(set) var openedApp: Key? private(set) var uninstalledApp: Key? @ObservationIgnored private let installer: AppInstaller + @ObservationIgnored private let operationMonitor: SimulatorOperationMonitor? + @ObservationIgnored private var monitoredOperations: [Key: UUID] = [:] @ObservationIgnored private let didInstall: @MainActor (SimulatorApplication, String) -> Void @ObservationIgnored private let didReplace: @MainActor (SimulatorApplication, String) -> Void @ObservationIgnored private let didUninstall: @MainActor (String, String) -> Void @@ -30,7 +32,8 @@ final class AppInstallationStore { didInstall: @escaping @MainActor (SimulatorApplication, String) -> Void = { _, _ in }, didReplace: @escaping @MainActor (SimulatorApplication, String) -> Void = { _, _ in }, didUninstall: @escaping @MainActor (String, String) -> Void = { _, _ in }, - didOpen: @escaping @MainActor (String, String) -> Void = { _, _ in } + didOpen: @escaping @MainActor (String, String) -> Void = { _, _ in }, + operationMonitor: SimulatorOperationMonitor? = nil ) { self.installer = installer self.didInstall = didInstall @@ -38,6 +41,7 @@ final class AppInstallationStore { self.didUninstall = didUninstall self.showDevice = showDevice self.didOpen = didOpen + self.operationMonitor = operationMonitor } func phase(appID: String, deviceID: String) -> AppInstallationPhase? { @@ -87,6 +91,7 @@ final class AppInstallationStore { return } else { phases[key] = phase + if phase == .installing { monitor(key, on: device) } } } completion( @@ -113,6 +118,7 @@ final class AppInstallationStore { uninstalledApp = nil errors[key] = nil phases[key] = .uninstalling + monitor(key, on: device) jobs[key] = Task { defer { finish(key) } do { @@ -150,6 +156,7 @@ final class AppInstallationStore { openedApp = nil errors[key] = nil phases[key] = .opening + monitor(key, on: device) jobs[key] = Task { defer { finish(key) } do { @@ -164,11 +171,19 @@ final class AppInstallationStore { } private func finish(_ key: Key) { + if let operation = monitoredOperations.removeValue(forKey: key) { + operationMonitor?.end(operation) + } jobs[key] = nil requests[key] = nil phases[key] = nil } + private func monitor(_ key: Key, on device: SimulatorDevice) { + guard monitoredOperations[key] == nil else { return } + monitoredOperations[key] = operationMonitor?.begin(on: device) + } + func updateBatch(deviceID: String) -> AppUpdateBatch? { updateBatches[deviceID] } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d0f23a7..a271082 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -13,6 +13,8 @@ add_executable(SimStore MACOSX_BUNDLE SimulatorDevicePresentation.swift SimulatorSelectionViews.swift SimulatorPowerButton.swift + SimulatorOperationMonitor.swift + SimulatorRecoveryAlert.swift ContentView.swift LibrarySection.swift AppCatalog.swift diff --git a/src/ContentView.swift b/src/ContentView.swift index 14d8de0..b1e780c 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -7,6 +7,7 @@ struct ContentView: View { @State private var catalog: CatalogStore @State private var installedApps: InstalledAppsStore @State private var installations: AppInstallationStore + @State private var operationMonitor: SimulatorOperationMonitor @State private var launchError: String? @State private var librarySection: LibrarySection = .discover @@ -54,9 +55,12 @@ struct ContentView: View { } #endif let simulators = SimulatorStore(discover: discover, shutdown: shutdown, boot: boot) + let operationMonitor = SimulatorOperationMonitor() + _operationMonitor = State(initialValue: operationMonitor) _simulators = State(initialValue: simulators) _catalog = State(initialValue: CatalogStore(client: catalogClient)) - let installedApps = InstalledAppsStore(load: loadInstalledApps) + let installedApps = InstalledAppsStore( + load: loadInstalledApps, operationMonitor: operationMonitor) _installedApps = State(initialValue: installedApps) _installations = State( initialValue: AppInstallationStore( @@ -80,7 +84,7 @@ struct ContentView: View { }, didOpen: { bundleID, deviceID in installedApps.removeRecentInstallation(bundleID: bundleID, deviceID: deviceID) - })) + }, operationMonitor: operationMonitor)) } private var needsXcode: Bool { @@ -151,6 +155,9 @@ struct ContentView: View { .frame(minWidth: 900, minHeight: 600) .task { await simulators.refresh() } .onChange(of: librarySection) { _, _ in installedApps.clearRecentInstallations() } + .onChange(of: discovery?.devices) { _, devices in + operationMonitor.synchronize(with: devices ?? []) + } .task(id: shouldMonitorState) { while shouldMonitorState && !Task.isCancelled { do { @@ -197,6 +204,7 @@ struct ContentView: View { } message: { Text(simulators.shutdownError ?? "") } + .modifier(SimulatorRecoveryAlert(monitor: operationMonitor, shutdown: shutdownSimulator)) } @ViewBuilder @@ -249,7 +257,13 @@ struct ContentView: View { } private func shutdownSimulator() { - guard let id = simulators.selectedDeviceID, simulators.shutdownDevice(id) != nil else { return } + guard let id = simulators.selectedDeviceID else { return } + shutdownSimulator(id) + } + + private func shutdownSimulator(_ id: String) { + guard simulators.shutdownDevice(id) != nil else { return } + operationMonitor.stop(deviceID: id) installations.cancelOperations(deviceID: id) } diff --git a/src/InstalledAppsStore.swift b/src/InstalledAppsStore.swift index b2b9944..9076d67 100644 --- a/src/InstalledAppsStore.swift +++ b/src/InstalledAppsStore.swift @@ -15,6 +15,8 @@ final class InstalledAppsStore { @ObservationIgnored private let load: @Sendable (SimulatorDevice) async throws -> [SimulatorApplication] @ObservationIgnored + private let operationMonitor: SimulatorOperationMonitor? + @ObservationIgnored private var selectedDevice: SimulatorDevice? @ObservationIgnored private var request: Request? @@ -26,9 +28,10 @@ final class InstalledAppsStore { init( load: @escaping @Sendable (SimulatorDevice) async throws -> [SimulatorApplication] = { try await SimulatorApplications.installedApps(on: $0) - } + }, operationMonitor: SimulatorOperationMonitor? = nil ) { self.load = load + self.operationMonitor = operationMonitor } func installedApps(in catalog: AppCatalog, for deviceID: String) -> [CatalogApp]? { @@ -136,7 +139,9 @@ final class InstalledAppsStore { applications = nil error = nil isLoading = true + let operation = operationMonitor?.begin(on: device) defer { + if let operation { operationMonitor?.end(operation) } if request === pending { request = nil isLoading = false diff --git a/src/SimulatorOperationMonitor.swift b/src/SimulatorOperationMonitor.swift new file mode 100644 index 0000000..f456486 --- /dev/null +++ b/src/SimulatorOperationMonitor.swift @@ -0,0 +1,69 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class SimulatorOperationMonitor { + struct Prompt: Identifiable, Equatable { + let id: UUID + let device: SimulatorDevice + } + + typealias Wait = @Sendable () async throws -> Void + + private var stalled: [Prompt] = [] + var prompt: Prompt? { stalled.first } + + @ObservationIgnored private let wait: Wait + @ObservationIgnored private var active: + [UUID: (device: SimulatorDevice, task: Task)] = [:] + @ObservationIgnored private var mutedDevices: Set = [] + + init(wait: @escaping Wait = { try await Task.sleep(for: .seconds(30)) }) { + self.wait = wait + } + + func begin(on device: SimulatorDevice) -> UUID { + let id = UUID() + guard device.state == "Booted" else { return id } + let wait = wait + let task = Task { [weak self] in + do { + try await wait() + try Task.checkCancellation() + } catch { return } + guard let self, active[id] != nil, !mutedDevices.contains(device.id) else { return } + stalled.append(Prompt(id: id, device: device)) + } + active[id] = (device, task) + return id + } + + func end(_ id: UUID) { + guard let operation = active.removeValue(forKey: id) else { return } + operation.task.cancel() + stalled.removeAll { $0.id == id } + if !active.values.contains(where: { $0.device.id == operation.device.id }) { + mutedDevices.remove(operation.device.id) + } + } + + func keepWaiting(_ prompt: Prompt) { + guard stalled.contains(where: { $0.id == prompt.id }) else { return } + mutedDevices.insert(prompt.device.id) + stalled.removeAll { $0.device.id == prompt.device.id } + } + + func stop(deviceID: String) { + for id in active.keys.filter({ active[$0]?.device.id == deviceID }) { + end(id) + } + } + + func synchronize(with devices: [SimulatorDevice]) { + let booted = Set(devices.filter { $0.state == "Booted" }.map(\.id)) + for deviceID in Set(active.values.map { $0.device.id }).subtracting(booted) { + stop(deviceID: deviceID) + } + } +} diff --git a/src/SimulatorRecoveryAlert.swift b/src/SimulatorRecoveryAlert.swift new file mode 100644 index 0000000..4d1251d --- /dev/null +++ b/src/SimulatorRecoveryAlert.swift @@ -0,0 +1,105 @@ +import AppKit +import SwiftUI + +struct SimulatorRecoveryAlert: ViewModifier { + let monitor: SimulatorOperationMonitor + let shutdown: (String) -> Void + + func body(content: Content) -> some View { + content.background { + RecoveryPresenter(monitor: monitor, prompt: monitor.prompt, shutdown: shutdown) + .frame(width: 0, height: 0) + } + } +} + +private struct RecoveryPresenter: NSViewRepresentable { + let monitor: SimulatorOperationMonitor + let prompt: SimulatorOperationMonitor.Prompt? + let shutdown: (String) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(monitor: monitor) } + + func makeNSView(context: Context) -> AnchorView { + let view = AnchorView() + view.windowChanged = { [weak coordinator = context.coordinator] window in + coordinator?.updateWindow(window) + } + return view + } + + func updateNSView(_ view: AnchorView, context: Context) { + context.coordinator.update(prompt: prompt, shutdown: shutdown, window: view.window) + } + + static func dismantleNSView(_ view: AnchorView, coordinator: Coordinator) { + view.windowChanged = nil + coordinator.dismiss() + } + + final class AnchorView: NSView { + var windowChanged: ((NSWindow?) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + windowChanged?(window) + } + } + + @MainActor + final class Coordinator { + private let monitor: SimulatorOperationMonitor + private var prompt: SimulatorOperationMonitor.Prompt? + private var shutdown: (String) -> Void = { _ in } + private weak var window: NSWindow? + private var alert: NSAlert? + private var presentedID: UUID? + + init(monitor: SimulatorOperationMonitor) { self.monitor = monitor } + + func update( + prompt: SimulatorOperationMonitor.Prompt?, shutdown: @escaping (String) -> Void, + window: NSWindow? + ) { + self.prompt = prompt + self.shutdown = shutdown + updateWindow(window) + } + + func updateWindow(_ window: NSWindow?) { + if self.window !== window || presentedID != prompt?.id { dismiss() } + self.window = window + guard alert == nil, let window, let prompt else { return } + let alert = NSAlert() + alert.messageText = String(localized: "Operation Is Taking Longer Than Expected") + alert.informativeText = String( + localized: + "An app operation on \(prompt.device.name) hasn’t finished. Keep waiting, or shut down this simulator to stop pending operations. Shutting down closes all of its apps." + ) + alert.addButton(withTitle: String(localized: "Keep Waiting")).keyEquivalent = "\u{1b}" + alert.addButton(withTitle: String(localized: "Shut Down")).hasDestructiveAction = true + // Direct sheet presentation bypasses NSAlert's usual pre-display layout. + alert.layout() + self.alert = alert + presentedID = prompt.id + // Recovery must remain available above the app details sheet. + window.beginCriticalSheet(alert.window) { [weak self, weak alert] response in + guard let self, let alert, self.alert === alert else { return } + self.alert = nil + presentedID = nil + monitor.keepWaiting(prompt) + if response == .alertSecondButtonReturn { shutdown(prompt.device.id) } + } + } + + func dismiss() { + let previous = alert + alert = nil + presentedID = nil + if let previous { + previous.window.sheetParent?.endSheet(previous.window, returnCode: .abort) + previous.window.orderOut(nil) + } + } + } +} diff --git a/tests/AppInstallationTests.swift b/tests/AppInstallationTests.swift index ce6c325..799b8c4 100644 --- a/tests/AppInstallationTests.swift +++ b/tests/AppInstallationTests.swift @@ -16,6 +16,7 @@ struct AppInstallationTests { try await stoppedDevice() try await uninstallJobs() try await uninstallFailure() + try await recoveryPrompts() try versionActions() try await replacementPipeline() try await replacementWhileSwitchingDevices() @@ -376,6 +377,48 @@ struct AppInstallationTests { } } + static func recoveryPrompts() async throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try archiveData(in: directory) + let version = InstallationFixtures.version(data: data) + let app = InstallationFixtures.app(version: version) + let device = InstallationFixtures.device + for action in ["install", "launch", "uninstall"] { + let monitor = SimulatorOperationMonitor(wait: {}) + let gate = InstallationDownloadGate(data: data) + let commands = InstallationCommands( + blocksInstallation: action == "install", blocksLaunch: action == "launch", + blocksUninstallation: action == "uninstall") + defer { commands.finish.signal() } + let store = AppInstallationStore( + installer: AppInstaller( + download: { try await gate.download($0, $1, $2) }, command: commands.run, + temporaryDirectory: directory.appending(component: "work")), + showDevice: { _ in }, operationMonitor: monitor) + switch action { + case "install": + store.install(app: app, version: version, on: device) + try await gate.waitForCount(1) + try InstallationFixtures.require( + monitor.prompt == nil, "A download must not suggest shutting down a healthy simulator.") + await gate.finish(0) + case "launch": store.open(app: app, on: device) + default: store.uninstall(app: app, on: device) + } + try InstallationFixtures.require( + await Task.detached { commands.waitForStart() }.value, "Wait for the simulator command.") + try await InstallationFixtures.eventually { monitor.prompt?.device.id == device.id } + commands.finish.signal() + try await InstallationFixtures.eventually { + store.phase(appID: app.id, deviceID: device.id) == nil + } + try InstallationFixtures.require( + monitor.prompt == nil && store.error(appID: app.id, deviceID: device.id) == nil, + "A completed simulator command must clear its recovery prompt and finish normally.") + } + } + static func versionActions() throws { let version = InstallationFixtures.version() let cases: [(String?, AppInstallationAction)] = [ @@ -498,18 +541,20 @@ private final class InstallationCommands: @unchecked Sendable { private let blocksInstallation: Bool private let failsInstallation: Bool private let failsLaunch: Bool + private let blocksLaunch: Bool private let blocksUninstallation: Bool private let failsUninstallation: Bool init( blocksExtraction: Bool = false, blocksInstallation: Bool = false, - failsInstallation: Bool = false, failsLaunch: Bool = false, + failsInstallation: Bool = false, failsLaunch: Bool = false, blocksLaunch: Bool = false, blocksUninstallation: Bool = false, failsUninstallation: Bool = false ) { self.blocksExtraction = blocksExtraction self.blocksInstallation = blocksInstallation self.failsInstallation = failsInstallation self.failsLaunch = failsLaunch + self.blocksLaunch = blocksLaunch self.blocksUninstallation = blocksUninstallation self.failsUninstallation = failsUninstallation } @@ -536,7 +581,10 @@ private final class InstallationCommands: @unchecked Sendable { if blocksInstallation { try block() } if failsInstallation { throw InstallationFixtures.failure("Install failed") } } - if arguments[1] == "launch", failsLaunch { throw InstallationFixtures.failure("Launch failed") } + if arguments[1] == "launch" { + if blocksLaunch { try block() } + if failsLaunch { throw InstallationFixtures.failure("Launch failed") } + } if arguments[1] == "listapps" { return Data( "{ \"org.example.App\" = { ApplicationType = User; CFBundleShortVersionString = \"0.9.0\"; }; }" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d0a3b53..7d552d2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -61,6 +61,7 @@ add_executable(SimStoreInstallationTest AppInstallationTests.swift AppDownloadTe "${PROJECT_SOURCE_DIR}/src/AppInstaller.swift" "${PROJECT_SOURCE_DIR}/src/AppInstallationPhase.swift" "${PROJECT_SOURCE_DIR}/src/AppInstallationStore.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorOperationMonitor.swift" "${PROJECT_SOURCE_DIR}/src/AppUpdateBatch.swift" "${PROJECT_SOURCE_DIR}/src/AppInstallationAction.swift" "${PROJECT_SOURCE_DIR}/src/InstalledAppsStore.swift" @@ -100,6 +101,7 @@ add_test(NAME simulator-applications COMMAND SimStoreApplicationsTest) set_tests_properties(simulator-applications PROPERTIES TIMEOUT 15) add_executable(SimStoreInstalledAppsTest InstalledAppsStoreTests.swift + "${PROJECT_SOURCE_DIR}/src/SimulatorOperationMonitor.swift" "${PROJECT_SOURCE_DIR}/src/InstalledAppsStore.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorApplications.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" @@ -110,6 +112,16 @@ 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(SimStoreOperationMonitorTest SimulatorOperationMonitorTests.swift + "${PROJECT_SOURCE_DIR}/src/SimulatorOperationMonitor.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorRecoveryAlert.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" + "${PROJECT_SOURCE_DIR}/src/Command.swift" +) +target_compile_options(SimStoreOperationMonitorTest PRIVATE -parse-as-library) +add_test(NAME simulator-operation-monitor COMMAND SimStoreOperationMonitorTest) +set_tests_properties(simulator-operation-monitor PROPERTIES TIMEOUT 20 RUN_SERIAL TRUE) + add_executable(SimStoreStoreTest SimulatorStoreTests.swift SimulatorShutdownTests.swift "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorStore.swift" diff --git a/tests/InstalledAppsStoreTests.swift b/tests/InstalledAppsStoreTests.swift index 14345ed..29a6d20 100644 --- a/tests/InstalledAppsStoreTests.swift +++ b/tests/InstalledAppsStoreTests.swift @@ -15,6 +15,7 @@ struct InstalledAppsStoreTests { try await installedVersionsAndMatching() try await recordingUninstallationDuringRefresh() try await updateFilteringAndRetention() + try await recoveryDuringRefresh() print( "PASS: per-device installed apps, filtering, stale responses, errors, and cancellation.") } catch { @@ -24,6 +25,22 @@ struct InstalledAppsStoreTests { } } + static func recoveryDuringRefresh() async throws { + let probe = InventoryProbe() + let monitor = SimulatorOperationMonitor(wait: {}) + let store = InstalledAppsStore( + load: { try await probe.load($0) }, operationMonitor: monitor) + let selected = device("A") + let refresh = Task { await store.refresh(for: selected) } + try await eventually { await probe.calls.count == 1 } + try await eventually { monitor.prompt?.device.id == selected.id } + await probe.finish(0, with: .success(["io.example.Installed"])) + await refresh.value + try require( + monitor.prompt == nil && !store.isLoading && store.bundleIDs == ["io.example.Installed"], + "A recovered app-list query must clear its prompt and publish the real inventory.") + } + static func filteringAndRefresh() async throws { let probe = InventoryProbe() let store = InstalledAppsStore(load: { try await probe.load($0) }) diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift index c2e3242..00dafbb 100644 --- a/tests/LocalizationTests.swift +++ b/tests/LocalizationTests.swift @@ -38,6 +38,11 @@ struct LocalizationTests { ("Starting…", "Starting…", "正在启动…"), ("Shut Down", "Shut Down", "关机"), ("Shutting Down…", "Shutting Down…", "正在关机…"), + ("Keep Waiting", "Keep Waiting", "继续等待"), + ( + "Operation Is Taking Longer Than Expected", "Operation Is Taking Longer Than Expected", + "操作耗时较长" + ), ("Couldn’t Shut Down Simulator", "Couldn’t Shut Down Simulator", "无法关闭模拟器"), ("Booted", "Booted", "已启动"), ("Shutdown", "Shutdown", "已关机"), @@ -104,6 +109,14 @@ struct LocalizationTests { 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: + "An app operation on \(deviceName) hasn’t finished. Keep waiting, or shut down this simulator to stop pending operations. Shutting down closes all of its apps.", + bundle: bundle, locale: locale), + english + ? "An app operation on iPhone 测试 hasn’t finished. Keep waiting, or shut down this simulator to stop pending operations. Shutting down closes all of its apps." + : "iPhone 测试 上的应用操作尚未完成。你可以继续等待,或关闭此模拟器以停止待处理的操作。关机会关闭其中的所有应用。") try expect( String( localized: "Couldn’t start \(deviceName).\n\(detail)", bundle: bundle, locale: locale), diff --git a/tests/SimulatorOperationMonitorTests.swift b/tests/SimulatorOperationMonitorTests.swift new file mode 100644 index 0000000..ee94eba --- /dev/null +++ b/tests/SimulatorOperationMonitorTests.swift @@ -0,0 +1,224 @@ +import AppKit +import SwiftUI + +@main +@MainActor +struct SimulatorOperationMonitorTests { + static func main() async { + do { + try await finishedAndReplacedOperations() + try await keepWaitingAndSeparateDevices() + try await powerStateChanges() + try await nativeAlertActions() + print( + "PASS: stalled operations, recovery prompts, waiting, completion, and exact shutdown targets." + ) + } catch { + FileHandle.standardError.write(Data("Operation monitor test failed: \(error)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + private static func device(_ id: String, state: String = "Booted") -> SimulatorDevice { + SimulatorDevice(id: id, name: "iPhone \(id)", runtime: "27.0", state: state) + } + + private static func finishedAndReplacedOperations() async throws { + let delay = MonitorDelay() + let monitor = SimulatorOperationMonitor(wait: { try await delay.wait() }) + let first = monitor.begin(on: device("A")) + try await eventually { await delay.count == 1 } + try require(monitor.prompt == nil, "Do not prompt before the timeout.") + monitor.end(first) + let second = monitor.begin(on: device("A")) + try await eventually { await delay.count == 2 } + await delay.release(0) + await delay.release(1) + try await eventually { monitor.prompt?.id == second } + monitor.end(first) + try require(monitor.prompt?.id == second, "A late completion cannot dismiss a newer operation.") + monitor.end(second) + try require(monitor.prompt == nil, "Completing an operation must dismiss its prompt.") + } + + private static func keepWaitingAndSeparateDevices() async throws { + let delay = MonitorDelay() + let monitor = SimulatorOperationMonitor(wait: { try await delay.wait() }) + let first = monitor.begin(on: device("A")) + try await eventually { await delay.count == 1 } + let repeated = monitor.begin(on: device("A")) + try await eventually { await delay.count == 2 } + let other = monitor.begin(on: device("B")) + try await eventually { await delay.count == 3 } + await delay.release(0) + try await eventually { monitor.prompt?.id == first } + let dismissed = monitor.prompt! + monitor.keepWaiting(dismissed) + await delay.release(1) + await delay.release(2) + try await eventually { monitor.prompt?.id == other } + monitor.keepWaiting(dismissed) + try require(monitor.prompt?.id == other, "A stale dismissal cannot mute another simulator.") + monitor.end(first) + monitor.end(repeated) + monitor.end(other) + let retry = monitor.begin(on: device("A")) + try await eventually { await delay.count == 4 } + await delay.release(3) + try await eventually { monitor.prompt?.id == retry } + monitor.end(retry) + } + + private static func powerStateChanges() async throws { + let delay = MonitorDelay() + let monitor = SimulatorOperationMonitor(wait: { try await delay.wait() }) + let first = monitor.begin(on: device("A")) + try await eventually { await delay.count == 1 } + let other = monitor.begin(on: device("B")) + try await eventually { await delay.count == 2 } + monitor.synchronize(with: [device("A", state: "Shutting Down"), device("B")]) + await delay.release(0) + await delay.release(1) + try await eventually { monitor.prompt?.id == other } + monitor.end(first) + monitor.synchronize(with: []) + try require(monitor.prompt == nil, "A stopped or removed device must not retain a prompt.") + let stopped = monitor.begin(on: device("C", state: "Shutdown")) + monitor.end(stopped) + try require(monitor.prompt == nil, "Do not monitor commands on a stopped simulator.") + } + + private static func nativeAlertActions() async throws { + NSApplication.shared.setActivationPolicy(.regular) + let monitor = SimulatorOperationMonitor(wait: {}) + var shutdowns: [String] = [] + let host = NSHostingView( + rootView: Text("Simulator recovery test") + .frame(width: 600, height: 240) + .modifier(SimulatorRecoveryAlert(monitor: monitor, shutdown: { shutdowns.append($0) }))) + let window = NSWindow( + contentRect: NSRect(x: 200, y: 200, width: 600, height: 240), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { window.close() } + + let first = monitor.begin(on: device("A")) + try await eventually { nativeButton("Keep Waiting") != nil } + try require( + visibleButtonTitles() == ["Keep Waiting", "Shut Down"], + "Show exactly the two recovery actions, without template buttons, help, or suppression controls." + ) + nativeButton("Keep Waiting")!.performClick(nil) + try await eventually { monitor.prompt == nil && nativeButton("Keep Waiting") == nil } + try require(shutdowns.isEmpty, "Keep Waiting must not shut down the simulator.") + monitor.end(first) + + let second = monitor.begin(on: device("A")) + try await eventually { nativeButton("Shut Down") != nil } + nativeButton("Shut Down")!.performClick(nil) + try await eventually { shutdowns == ["A"] && monitor.prompt == nil } + monitor.end(second) + try await eventually { nativeButton("Shut Down") == nil } + + let recovered = monitor.begin(on: device("B")) + try await eventually { nativeButton("Keep Waiting") != nil } + monitor.end(recovered) + try await eventually { nativeButton("Keep Waiting") == nil } + try require(shutdowns == ["A"], "A recovered operation must dismiss without shutting down.") + + let completing = monitor.begin(on: device("B")) + try await eventually { nativeTextContains("iPhone B") } + let queued = monitor.begin(on: device("C")) + try await Task.sleep(for: .milliseconds(100)) + monitor.end(completing) + try await eventually { nativeTextContains("iPhone C") } + nativeButton("Shut Down")!.performClick(nil) + try await eventually { shutdowns == ["A", "C"] } + monitor.end(queued) + try await eventually { nativeButton("Shut Down") == nil } + + let details = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 160), styleMask: [.titled], + backing: .buffered, defer: false) + details.isReleasedWhenClosed = false + window.beginSheet(details, completionHandler: { _ in }) + let covered = monitor.begin(on: device("D")) + try await eventually { nativeButton("Keep Waiting") != nil } + nativeButton("Keep Waiting")!.performClick(nil) + try await eventually { monitor.prompt == nil && nativeButton("Keep Waiting") == nil } + monitor.end(covered) + try require( + window.attachedSheet === details, "A recovery prompt must preserve the app details sheet.") + window.endSheet(details) + details.orderOut(nil) + details.close() + } + + private static func nativeTextContains(_ text: String) -> Bool { + func contains(in view: NSView) -> Bool { + if let field = view as? NSTextField, field.stringValue.contains(text) { return true } + return view.subviews.contains { contains(in: $0) } + } + return NSApplication.shared.windows.filter(\.isVisible).contains { + $0.contentView.map { contains(in: $0) } ?? false + } + } + + private static func visibleButtonTitles() -> [String] { + func titles(in view: NSView) -> [String] { + guard !view.isHiddenOrHasHiddenAncestor else { return [] } + if let button = view as? NSButton { return [button.title] } + return view.subviews.flatMap { titles(in: $0) } + } + return NSApplication.shared.windows.filter(\.isVisible).flatMap { + $0.contentView.map { titles(in: $0) } ?? [] + }.sorted() + } + + private static func nativeButton(_ title: String) -> NSButton? { + func find(in view: NSView) -> NSButton? { + if let button = view as? NSButton, button.title == title { return button } + return view.subviews.lazy.compactMap { find(in: $0) }.first + } + return NSApplication.shared.windows.filter(\.isVisible).lazy.compactMap { + $0.contentView.flatMap { find(in: $0) } + }.first + } + + private static func eventually(_ predicate: () async -> Bool, line: UInt = #line) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !(await predicate()) { + guard ContinuousClock.now < deadline else { + throw failure("Timed out waiting for recovery state at line \(line).") + } + try await Task.sleep(for: .milliseconds(5)) + } + } + + private static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + private static func failure(_ message: String) -> NSError { + NSError( + domain: "OperationMonitorTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} + +private actor MonitorDelay { + private var pending: [Int: CheckedContinuation] = [:] + private(set) var count = 0 + + func wait() async throws { + let index = count + count += 1 + await withCheckedContinuation { pending[index] = $0 } + try Task.checkCancellation() + } + + func release(_ index: Int) { + pending.removeValue(forKey: index)?.resume() + } +} From 1a787a870891253110415eee0b7150450d55e53d Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 23:12:16 -0500 Subject: [PATCH 2/3] Preserve operation monitoring when simulator discovery fails --- src/ContentView.swift | 2 +- src/SimulatorOperationMonitor.swift | 4 +- tests/AppInstallationTests.swift | 49 ++++++++++++++++++++++ tests/SimulatorOperationMonitorTests.swift | 16 +++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/ContentView.swift b/src/ContentView.swift index b1e780c..a771b00 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -156,7 +156,7 @@ struct ContentView: View { .task { await simulators.refresh() } .onChange(of: librarySection) { _, _ in installedApps.clearRecentInstallations() } .onChange(of: discovery?.devices) { _, devices in - operationMonitor.synchronize(with: devices ?? []) + operationMonitor.synchronize(with: devices) } .task(id: shouldMonitorState) { while shouldMonitorState && !Task.isCancelled { diff --git a/src/SimulatorOperationMonitor.swift b/src/SimulatorOperationMonitor.swift index f456486..77e251e 100644 --- a/src/SimulatorOperationMonitor.swift +++ b/src/SimulatorOperationMonitor.swift @@ -60,7 +60,9 @@ final class SimulatorOperationMonitor { } } - func synchronize(with devices: [SimulatorDevice]) { + func synchronize(with devices: [SimulatorDevice]?) { + // A failed scan does not confirm that any device has stopped or disappeared. + guard let devices else { return } let booted = Set(devices.filter { $0.state == "Booted" }.map(\.id)) for deviceID in Set(active.values.map { $0.device.id }).subtracting(booted) { stop(deviceID: deviceID) diff --git a/tests/AppInstallationTests.swift b/tests/AppInstallationTests.swift index 799b8c4..bc00e88 100644 --- a/tests/AppInstallationTests.swift +++ b/tests/AppInstallationTests.swift @@ -17,6 +17,7 @@ struct AppInstallationTests { try await uninstallJobs() try await uninstallFailure() try await recoveryPrompts() + try await discoveryFailureDuringUninstall() try versionActions() try await replacementPipeline() try await replacementWhileSwitchingDevices() @@ -419,6 +420,54 @@ struct AppInstallationTests { } } + static func discoveryFailureDuringUninstall() async throws { + @MainActor + final class DiscoveryState { var shouldFail = false } + let state = DiscoveryState() + let device = InstallationFixtures.device + let simulators = SimulatorStore( + discover: { + if state.shouldFail { throw InstallationFixtures.failure("Temporary discovery failure") } + return SimulatorDiscovery( + developerDirectory: URL(fileURLWithPath: "/Xcode.app/Contents/Developer"), + devices: [device]) + }, boot: { _ in }) + let monitor = SimulatorOperationMonitor(wait: {}) + let commands = InstallationCommands(blocksUninstallation: true) + defer { commands.finish.signal() } + let store = AppInstallationStore( + installer: AppInstaller(command: commands.run), showDevice: { _ in }, + operationMonitor: monitor) + await simulators.refresh() + monitor.synchronize(with: simulators.discovery?.devices) + store.uninstall(app: InstallationFixtures.app(), on: device) + try InstallationFixtures.require( + await Task.detached { commands.waitForStart() }.value, "Wait for the blocked uninstall.") + try await InstallationFixtures.eventually { monitor.prompt?.device.id == device.id } + let prompt = monitor.prompt + state.shouldFail = true + await simulators.refresh() + try InstallationFixtures.require(simulators.discovery == nil, "The scan must fail.") + monitor.synchronize(with: simulators.discovery?.devices) + try InstallationFixtures.require( + monitor.prompt == prompt, "A failed scan must preserve the blocked operation's prompt.") + state.shouldFail = false + await simulators.refresh() + monitor.synchronize(with: simulators.discovery?.devices) + let inventory = InstalledAppsStore(load: { _ in [] }, operationMonitor: monitor) + await inventory.refresh(for: simulators.selectedDevice!) + try InstallationFixtures.require( + simulators.selectedDevice?.state == "Booted" && monitor.prompt == prompt + && store.phase(appID: "example", deviceID: device.id) == .uninstalling, + "Retrying discovery and refreshing apps cannot lose the original stalled operation.") + commands.finish.signal() + try await InstallationFixtures.eventually { + store.phase(appID: "example", deviceID: device.id) == nil + } + try InstallationFixtures.require( + monitor.prompt == nil, "Clear the preserved prompt when the original operation completes.") + } + static func versionActions() throws { let version = InstallationFixtures.version() let cases: [(String?, AppInstallationAction)] = [ diff --git a/tests/SimulatorOperationMonitorTests.swift b/tests/SimulatorOperationMonitorTests.swift index ee94eba..1f14be0 100644 --- a/tests/SimulatorOperationMonitorTests.swift +++ b/tests/SimulatorOperationMonitorTests.swift @@ -9,6 +9,7 @@ struct SimulatorOperationMonitorTests { try await finishedAndReplacedOperations() try await keepWaitingAndSeparateDevices() try await powerStateChanges() + try await failedScanBeforeTimeout() try await nativeAlertActions() print( "PASS: stalled operations, recovery prompts, waiting, completion, and exact shutdown targets." @@ -88,6 +89,21 @@ struct SimulatorOperationMonitorTests { try require(monitor.prompt == nil, "Do not monitor commands on a stopped simulator.") } + private static func failedScanBeforeTimeout() async throws { + let delay = MonitorDelay() + let monitor = SimulatorOperationMonitor(wait: { try await delay.wait() }) + let operation = monitor.begin(on: device("A")) + try await eventually { await delay.count == 1 } + monitor.synchronize(with: nil) + await delay.release(0) + try await eventually { monitor.prompt?.id == operation } + monitor.synchronize(with: nil) + try require( + monitor.prompt?.id == operation, "A failed scan cannot dismiss an existing recovery prompt.") + monitor.synchronize(with: []) + try require(monitor.prompt == nil, "A successful empty scan must still remove missing devices.") + } + private static func nativeAlertActions() async throws { NSApplication.shared.setActivationPolicy(.regular) let monitor = SimulatorOperationMonitor(wait: {}) From bf059a10e57d1ee2e9a1f6c4542639913d95c19b Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 23:36:48 -0500 Subject: [PATCH 3/3] Preserve recovery and report rejected shutdown requests --- assets/Localizable.xcstrings | 16 +++++ src/ContentView.swift | 7 ++- src/SimulatorRecoveryAlert.swift | 45 ++++++++++---- tests/CMakeLists.txt | 2 + tests/LocalizationTests.swift | 8 +++ tests/SimulatorOperationMonitorTests.swift | 69 +++++++++++++++++++++- 6 files changed, 131 insertions(+), 16 deletions(-) diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index d1ac3c1..1fd1142 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -833,6 +833,22 @@ } } }, + "Couldn’t start shutting down %@. Check its current state and try again.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t start shutting down %@. Check its current state and try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法开始关闭 %@。请检查其当前状态后重试。" + } + } + } + }, "Create an iPhone or iPad simulator in %@.\nThen come back to find your next favorite app.": { "localizations": { "en": { diff --git a/src/ContentView.swift b/src/ContentView.swift index a771b00..0ffd496 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -258,13 +258,14 @@ struct ContentView: View { private func shutdownSimulator() { guard let id = simulators.selectedDeviceID else { return } - shutdownSimulator(id) + _ = shutdownSimulator(id) } - private func shutdownSimulator(_ id: String) { - guard simulators.shutdownDevice(id) != nil else { return } + private func shutdownSimulator(_ id: String) -> Bool { + guard simulators.shutdownDevice(id) != nil else { return false } operationMonitor.stop(deviceID: id) installations.cancelOperations(deviceID: id) + return true } private var shouldMonitorState: Bool { diff --git a/src/SimulatorRecoveryAlert.swift b/src/SimulatorRecoveryAlert.swift index 4d1251d..24eafa3 100644 --- a/src/SimulatorRecoveryAlert.swift +++ b/src/SimulatorRecoveryAlert.swift @@ -3,7 +3,7 @@ import SwiftUI struct SimulatorRecoveryAlert: ViewModifier { let monitor: SimulatorOperationMonitor - let shutdown: (String) -> Void + let shutdown: (String) -> Bool func body(content: Content) -> some View { content.background { @@ -16,7 +16,7 @@ struct SimulatorRecoveryAlert: ViewModifier { private struct RecoveryPresenter: NSViewRepresentable { let monitor: SimulatorOperationMonitor let prompt: SimulatorOperationMonitor.Prompt? - let shutdown: (String) -> Void + let shutdown: (String) -> Bool func makeCoordinator() -> Coordinator { Coordinator(monitor: monitor) } @@ -50,17 +50,19 @@ private struct RecoveryPresenter: NSViewRepresentable { final class Coordinator { private let monitor: SimulatorOperationMonitor private var prompt: SimulatorOperationMonitor.Prompt? - private var shutdown: (String) -> Void = { _ in } + private var shutdown: (String) -> Bool = { _ in false } private weak var window: NSWindow? private var alert: NSAlert? private var presentedID: UUID? + private var rejectedID: UUID? init(monitor: SimulatorOperationMonitor) { self.monitor = monitor } func update( - prompt: SimulatorOperationMonitor.Prompt?, shutdown: @escaping (String) -> Void, + prompt: SimulatorOperationMonitor.Prompt?, shutdown: @escaping (String) -> Bool, window: NSWindow? ) { + if self.prompt?.id != prompt?.id { rejectedID = nil } self.prompt = prompt self.shutdown = shutdown updateWindow(window) @@ -69,13 +71,21 @@ private struct RecoveryPresenter: NSViewRepresentable { func updateWindow(_ window: NSWindow?) { if self.window !== window || presentedID != prompt?.id { dismiss() } self.window = window - guard alert == nil, let window, let prompt else { return } + guard alert == nil, let window, let prompt, monitor.prompt?.id == prompt.id else { return } let alert = NSAlert() - alert.messageText = String(localized: "Operation Is Taking Longer Than Expected") - alert.informativeText = String( - localized: - "An app operation on \(prompt.device.name) hasn’t finished. Keep waiting, or shut down this simulator to stop pending operations. Shutting down closes all of its apps." - ) + if rejectedID == prompt.id { + alert.messageText = String(localized: "Couldn’t Shut Down Simulator") + alert.informativeText = String( + localized: + "Couldn’t start shutting down \(prompt.device.name). Check its current state and try again." + ) + } else { + alert.messageText = String(localized: "Operation Is Taking Longer Than Expected") + alert.informativeText = String( + localized: + "An app operation on \(prompt.device.name) hasn’t finished. Keep waiting, or shut down this simulator to stop pending operations. Shutting down closes all of its apps." + ) + } alert.addButton(withTitle: String(localized: "Keep Waiting")).keyEquivalent = "\u{1b}" alert.addButton(withTitle: String(localized: "Shut Down")).hasDestructiveAction = true // Direct sheet presentation bypasses NSAlert's usual pre-display layout. @@ -87,8 +97,19 @@ private struct RecoveryPresenter: NSViewRepresentable { guard let self, let alert, self.alert === alert else { return } self.alert = nil presentedID = nil - monitor.keepWaiting(prompt) - if response == .alertSecondButtonReturn { shutdown(prompt.device.id) } + if response == .alertSecondButtonReturn { + if shutdown(prompt.device.id) { + monitor.keepWaiting(prompt) + } else { + rejectedID = prompt.id + Task { [weak self] in + guard let self else { return } + updateWindow(self.window) + } + } + } else { + monitor.keepWaiting(prompt) + } } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7d552d2..59731dc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -116,6 +116,8 @@ add_executable(SimStoreOperationMonitorTest SimulatorOperationMonitorTests.swift "${PROJECT_SOURCE_DIR}/src/SimulatorOperationMonitor.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorRecoveryAlert.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(SimStoreOperationMonitorTest PRIVATE -parse-as-library) diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift index 00dafbb..3f1e3d5 100644 --- a/tests/LocalizationTests.swift +++ b/tests/LocalizationTests.swift @@ -109,6 +109,14 @@ struct LocalizationTests { 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 shutting down \(deviceName). Check its current state and try again.", + bundle: bundle, locale: locale), + english + ? "Couldn’t start shutting down iPhone 测试. Check its current state and try again." + : "无法开始关闭 iPhone 测试。请检查其当前状态后重试。") try expect( String( localized: diff --git a/tests/SimulatorOperationMonitorTests.swift b/tests/SimulatorOperationMonitorTests.swift index 1f14be0..7bf1f0f 100644 --- a/tests/SimulatorOperationMonitorTests.swift +++ b/tests/SimulatorOperationMonitorTests.swift @@ -11,6 +11,7 @@ struct SimulatorOperationMonitorTests { try await powerStateChanges() try await failedScanBeforeTimeout() try await nativeAlertActions() + try await shutdownAfterDiscoveryFailure() print( "PASS: stalled operations, recovery prompts, waiting, completion, and exact shutdown targets." ) @@ -111,7 +112,13 @@ struct SimulatorOperationMonitorTests { let host = NSHostingView( rootView: Text("Simulator recovery test") .frame(width: 600, height: 240) - .modifier(SimulatorRecoveryAlert(monitor: monitor, shutdown: { shutdowns.append($0) }))) + .modifier( + SimulatorRecoveryAlert( + monitor: monitor, + shutdown: { + shutdowns.append($0) + return true + }))) let window = NSWindow( contentRect: NSRect(x: 200, y: 200, width: 600, height: 240), styleMask: [.titled, .closable], backing: .buffered, defer: false) @@ -172,6 +179,66 @@ struct SimulatorOperationMonitorTests { details.close() } + private static func shutdownAfterDiscoveryFailure() async throws { + @MainActor + final class DiscoveryState { + var shouldFail = false + var shutdownCalls: [String] = [] + } + let state = DiscoveryState() + let target = device("A") + let simulators = SimulatorStore( + discover: { + if state.shouldFail { throw failure("Temporary discovery failure") } + return SimulatorDiscovery( + developerDirectory: URL(fileURLWithPath: "/Xcode.app/Contents/Developer"), + devices: [target]) + }, + shutdown: { id in await MainActor.run { state.shutdownCalls.append(id) } }, + boot: { _ in }) + await simulators.refresh() + let monitor = SimulatorOperationMonitor(wait: {}) + let host = NSHostingView( + rootView: Text("Recovery during failed discovery") + .frame(width: 600, height: 240) + .modifier( + SimulatorRecoveryAlert( + monitor: monitor, + shutdown: { id in + guard simulators.shutdownDevice(id) != nil else { return false } + monitor.stop(deviceID: id) + return true + }))) + let window = NSWindow( + contentRect: NSRect(x: 200, y: 200, width: 600, height: 240), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { window.close() } + let operation = monitor.begin(on: target) + defer { monitor.end(operation) } + try await eventually { nativeButton("Shut Down") != nil } + state.shouldFail = true + await simulators.refresh() + monitor.synchronize(with: simulators.discovery?.devices) + let original = monitor.prompt + nativeButton("Shut Down")!.performClick(nil) + try await eventually { nativeTextContains("Couldn’t Shut Down Simulator") } + try require( + state.shutdownCalls.isEmpty && monitor.prompt == original, + "A rejected request must show feedback and preserve recovery without muting the device.") + try require( + visibleButtonTitles() == ["Keep Waiting", "Shut Down"], + "The failure prompt must retain both waiting and retry actions.") + state.shouldFail = false + await simulators.refresh() + nativeButton("Shut Down")!.performClick(nil) + try await eventually { state.shutdownCalls == [target.id] } + try await eventually { nativeButton("Shut Down") == nil } + try require(monitor.prompt == nil, "An accepted retry can dismiss the recovery prompt.") + } + private static func nativeTextContains(_ text: String) -> Bool { func contains(in view: NSView) -> Bool { if let field = view as? NSTextField, field.stringValue.contains(text) { return true }