diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index 3c25cdc..1fd1142 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": { @@ -817,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": { @@ -1201,6 +1233,22 @@ } } }, + "Keep Waiting": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Waiting" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续等待" + } + } + } + }, "License": { "localizations": { "en": { @@ -1617,6 +1665,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..0ffd496 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,8 +257,15 @@ 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) -> 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/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..77e251e --- /dev/null +++ b/src/SimulatorOperationMonitor.swift @@ -0,0 +1,71 @@ +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]?) { + // 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/src/SimulatorRecoveryAlert.swift b/src/SimulatorRecoveryAlert.swift new file mode 100644 index 0000000..24eafa3 --- /dev/null +++ b/src/SimulatorRecoveryAlert.swift @@ -0,0 +1,126 @@ +import AppKit +import SwiftUI + +struct SimulatorRecoveryAlert: ViewModifier { + let monitor: SimulatorOperationMonitor + let shutdown: (String) -> Bool + + 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) -> Bool + + 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) -> 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) -> Bool, + window: NSWindow? + ) { + if self.prompt?.id != prompt?.id { rejectedID = nil } + 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, monitor.prompt?.id == prompt.id else { return } + let alert = NSAlert() + 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. + 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 + 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) + } + } + } + + 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..bc00e88 100644 --- a/tests/AppInstallationTests.swift +++ b/tests/AppInstallationTests.swift @@ -16,6 +16,8 @@ struct AppInstallationTests { try await stoppedDevice() try await uninstallJobs() try await uninstallFailure() + try await recoveryPrompts() + try await discoveryFailureDuringUninstall() try versionActions() try await replacementPipeline() try await replacementWhileSwitchingDevices() @@ -376,6 +378,96 @@ 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 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)] = [ @@ -498,18 +590,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 +630,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..59731dc 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,18 @@ 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/SimulatorStore.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorControl.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..3f1e3d5 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,22 @@ 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: + "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..7bf1f0f --- /dev/null +++ b/tests/SimulatorOperationMonitorTests.swift @@ -0,0 +1,307 @@ +import AppKit +import SwiftUI + +@main +@MainActor +struct SimulatorOperationMonitorTests { + static func main() async { + do { + try await finishedAndReplacedOperations() + try await keepWaitingAndSeparateDevices() + try await powerStateChanges() + try await failedScanBeforeTimeout() + try await nativeAlertActions() + try await shutdownAfterDiscoveryFailure() + 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 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: {}) + var shutdowns: [String] = [] + let host = NSHostingView( + rootView: Text("Simulator recovery test") + .frame(width: 600, height: 240) + .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) + 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 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 } + 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() + } +}