From 469bcabda508bbf989db16ee7c1ececaf2fd015b Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 21:11:52 -0500 Subject: [PATCH 1/3] Allow simulator shutdown during app operations --- assets/Localizable.xcstrings | 16 ---------------- src/CatalogBrowserView.swift | 4 ++-- src/ContentView.swift | 4 +--- src/SimulatorPowerButton.swift | 13 ++----------- src/SimulatorSelectionViews.swift | 3 +-- tests/LocalizationTests.swift | 5 ----- tests/SimulatorPowerButtonTests.swift | 18 ++++++++---------- 7 files changed, 14 insertions(+), 49 deletions(-) diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index 1ebbdc0..f19d601 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -993,22 +993,6 @@ } } }, - "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": { diff --git a/src/CatalogBrowserView.swift b/src/CatalogBrowserView.swift index 5e8a8e9..81b6d24 100644 --- a/src/CatalogBrowserView.swift +++ b/src/CatalogBrowserView.swift @@ -115,7 +115,7 @@ struct CatalogBrowserView: View { } .popover(isPresented: $showsSimulator) { SimulatorDetailView( - device: device, hasAppOperations: installations.hasOperations(deviceID: device.id), + device: device, deviceManagerName: deviceManagerName, start: startSimulator, shutdown: shutdownSimulator, openDeviceManager: openDeviceManager ) @@ -152,7 +152,7 @@ struct CatalogBrowserView: View { Spacer(minLength: 12) if device.canStart || device.canShutdown || device.isTransitioning { SimulatorPowerButton( - device: device, hasAppOperations: installations.hasOperations(deviceID: device.id), + device: device, start: startSimulator, shutdown: shutdownSimulator) } Button { diff --git a/src/ContentView.swift b/src/ContentView.swift index a8322fd..88f2893 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -249,9 +249,7 @@ struct ContentView: View { } private func shutdownSimulator() { - guard let id = simulators.selectedDeviceID, !installations.hasOperations(deviceID: id) else { - return - } + guard let id = simulators.selectedDeviceID else { return } simulators.shutdownDevice(id) } diff --git a/src/SimulatorPowerButton.swift b/src/SimulatorPowerButton.swift index c40f2f9..88cfb30 100644 --- a/src/SimulatorPowerButton.swift +++ b/src/SimulatorPowerButton.swift @@ -2,7 +2,6 @@ import SwiftUI struct SimulatorPowerButton: View { let device: SimulatorDevice - let hasAppOperations: Bool let start: () -> Void let shutdown: () -> Void @@ -38,7 +37,7 @@ struct SimulatorPowerButton: View { private var enabled: Bool { switch action { case .start: device.canStart - case .shutdown: device.canShutdown && !hasAppOperations + case .shutdown: device.canShutdown case .starting, .shuttingDown: false } } @@ -66,14 +65,6 @@ struct SimulatorPowerButton: View { } .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) - } + .help(Text(verbatim: action.title)) } } diff --git a/src/SimulatorSelectionViews.swift b/src/SimulatorSelectionViews.swift index c065a75..e6e5409 100644 --- a/src/SimulatorSelectionViews.swift +++ b/src/SimulatorSelectionViews.swift @@ -49,7 +49,6 @@ struct SimulatorPicker: View { @MainActor struct SimulatorDetailView: View { let device: SimulatorDevice - let hasAppOperations: Bool let deviceManagerName: String? let start: () -> Void let shutdown: () -> Void @@ -101,7 +100,7 @@ struct SimulatorDetailView: View { HStack(spacing: 10) { if device.canStart || device.canShutdown || device.isTransitioning { SimulatorPowerButton( - device: device, hasAppOperations: hasAppOperations, start: start, shutdown: shutdown) + device: device, start: start, shutdown: shutdown) } DeviceManagerButton(name: deviceManagerName, action: openDeviceManager) .buttonStyle(.bordered) diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift index 919731b..09da7e3 100644 --- a/tests/LocalizationTests.swift +++ b/tests/LocalizationTests.swift @@ -39,11 +39,6 @@ struct LocalizationTests { ("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", "正在启动"), diff --git a/tests/SimulatorPowerButtonTests.swift b/tests/SimulatorPowerButtonTests.swift index 9fdcb98..eb225c6 100644 --- a/tests/SimulatorPowerButtonTests.swift +++ b/tests/SimulatorPowerButtonTests.swift @@ -16,14 +16,11 @@ struct SimulatorPowerButtonTests { 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), - ] { + for state in ["Shutdown", "Booting", "Booted", "Shutting Down"] { let device = SimulatorDevice(id: "A", name: "iPhone", runtime: "27.0", state: state) let host = NSHostingView( rootView: SimulatorPowerButton( - device: device, hasAppOperations: busy, + device: device, start: { starts += 1 }, shutdown: { shutdowns += 1 } ) .controlSize(size) @@ -35,21 +32,22 @@ struct SimulatorPowerButtonTests { 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)] { + results.map(\.enabled) == [true, false, true, false], + "Power actions depend only on device state; a booted simulator can always shut down.") + for (idle, pending) in [(0, 1), (2, 3)] { 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.") + "Power actions must keep stable bounds while starting or shutting down.") } } 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.") + print( + "PASS: native simulator power actions, device state gating, and stable transition bounds.") } catch { FileHandle.standardError.write( Data("Simulator power button test failed: \(error.localizedDescription)\n".utf8)) From 315e1c435a91bbb5f962b3124ed84b5c9e952fc3 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 21:18:12 -0500 Subject: [PATCH 2/3] Add email catalog category and translations --- assets/Localizable.xcstrings | 16 ++++++++++++++++ src/CatalogCategory.swift | 5 +++-- tests/LocalizationTests.swift | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index f19d601..3c25cdc 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -961,6 +961,22 @@ } } }, + "Email": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电子邮件" + } + } + } + }, "Every app in the catalog is already installed on this simulator.": { "localizations": { "en": { diff --git a/src/CatalogCategory.swift b/src/CatalogCategory.swift index b3e0cff..105558c 100644 --- a/src/CatalogCategory.swift +++ b/src/CatalogCategory.swift @@ -1,14 +1,15 @@ import Foundation enum CatalogCategory: String { - case browser, cloud, communication, games, health, maps, media, productivity, reading, security, - social, utilities + case browser, cloud, communication, email, games, health, maps, media, productivity, reading, + security, social, utilities var title: String { switch self { case .browser: String(localized: "Browser") case .cloud: String(localized: "Cloud") case .communication: String(localized: "Communication") + case .email: String(localized: "Email") case .games: String(localized: "Games") case .health: String(localized: "Health") case .maps: String(localized: "Maps") diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift index 09da7e3..c2e3242 100644 --- a/tests/LocalizationTests.swift +++ b/tests/LocalizationTests.swift @@ -47,6 +47,7 @@ struct LocalizationTests { ("Search apps", "Search apps", "搜索应用"), ("Refresh Catalog", "Refresh Catalog", "刷新目录"), ("Cloud", "Cloud", "云服务"), + ("Email", "Email", "电子邮件"), ("Security", "Security", "安全"), ("Catalog Unavailable", "Catalog Unavailable", "无法获取应用目录"), ("License", "License", "许可证"), From a7b4b60b6e30a19b9b22685f49985b6e8f1b37ae Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 22:12:18 -0500 Subject: [PATCH 3/3] Cancel pending app operations when shutting down a simulator --- src/AppInstallationStore.swift | 7 +++ src/ContentView.swift | 4 +- tests/AppInstallationTests.swift | 3 +- tests/AppUpdateBatchTests.swift | 90 +++++++++++++++++++++++++++++--- tests/CMakeLists.txt | 2 + 5 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/AppInstallationStore.swift b/src/AppInstallationStore.swift index 76f4137..bda1dab 100644 --- a/src/AppInstallationStore.swift +++ b/src/AppInstallationStore.swift @@ -137,6 +137,13 @@ final class AppInstallationStore { jobs[key]?.cancel() } + func cancelOperations(deviceID: String) { + cancelAllUpdates(deviceID: deviceID) + for key in phases.keys.filter({ $0.deviceID == deviceID }) { + cancel(appID: key.appID, deviceID: deviceID) + } + } + func open(app: CatalogApp, on device: SimulatorDevice) { let key = Key(appID: app.id, deviceID: device.id) guard jobs[key] == nil, phases[key] == nil else { return } diff --git a/src/ContentView.swift b/src/ContentView.swift index 88f2893..14d8de0 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -249,8 +249,8 @@ struct ContentView: View { } private func shutdownSimulator() { - guard let id = simulators.selectedDeviceID else { return } - simulators.shutdownDevice(id) + guard let id = simulators.selectedDeviceID, simulators.shutdownDevice(id) != nil else { return } + installations.cancelOperations(deviceID: id) } private var shouldMonitorState: Bool { diff --git a/tests/AppInstallationTests.swift b/tests/AppInstallationTests.swift index e70c5d3..ce6c325 100644 --- a/tests/AppInstallationTests.swift +++ b/tests/AppInstallationTests.swift @@ -324,10 +324,11 @@ struct AppInstallationTests { store.open(app: app, on: device) store.install(app: app, version: app.latestVersion, on: device) store.cancel(appID: app.id, deviceID: device.id) + store.cancelOperations(deviceID: device.id) try InstallationFixtures.require( store.phase(appID: app.id, deviceID: device.id) == .uninstalling && commands.calls.count == 2, - "Do not cancel, duplicate, install, or open during an in-flight uninstall.") + "Stopping device operations must not wait for, cancel, or duplicate an in-flight uninstall.") let other = SimulatorDevice( id: "device-B", name: device.name, runtime: device.runtime, state: "Booted") await inventory.refresh(for: other) diff --git a/tests/AppUpdateBatchTests.swift b/tests/AppUpdateBatchTests.swift index 15f23ef..76f6ad9 100644 --- a/tests/AppUpdateBatchTests.swift +++ b/tests/AppUpdateBatchTests.swift @@ -8,6 +8,7 @@ enum AppUpdateBatchTests { try await cancellation() try await cancellationDuringInstall() try await separateDevices() + try await shutdownWhileDownloading() print( "PASS: Update All selection, queue reservations, serial execution, retry, cancellation, and per-device isolation." ) @@ -52,7 +53,7 @@ enum AppUpdateBatchTests { try InstallationFixtures.require( store.hasOperations(deviceID: device.id) && !store.hasOperations(deviceID: "another-device"), - "Queued updates must block shutdown only for their own device.") + "Queued updates must report activity only for their own device.") try InstallationFixtures.require( store.phase(appID: "bravo", deviceID: device.id) == .queued, "Reserve queued apps before returning to the UI.") @@ -84,7 +85,7 @@ enum AppUpdateBatchTests { try await finished(store, device.id) try InstallationFixtures.require( !store.hasOperations(deviceID: device.id), - "Completed batch results must not prevent simulator shutdown.") + "Completed batch results must not report ongoing operations.") try InstallationFixtures.require( store.updateBatch(deviceID: device.id)?.updated == 2 && store.updateBatch(deviceID: device.id)?.completed == 2, @@ -163,7 +164,7 @@ enum AppUpdateBatchTests { store.cancelAllUpdates(deviceID: device.id) try InstallationFixtures.require( store.hasOperations(deviceID: device.id), - "Canceling must keep shutdown blocked until active operations finish cleaning up.") + "Canceling must report activity 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, @@ -203,11 +204,11 @@ enum AppUpdateBatchTests { try await downloads.waitForCount(1) await downloads.finish(0) try await InstallationFixtures.eventually { commands.installs.count == 1 } - store.cancelAllUpdates(deviceID: device.id) + store.cancelOperations(deviceID: device.id) try InstallationFixtures.require( store.phase(appID: "alpha", deviceID: device.id) == .installing && store.updateBatch(deviceID: device.id)?.isStopping == true, - "A running simctl install must finish before the batch stops.") + "Stopping device operations must leave the in-flight install alone and stop its queue.") commands.finish.signal() try await finished(store, device.id) try InstallationFixtures.require( @@ -215,7 +216,7 @@ enum AppUpdateBatchTests { && store.updateBatch(deviceID: device.id)?.canceled == 1, "Keep a completed installation and cancel only the pending update.") try InstallationFixtures.require( - await downloads.count == 1, "Do not download the next app after Cancel All.") + await downloads.count == 1, "Do not download the next app after stopping device operations.") } private static func separateDevices() async throws { @@ -259,6 +260,70 @@ enum AppUpdateBatchTests { try fixture.requireClean() } + private static func shutdownWhileDownloading() async throws { + let fixture = try BatchFixture(ids: ["alpha", "bravo", "manual"]) + defer { fixture.remove() } + let downloads = BatchDownloads(data: fixture.data) + let commands = BatchCommands() + let inventory = fixture.inventory() + let power = BatchSimulator() + let simulators = SimulatorStore( + discover: { + SimulatorDiscovery( + developerDirectory: URL(fileURLWithPath: "/Xcode.app/Contents/Developer"), + devices: [await power.device]) + }, shutdown: { try await power.shutdown($0) }, boot: { _ in }) + await simulators.refresh() + let device = simulators.selectedDevice! + await inventory.refresh(for: device) + let store = fixture.store(downloads: downloads, commands: commands, inventory: inventory) + let catalog = AppCatalog(schemaVersion: 1, apps: Array(fixture.apps.prefix(2))) + store.updateAll(in: catalog, installedApps: inventory, on: device) + try await downloads.waitForCount(1) + let manual = fixture.apps[2] + store.install(app: manual, version: manual.latestVersion, on: device) + try await downloads.waitForCount(2) + let other = SimulatorDevice( + id: "device-B", name: "iPhone B", runtime: device.runtime, state: "Booted") + store.install(app: manual, version: manual.latestVersion, on: other) + try await downloads.waitForCount(3) + + guard let shutdown = simulators.shutdownDevice(device.id) else { + throw InstallationFixtures.failure("Accept shutdown during app downloads.") + } + store.cancelOperations(deviceID: device.id) + try InstallationFixtures.require( + store.phase(appID: "alpha", deviceID: device.id) == .canceling + && store.phase(appID: "bravo", deviceID: device.id) == nil + && store.phase(appID: "manual", deviceID: device.id) == .canceling, + "Cancel both batch and manual downloads and release queued apps immediately.") + try InstallationFixtures.require( + await shutdown.value && simulators.selectedDevice?.state == "Shutdown", + "Shutdown must complete before canceled downloads finish cleaning up.") + try InstallationFixtures.require( + store.phase(appID: "manual", deviceID: other.id) == .downloading(0), + "Shutting down one simulator cannot cancel another simulator's downloads.") + await downloads.finish(0) + await downloads.finish(1) + try await finished(store, device.id) + try await InstallationFixtures.eventually { + store.phase(appID: "manual", deviceID: device.id) == nil + } + try InstallationFixtures.require( + await downloads.count == 3 && commands.installs.isEmpty + && store.updateBatch(deviceID: device.id)?.canceled == 2, + "A confirmed shutdown cannot download the next queued app or install canceled packages.") + + await downloads.finish(2) + try await InstallationFixtures.eventually { + store.phase(appID: "manual", deviceID: other.id) == nil + } + try InstallationFixtures.require( + commands.installs.count == 1 && commands.installs.first?.deviceID == other.id, + "The other simulator's installation must finish normally.") + try fixture.requireClean() + } + private static func finished(_ store: AppInstallationStore, _ deviceID: String) async throws { try await InstallationFixtures.eventually { store.updateBatch(deviceID: deviceID)?.isRunning == false @@ -266,6 +331,19 @@ enum AppUpdateBatchTests { } } +private actor BatchSimulator { + private var state = "Booted" + + var device: SimulatorDevice { + SimulatorDevice(id: "device-A", name: "iPhone", runtime: "27.0", state: state) + } + + func shutdown(_ id: String) throws { + try InstallationFixtures.require(id == "device-A", "Shut down only the selected simulator.") + state = "Shutdown" + } +} + private struct BatchFixture: Sendable { let directory: URL let apps: [CatalogApp] diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 168f6fc..d0a3b53 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -66,6 +66,8 @@ add_executable(SimStoreInstallationTest AppInstallationTests.swift AppDownloadTe "${PROJECT_SOURCE_DIR}/src/InstalledAppsStore.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorApplications.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(SimStoreInstallationTest PRIVATE -parse-as-library)