Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions assets/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -961,50 +961,50 @@
}
}
},
"Every app in the catalog is already installed on this simulator.": {
"Email": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Every app in the catalog is already installed on this simulator."
"value": "Email"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "目录中的所有应用均已安装在此模拟器中。"
"value": "电子邮件"
}
}
}
},
"Failed Updates": {
"Every app in the catalog is already installed on this simulator.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Failed Updates"
"value": "Every app in the catalog is already installed on this simulator."
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "更新失败的应用"
"value": "目录中的所有应用均已安装在此模拟器中。"
}
}
}
},
"Finish or cancel app operations before shutting down this simulator.": {
"Failed Updates": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Finish or cancel app operations before shutting down this simulator."
"value": "Failed Updates"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "请先完成或取消应用操作,再关闭此模拟器。"
"value": "更新失败的应用"
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/AppInstallationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 2 additions & 2 deletions src/CatalogBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions src/CatalogCategory.swift
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
6 changes: 2 additions & 4 deletions src/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,8 @@ struct ContentView: View {
}

private func shutdownSimulator() {
guard let id = simulators.selectedDeviceID, !installations.hasOperations(deviceID: id) else {
return
}
simulators.shutdownDevice(id)
guard let id = simulators.selectedDeviceID, simulators.shutdownDevice(id) != nil else { return }
installations.cancelOperations(deviceID: id)
}

private var shouldMonitorState: Bool {
Expand Down
13 changes: 2 additions & 11 deletions src/SimulatorPowerButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import SwiftUI

struct SimulatorPowerButton: View {
let device: SimulatorDevice
let hasAppOperations: Bool
let start: () -> Void
let shutdown: () -> Void

Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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))
}
}
3 changes: 1 addition & 2 deletions src/SimulatorSelectionViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion tests/AppInstallationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
90 changes: 84 additions & 6 deletions tests/AppUpdateBatchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -203,19 +204,19 @@ 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(
store.updateBatch(deviceID: device.id)?.updated == 1
&& 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 {
Expand Down Expand Up @@ -259,13 +260,90 @@ 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
}
}
}

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]
Expand Down
2 changes: 2 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 1 addition & 5 deletions tests/LocalizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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", "正在启动"),
Expand All @@ -52,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", "许可证"),
Expand Down
Loading