diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index 84c7ec4..75fe9fe 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -81,6 +81,22 @@ } } }, + "%lld updated, %lld failed, %lld canceled": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld updated, %2$lld failed, %3$lld canceled" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 个已更新,%2$lld 个失败,%3$lld 个已取消" + } + } + } + }, "A New Nightly Is Available": { "localizations": { "en": { @@ -401,6 +417,22 @@ } } }, + "Cancel All": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel All" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部取消" + } + } + } + }, "Canceling…": { "localizations": { "en": { @@ -817,6 +849,22 @@ } } }, + "Dismiss": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + } + } + }, "Done": { "localizations": { "en": { @@ -897,6 +945,22 @@ } } }, + "Failed Updates": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed Updates" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新失败的应用" + } + } + } + }, "Games": { "localizations": { "en": { @@ -1905,6 +1969,22 @@ } } }, + "Some Updates Failed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Some Updates Failed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部分应用更新失败" + } + } + } + }, "Source Repository": { "localizations": { "en": { @@ -2001,6 +2081,22 @@ } } }, + "Stopping Updates…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopping Updates…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在停止更新…" + } + } + } + }, "The app catalog is invalid. Try refreshing it again later.": { "localizations": { "en": { @@ -2449,6 +2545,22 @@ } } }, + "Update All": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update All" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部更新" + } + } + } + }, "Update SimStore to read this app catalog.": { "localizations": { "en": { @@ -2497,6 +2609,70 @@ } } }, + "Updates Canceled": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updates Canceled" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新已取消" + } + } + } + }, + "Updates Complete": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updates Complete" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新完成" + } + } + } + }, + "Updating Apps": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updating Apps" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在更新应用" + } + } + } + }, + "Updating…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updating…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在更新…" + } + } + } + }, "Utilities": { "localizations": { "en": { @@ -2577,6 +2753,22 @@ } } }, + "Waiting…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待中…" + } + } + } + }, "Xcode did not provide a valid developer directory.": { "localizations": { "en": { diff --git a/docs/cache.md b/docs/cache.md index a730258..1a4fed7 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -32,6 +32,8 @@ App archives are streamed to a separate, randomly named directory under `URL.tem The archive's size and SHA-256 must match the selected catalog version before extraction. The temporary archive and extracted app are removed when the operation succeeds, fails, or finishes canceling. Cancellation during preparation waits for any running archive command to finish before cleanup; once `simctl install` starts, it is allowed to finish. A process exit can leave temporary files for the operating system to reclaim. +Update All snapshots all compatible pending updates for the selected simulator, independently of search and category filters, and runs them one at a time through the same installation pipeline. Apps already running another operation are omitted. Queued apps have no downloaded files, reserve their per-app/device operation slots, and retain their selected version, size, and checksum even if the catalog refreshes. Each simulator has its own queue and results, which remain in memory when navigating or switching devices. A failure records its diagnostic and the next app proceeds; another Update All action retries only apps that still need updates. Canceling a queued item releases it immediately. Cancel All cancels pending downloads/preparation and removes the remaining queue, while an installation already handed to `simctl` finishes normally. Completed updates retain their usual Open actions until navigation, catalog refresh, or opening removes them from the update list. + ## SimStore updates The client's own `version.json` feed is fetched on each explicit update check with the local HTTP cache bypassed and `Cache-Control: no-cache`. It has no disk cache, ETag storage, expiry timer, or offline fallback. The most recent result and selected release are kept in memory for the current application session. Before downloading a nightly, SimStore checks the current feed again and asks for another update check if the offered build has changed. diff --git a/src/AppInstallButton.swift b/src/AppInstallButton.swift index 62f477f..02a0de0 100644 --- a/src/AppInstallButton.swift +++ b/src/AppInstallButton.swift @@ -36,7 +36,7 @@ struct AppInstallButton: View { private var action: AppInstallationAction { switch phase { - case .downloading, .verifying, .extracting: .cancel + case .queued, .downloading, .verifying, .extracting: .cancel case .installing: .installing case .canceling: .canceling case .opening: .opening diff --git a/src/AppInstallationPhase.swift b/src/AppInstallationPhase.swift index 99242a7..5f445d5 100644 --- a/src/AppInstallationPhase.swift +++ b/src/AppInstallationPhase.swift @@ -1,6 +1,7 @@ import Foundation enum AppInstallationPhase: Equatable, Sendable { + case queued case downloading(Int64) case verifying case extracting @@ -11,13 +12,14 @@ enum AppInstallationPhase: Equatable, Sendable { var canCancel: Bool { switch self { - case .downloading, .verifying, .extracting: true + case .queued, .downloading, .verifying, .extracting: true case .installing, .canceling, .opening, .uninstalling: false } } var title: String { switch self { + case .queued: String(localized: "Waiting…") case .downloading: String(localized: "Downloading…") case .verifying: String(localized: "Verifying…") case .extracting: String(localized: "Preparing App…") diff --git a/src/AppInstallationStore.swift b/src/AppInstallationStore.swift index b0d8c65..ac5d2d8 100644 --- a/src/AppInstallationStore.swift +++ b/src/AppInstallationStore.swift @@ -11,6 +11,7 @@ final class AppInstallationStore { private var phases: [Key: AppInstallationPhase] = [:] private var errors: [Key: String] = [:] + private var updateBatches: [String: AppUpdateBatch] = [:] private(set) var openedApp: Key? private(set) var uninstalledApp: Key? @ObservationIgnored private let installer: AppInstaller @@ -21,6 +22,7 @@ final class AppInstallationStore { @ObservationIgnored private let didOpen: @MainActor (String, String) -> Void @ObservationIgnored private var jobs: [Key: Task] = [:] @ObservationIgnored private var requests: [Key: UUID] = [:] + @ObservationIgnored private var batchJobs: [String: Task] = [:] init( installer: AppInstaller = AppInstaller(), @@ -54,17 +56,22 @@ final class AppInstallationStore { startInstallation(app: app, version: version, on: device, completion: didReplace) } + @discardableResult private func startInstallation( app: CatalogApp, version: CatalogVersion, on device: SimulatorDevice, - completion: @escaping @MainActor (SimulatorApplication, String) -> Void - ) { + completion: @escaping @MainActor (SimulatorApplication, String) -> Void, + fromQueue: Bool = false, + finished: @escaping @MainActor (Result) -> Void = { _ in } + ) -> Task? { let key = Key(appID: app.id, deviceID: device.id) - guard jobs[key] == nil else { return } + guard jobs[key] == nil, phases[key] == nil || (fromQueue && phases[key] == .queued) else { + return nil + } let request = UUID() requests[key] = request errors[key] = nil phases[key] = .downloading(0) - jobs[key] = Task { [self] in + let job = Task { [self] in defer { finish(key) } do { try await installer.install(app: app, version: version, on: device) { [weak self] phase in @@ -81,16 +88,23 @@ final class AppInstallationStore { SimulatorApplication( bundleID: app.bundleID, version: version.version, isUserApplication: true), device.id) - } catch is CancellationError { + finished(.success(())) } catch { - if !Task.isCancelled { errors[key] = error.localizedDescription } + if Task.isCancelled || error is CancellationError { + finished(.failure(CancellationError())) + } else { + errors[key] = error.localizedDescription + finished(.failure(error)) + } } } + jobs[key] = job + return job } func uninstall(app: CatalogApp, on device: SimulatorDevice) { let key = Key(appID: app.id, deviceID: device.id) - guard jobs[key] == nil else { return } + guard jobs[key] == nil, phases[key] == nil else { return } uninstalledApp = nil errors[key] = nil phases[key] = .uninstalling @@ -108,6 +122,11 @@ final class AppInstallationStore { func cancel(appID: String, deviceID: String) { let key = Key(appID: appID, deviceID: deviceID) + if phases[key] == .queued { + phases[key] = nil + updateBatches[deviceID]?.outcomes[appID] = .canceled + return + } guard phases[key]?.canCancel == true else { return } phases[key] = .canceling jobs[key]?.cancel() @@ -115,7 +134,7 @@ final class AppInstallationStore { func open(app: CatalogApp, on device: SimulatorDevice) { let key = Key(appID: app.id, deviceID: device.id) - guard jobs[key] == nil else { return } + guard jobs[key] == nil, phases[key] == nil else { return } openedApp = nil errors[key] = nil phases[key] = .opening @@ -137,4 +156,90 @@ final class AppInstallationStore { requests[key] = nil phases[key] = nil } + + func updateBatch(deviceID: String) -> AppUpdateBatch? { + updateBatches[deviceID] + } + + func activeUpdateVersion(appID: String, deviceID: String) -> CatalogVersion? { + guard let batch = updateBatches[deviceID], batch.isRunning, batch.outcomes[appID] == nil else { + return nil + } + return batch.items.first { $0.id == appID }?.version + } + + func pendingUpdates( + in catalog: AppCatalog, installedApps: InstalledAppsStore, on device: SimulatorDevice + ) -> [AppUpdateBatch.Item] { + let apps = installedApps.updatableApps(in: catalog, for: device, includingRecent: false) ?? [] + return apps.compactMap { app in + guard phase(appID: app.id, deviceID: device.id) == nil, + let version = app.availableUpdate( + from: installedApps.application(bundleID: app.bundleID, deviceID: device.id)?.version, + runtime: device.runtime) + else { return nil } + return AppUpdateBatch.Item(app: app, version: version) + } + .sorted { $0.app.name.localizedStandardCompare($1.app.name) == .orderedAscending } + } + + func updateAll( + in catalog: AppCatalog, installedApps: InstalledAppsStore, on device: SimulatorDevice + ) { + guard batchJobs[device.id] == nil else { return } + let items = pendingUpdates(in: catalog, installedApps: installedApps, on: device) + guard !items.isEmpty else { return } + let batch = AppUpdateBatch(items: items, currentAppID: items.first?.id) + updateBatches[device.id] = batch + for item in items { + let key = Key(appID: item.id, deviceID: device.id) + errors[key] = nil + phases[key] = .queued + } + batchJobs[device.id] = Task { [self] in + defer { + updateBatches[device.id]?.currentAppID = nil + updateBatches[device.id]?.isRunning = false + batchJobs[device.id] = nil + } + for item in items { + guard updateBatches[device.id]?.outcomes[item.id] == nil else { continue } + updateBatches[device.id]?.currentAppID = item.id + let job = startInstallation( + app: item.app, version: item.version, on: device, completion: didReplace, + fromQueue: true + ) { [weak self] result in + guard let self, updateBatches[device.id]?.id == batch.id else { return } + let outcome: AppUpdateBatch.Outcome + switch result { + case .success: outcome = .updated + case .failure(let error) where error is CancellationError: outcome = .canceled + case .failure(let error): outcome = .failed(error.localizedDescription) + } + updateBatches[device.id]?.outcomes[item.id] = outcome + } + if let job { + await job.value + } else { + updateBatches[device.id]?.outcomes[item.id] = .canceled + if phase(appID: item.id, deviceID: device.id) == .queued { + phases[Key(appID: item.id, deviceID: device.id)] = nil + } + } + } + } + } + + func cancelAllUpdates(deviceID: String) { + guard let batch = updateBatches[deviceID], batch.isRunning, !batch.isStopping else { return } + updateBatches[deviceID]?.isStopping = true + for item in batch.items where updateBatches[deviceID]?.outcomes[item.id] == nil { + cancel(appID: item.id, deviceID: deviceID) + } + } + + func dismissUpdateBatch(deviceID: String) { + guard updateBatches[deviceID]?.isRunning == false else { return } + updateBatches[deviceID] = nil + } } diff --git a/src/AppUpdateBatch.swift b/src/AppUpdateBatch.swift new file mode 100644 index 0000000..af565b8 --- /dev/null +++ b/src/AppUpdateBatch.swift @@ -0,0 +1,41 @@ +import Foundation + +struct AppUpdateBatch { + struct Item: Identifiable { + let app: CatalogApp + let version: CatalogVersion + var id: String { app.id } + } + + enum Outcome { + case updated + case failed(String) + case canceled + } + + let id = UUID() + let items: [Item] + var outcomes: [String: Outcome] = [:] + var currentAppID: String? + var isRunning = true + var isStopping = false + + var total: Int { items.count } + var completed: Int { outcomes.count } + var updated: Int { + outcomes.values.filter { if case .updated = $0 { true } else { false } }.count + } + var failed: Int { outcomes.values.filter { if case .failed = $0 { true } else { false } }.count } + var canceled: Int { + outcomes.values.filter { if case .canceled = $0 { true } else { false } }.count + } + + var currentApp: CatalogApp? { items.first { $0.id == currentAppID }?.app } + + var failures: [(app: CatalogApp, message: String)] { + items.compactMap { item in + guard case .failed(let message) = outcomes[item.id] else { return nil } + return (item.app, message) + } + } +} diff --git a/src/AppUpdateBatchView.swift b/src/AppUpdateBatchView.swift new file mode 100644 index 0000000..26bc184 --- /dev/null +++ b/src/AppUpdateBatchView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct AppUpdateAllButton: View { + let isUpdating: Bool + let canUpdate: Bool + let action: () -> Void + + var body: some View { + Group { + if isUpdating { + button.buttonStyle(.bordered) + } else { + button.buttonStyle(.borderedProminent) + } + } + .accessibilityIdentifier("update-all") + } + + private var button: some View { + Button(action: action) { + StableSizeView(isUpdating, states: [false, true]) { updating in + if updating { Text("Updating…") } else { Text("Update All") } + } + } + .disabled(isUpdating || !canUpdate) + } +} + +struct AppUpdateBatchView: View { + let batch: AppUpdateBatch + let phase: AppInstallationPhase? + let cancel: () -> Void + let dismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + heading.fontWeight(.medium) + Spacer(minLength: 0) + if batch.isRunning { + Text(verbatim: "\(batch.completed) / \(batch.total)").monospacedDigit() + .foregroundStyle(.secondary) + Button("Cancel All", action: cancel).disabled(batch.isStopping) + } else { + Button("Dismiss", action: dismiss) + } + } + if batch.isRunning { + ProgressView(value: Double(batch.completed), total: Double(batch.total)) + .accessibilityLabel(Text(progressTitle)) + if let app = batch.currentApp { + HStack(spacing: 12) { + Text(verbatim: app.name).lineLimit(1) + Spacer(minLength: 0) + if let phase { Text(verbatim: phase.title) } + } + .foregroundStyle(.secondary) + } + } else { + Text("\(batch.updated) updated, \(batch.failed) failed, \(batch.canceled) canceled") + .foregroundStyle(.secondary) + if batch.failed > 0 { + DisclosureGroup("Failed Updates") { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(batch.failures, id: \.app.id) { failure in + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: failure.app.name).fontWeight(.medium) + Text(verbatim: failure.message).foregroundStyle(.secondary) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 8) + } + .frame(maxHeight: 160) + } + .disclosureGroupStyle(DetailsDisclosureStyle()) + } + } + } + .font(.system(size: 12)) + .padding(12) + .background(Color.accentColor.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + .accessibilityIdentifier("update-batch") + } + + private var progressTitle: LocalizedStringKey { + batch.isStopping ? "Stopping Updates…" : "Updating Apps" + } + + @ViewBuilder + private var heading: some View { + if batch.isRunning { + Text(progressTitle) + } else if batch.failed > 0 { + Label("Some Updates Failed", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } else if batch.canceled > 0 { + Text("Updates Canceled") + } else { + Label("Updates Complete", systemImage: "checkmark.circle.fill").foregroundStyle(.green) + } + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index bb8b516..dbf759d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,8 @@ add_executable(SimStore MACOSX_BUNDLE AppInstaller.swift AppInstallationPhase.swift AppInstallationStore.swift + AppUpdateBatch.swift + AppUpdateBatchView.swift AppInstallationAction.swift AppInstallButton.swift AppUninstallButton.swift @@ -34,6 +36,7 @@ add_executable(SimStore MACOSX_BUNDLE CatalogAppCard.swift CatalogAppDetailView.swift CopyableText.swift + DetailsDisclosureStyle.swift SidebarView.swift NoSimulatorsView.swift MissingXcodeView.swift diff --git a/src/CatalogAppCard.swift b/src/CatalogAppCard.swift index dca0cca..ac6acc2 100644 --- a/src/CatalogAppCard.swift +++ b/src/CatalogAppCard.swift @@ -13,7 +13,9 @@ struct CatalogAppCard: View { private var showsInstallation: Bool { section != .discover } var body: some View { - let version = app.recommendedVersion(for: runtime) + let version = + installations.activeUpdateVersion(appID: app.id, deviceID: device.id) + ?? app.recommendedVersion(for: runtime) VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { diff --git a/src/CatalogAppDetailView.swift b/src/CatalogAppDetailView.swift index 29c15f0..f0c95b1 100644 --- a/src/CatalogAppDetailView.swift +++ b/src/CatalogAppDetailView.swift @@ -20,13 +20,24 @@ struct CatalogAppDetailView: View { self.device = device self.installedApps = installedApps self.installations = installations - _versionID = State(initialValue: app.recommendedVersion(for: device.runtime).version) + _versionID = State( + initialValue: installations.activeUpdateVersion(appID: app.id, deviceID: device.id)?.version + ?? app.recommendedVersion(for: device.runtime).version) } private var runtime: String { device.runtime } private var version: CatalogVersion { - app.versions.first(where: { $0.version == versionID }) ?? app.recommendedVersion(for: runtime) + installations.activeUpdateVersion(appID: app.id, deviceID: device.id) + ?? app.versions.first(where: { $0.version == versionID }) + ?? app.recommendedVersion(for: runtime) + } + + private var versions: [CatalogVersion] { + guard let active = installations.activeUpdateVersion(appID: app.id, deviceID: device.id), + !app.versions.contains(where: { $0.id == active.id }) + else { return app.versions } + return [active] + app.versions } private var contentHeight: CGFloat { @@ -66,7 +77,7 @@ struct CatalogAppDetailView: View { GridRow { Text("Version").foregroundStyle(.secondary) Picker(selection: $versionID) { - ForEach(app.versions) { build in + ForEach(versions) { build in Text(verbatim: build.version).tag(build.version) } } label: { @@ -240,30 +251,3 @@ private struct DetailContentHeight: PreferenceKey { value = max(value, nextValue()) } } - -private struct DetailsDisclosureStyle: DisclosureGroupStyle { - func makeBody(configuration: Configuration) -> some View { - VStack(alignment: .leading, spacing: 0) { - Button { - withAnimation { configuration.isExpanded.toggle() } - } label: { - HStack(spacing: 6) { - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) - .accessibilityHidden(true) - configuration.label - Spacer(minLength: 0) - } - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if configuration.isExpanded { - configuration.content.padding(.leading, 16) - } - } - } -} diff --git a/src/CatalogBrowserView.swift b/src/CatalogBrowserView.swift index 9564e77..2067168 100644 --- a/src/CatalogBrowserView.swift +++ b/src/CatalogBrowserView.swift @@ -31,6 +31,10 @@ struct CatalogBrowserView: View { } } + private var updateBatch: AppUpdateBatch? { + installations.updateBatch(deviceID: device.id) + } + private var apps: [CatalogApp] { (availableApps ?? []).filter { app in (category.isEmpty || app.categories.contains(category)) @@ -45,6 +49,15 @@ struct CatalogBrowserView: View { VStack(alignment: .leading, spacing: 20) { heading simulatorSummary + if section == .updates, let batch = updateBatch { + AppUpdateBatchView( + batch: batch, + phase: batch.currentApp.flatMap { + installations.phase(appID: $0.id, deviceID: device.id) + }, + cancel: { installations.cancelAllUpdates(deviceID: device.id) }, + dismiss: { installations.dismissUpdateBatch(deviceID: device.id) }) + } if let catalog = store.catalog { if let error = store.error { HStack(alignment: .top, spacing: 10) { @@ -168,6 +181,16 @@ struct CatalogBrowserView: View { Spacer(minLength: 0) Text(verbatim: "\(apps.count) / \(availableApps.count)") .monospacedDigit().foregroundStyle(.secondary) + if section == .updates { + AppUpdateAllButton( + isUpdating: updateBatch?.isRunning == true, + canUpdate: !installations.pendingUpdates( + in: catalog, installedApps: installedApps, on: device + ).isEmpty + ) { + installations.updateAll(in: catalog, installedApps: installedApps, on: device) + } + } CatalogRefreshButton(isLoading: store.isLoading, refresh: refresh) .labelStyle(.iconOnly) } diff --git a/src/DetailsDisclosureStyle.swift b/src/DetailsDisclosureStyle.swift new file mode 100644 index 0000000..e6a1f1b --- /dev/null +++ b/src/DetailsDisclosureStyle.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct DetailsDisclosureStyle: DisclosureGroupStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation { configuration.isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) + .accessibilityHidden(true) + configuration.label + Spacer(minLength: 0) + } + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if configuration.isExpanded { + configuration.content.padding(.leading, 16) + } + } + } +} diff --git a/tests/AppInstallationTests.swift b/tests/AppInstallationTests.swift index 79965c8..e70c5d3 100644 --- a/tests/AppInstallationTests.swift +++ b/tests/AppInstallationTests.swift @@ -19,6 +19,7 @@ struct AppInstallationTests { try versionActions() try await replacementPipeline() try await replacementWhileSwitchingDevices() + try await AppUpdateBatchTests.run() print( "PASS: package verification, safe extraction, Simulator binaries, install/open, per-device jobs, cancellation, failures, and cleanup." ) diff --git a/tests/AppUpdateBatchTests.swift b/tests/AppUpdateBatchTests.swift new file mode 100644 index 0000000..e796aa8 --- /dev/null +++ b/tests/AppUpdateBatchTests.swift @@ -0,0 +1,412 @@ +import Foundation + +@MainActor +enum AppUpdateBatchTests { + static func run() async throws { + try await selectionAndSerialExecution() + try await failureAndRetry() + try await cancellation() + try await cancellationDuringInstall() + try await separateDevices() + print( + "PASS: Update All selection, queue reservations, serial execution, retry, cancellation, and per-device isolation." + ) + } + + private static func selectionAndSerialExecution() async throws { + let fixture = try BatchFixture(ids: [ + "alpha", "bravo", "current", "future", "unknown", "system", "missing", + ]) + defer { fixture.remove() } + let downloads = BatchDownloads(data: fixture.data) + let commands = BatchCommands() + let inventory = InstalledAppsStore(load: { _ in + fixture.apps.filter { $0.id != "missing" }.map { app in + SimulatorApplication( + bundleID: app.bundleID, + version: app.id == "unknown" ? nil : app.id == "current" ? "2.0.0" : "0.9.0", + isUserApplication: app.id != "system") + } + }) + let store = fixture.store(downloads: downloads, commands: commands, inventory: inventory) + let device = InstallationFixtures.device + var apps = fixture.apps + let future = CatalogVersion( + version: "2.0.0", minSimulatorOS: "28.0", sha256: apps[0].latestVersion.sha256, + size: apps[0].latestVersion.size, revision: apps[0].latestVersion.revision, + recipeRevision: apps[0].latestVersion.recipeRevision, time: 0) + apps[0] = BatchFixture.app(id: "alpha", versions: [future, apps[0].latestVersion]) + apps[3] = BatchFixture.app(id: "future", versions: [future]) + let catalog = AppCatalog(schemaVersion: 1, apps: apps) + store.updateAll(in: catalog, installedApps: inventory, on: device) + try InstallationFixtures.require( + store.updateBatch(deviceID: device.id) == nil, "An unknown inventory cannot start a batch.") + await inventory.refresh(for: device) + try InstallationFixtures.require( + store.pendingUpdates(in: catalog, installedApps: inventory, on: device).map(\.id) == [ + "alpha", "bravo", + ], + "Only installed user apps with newer compatible versions may be queued.") + store.updateAll(in: catalog, installedApps: inventory, on: device) + let firstID = store.updateBatch(deviceID: device.id)?.id + try InstallationFixtures.require( + store.phase(appID: "bravo", deviceID: device.id) == .queued, + "Reserve queued apps before returning to the UI.") + try InstallationFixtures.require( + store.activeUpdateVersion(appID: "alpha", deviceID: device.id)?.version == "1.0.0", + "Rows and details must retain the selected update's version, size, and checksum while queued." + ) + store.updateAll(in: catalog, installedApps: inventory, on: device) + store.install(app: apps[1], version: apps[1].latestVersion, on: device) + store.replace(app: apps[1], version: apps[1].latestVersion, on: device) + store.open(app: apps[1], on: device) + store.uninstall(app: apps[1], on: device) + try await downloads.waitForCount(1) + let initialDownloads = await downloads.count + try InstallationFixtures.require( + store.updateBatch(deviceID: device.id)?.id == firstID && initialDownloads == 1, + "Repeated Update All and per-app actions must not duplicate reserved jobs.") + try InstallationFixtures.require( + await downloads.urls.first + == AppConfiguration.assetURL( + appID: "alpha", version: "1.0.0", filename: "alpha.app.tar.bz2"), + "Use the compatible historical build rather than the incompatible latest build.") + await downloads.finish(0) + try await downloads.waitForCount(2) + try InstallationFixtures.require( + commands.installs.count == 1 && store.updateBatch(deviceID: device.id)?.updated == 1, + "The next download starts only after the previous installation has finished.") + await downloads.finish(1) + try await finished(store, device.id) + try InstallationFixtures.require( + store.updateBatch(deviceID: device.id)?.updated == 2 + && store.updateBatch(deviceID: device.id)?.completed == 2, + "Report every successful update.") + try InstallationFixtures.require( + store.pendingUpdates(in: catalog, installedApps: inventory, on: device).isEmpty, + "Successful updates must immediately update the installed inventory.") + try InstallationFixtures.require( + store.activeUpdateVersion(appID: "alpha", deviceID: device.id) == nil, + "Completed jobs return to the current catalog rather than pinning an old build.") + try InstallationFixtures.require( + commands.installs.allSatisfy { $0.deviceID == device.id }, "Keep the captured target device.") + try fixture.requireClean() + let stopped = SimulatorDevice( + id: device.id, name: device.name, runtime: device.runtime, state: "Shutdown") + try InstallationFixtures.require( + store.pendingUpdates(in: catalog, installedApps: inventory, on: stopped).isEmpty, + "A stopped simulator cannot be updated.") + } + + private static func failureAndRetry() async throws { + let fixture = try BatchFixture(ids: ["alpha", "bravo", "charlie"]) + defer { fixture.remove() } + let downloads = BatchDownloads(data: fixture.data, blocked: false, failures: ["alpha"]) + let commands = BatchCommands(failures: ["bravo"]) + let inventory = fixture.inventory() + let device = InstallationFixtures.device + await inventory.refresh(for: device) + let store = fixture.store(downloads: downloads, commands: commands, inventory: inventory) + store.updateAll(in: fixture.catalog, installedApps: inventory, on: device) + try await finished(store, device.id) + let result = store.updateBatch(deviceID: device.id) + try InstallationFixtures.require( + result?.updated == 1 && result?.failed == 2 && result?.canceled == 0, + "Download and installation failures must not prevent later apps from updating.") + try InstallationFixtures.require( + result?.failures.map(\.app.id) == ["alpha", "bravo"], "Keep each failed app's diagnostic.") + try InstallationFixtures.require( + inventory.application(bundleID: fixture.apps[0].bundleID, deviceID: device.id)?.version + == "0.9.0" + && inventory.application(bundleID: fixture.apps[1].bundleID, deviceID: device.id)?.version + == "0.9.0", + "A failed update must preserve the installed version.") + try fixture.requireClean() + await downloads.clearFailures() + commands.clearFailures() + store.updateAll(in: fixture.catalog, installedApps: inventory, on: device) + try await finished(store, device.id) + try InstallationFixtures.require( + store.updateBatch(deviceID: device.id)?.updated == 2 + && store.updateBatch(deviceID: device.id)?.failed == 0, + "Update All must retry only the remaining updates.") + try InstallationFixtures.require( + await downloads.appIDs == ["alpha", "bravo", "charlie", "alpha", "bravo"], + "Do not reinstall a successful batch member on retry.") + try fixture.requireClean() + } + + private static func cancellation() async throws { + let fixture = try BatchFixture(ids: ["alpha", "bravo", "charlie"]) + defer { fixture.remove() } + let downloads = BatchDownloads(data: fixture.data) + let commands = BatchCommands() + let inventory = fixture.inventory() + let device = InstallationFixtures.device + await inventory.refresh(for: device) + let store = fixture.store(downloads: downloads, commands: commands, inventory: inventory) + store.updateAll(in: fixture.catalog, installedApps: inventory, on: device) + try await downloads.waitForCount(1) + store.cancel(appID: "bravo", deviceID: device.id) + try InstallationFixtures.require( + store.phase(appID: "bravo", deviceID: device.id) == nil, + "Canceling a queued app must release it immediately.") + store.replace(app: fixture.apps[1], version: fixture.apps[1].latestVersion, on: device) + try await downloads.waitForCount(2) + store.cancelAllUpdates(deviceID: device.id) + try InstallationFixtures.require( + store.phase(appID: "alpha", deviceID: device.id) == .canceling + && store.phase(appID: "charlie", deviceID: device.id) == nil, + "Stop the active download and immediately remove the remaining queue.") + try InstallationFixtures.require( + store.phase(appID: "bravo", deviceID: device.id) == .downloading(0), + "Cancel All must not cancel a separate manual retry of a previously canceled queue entry.") + await downloads.finish(0) + try await finished(store, device.id) + try InstallationFixtures.require( + store.updateBatch(deviceID: device.id)?.canceled == 3 && commands.installs.isEmpty, + "Cancellation must not install an unfinished download or start later queue entries.") + await downloads.finish(1) + try await InstallationFixtures.eventually { + store.phase(appID: "bravo", deviceID: device.id) == nil + } + try InstallationFixtures.require( + commands.installs.count == 1 && commands.installs.first?.appID == "bravo", + "The independent manual update must finish normally.") + store.dismissUpdateBatch(deviceID: device.id) + try InstallationFixtures.require( + store.updateBatch(deviceID: device.id) == nil, "Finished batch results can be dismissed.") + try fixture.requireClean() + } + + private static func cancellationDuringInstall() async throws { + let fixture = try BatchFixture(ids: ["alpha", "bravo"]) + defer { fixture.remove() } + let downloads = BatchDownloads(data: fixture.data) + let commands = BatchCommands(blocking: "alpha") + defer { commands.finish.signal() } + let inventory = fixture.inventory() + let device = InstallationFixtures.device + await inventory.refresh(for: device) + let store = fixture.store(downloads: downloads, commands: commands, inventory: inventory) + store.updateAll(in: fixture.catalog, installedApps: inventory, on: device) + try await downloads.waitForCount(1) + await downloads.finish(0) + try await InstallationFixtures.eventually { commands.installs.count == 1 } + store.cancelAllUpdates(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.") + 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.") + } + + private static func separateDevices() async throws { + let fixture = try BatchFixture(ids: ["alpha", "bravo"]) + defer { fixture.remove() } + let downloads = BatchDownloads(data: fixture.data) + let commands = BatchCommands() + let inventory = fixture.inventory() + let a = InstallationFixtures.device + let b = SimulatorDevice(id: "device-B", name: "iPad", runtime: a.runtime, state: "Booted") + await inventory.refresh(for: a) + let store = fixture.store(downloads: downloads, commands: commands, inventory: inventory) + store.updateAll(in: fixture.catalog, installedApps: inventory, on: a) + try await downloads.waitForCount(1) + await inventory.refresh(for: b) + store.updateAll(in: fixture.catalog, installedApps: inventory, on: b) + try await downloads.waitForCount(2) + await downloads.finish(1) + try await downloads.waitForCount(3) + await downloads.finish(2) + try await finished(store, b.id) + try InstallationFixtures.require( + store.updateBatch(deviceID: a.id)?.isRunning == true, + "Different simulators keep independent batches.") + for app in fixture.apps { + inventory.recordReplacement( + SimulatorApplication(bundleID: app.bundleID, version: "9.0.0", isUserApplication: true), + deviceID: b.id) + } + await downloads.finish(0) + try await downloads.waitForCount(4) + await downloads.finish(3) + try await finished(store, a.id) + try InstallationFixtures.require( + commands.installs.map(\.deviceID) == [b.id, b.id, a.id, a.id], + "Switching devices cannot retarget an active or queued update.") + try InstallationFixtures.require( + inventory.deviceID == b.id + && inventory.applications?.allSatisfy { $0.version == "9.0.0" } == true, + "Background completion must not overwrite the selected device's inventory.") + try fixture.requireClean() + } + + private static func finished(_ store: AppInstallationStore, _ deviceID: String) async throws { + try await InstallationFixtures.eventually { + store.updateBatch(deviceID: deviceID)?.isRunning == false + } + } +} + +private struct BatchFixture: Sendable { + let directory: URL + let apps: [CatalogApp] + let data: [String: Data] + var catalog: AppCatalog { AppCatalog(schemaVersion: 1, apps: apps) } + + init(ids: [String]) throws { + directory = try InstallationFixtures.temporaryDirectory() + var apps: [CatalogApp] = [] + var data: [String: Data] = [:] + for id in ids { + let entries = [ + InstallationFixtures.Entry(path: "Example.app/", type: 53, mode: 0o755), + InstallationFixtures.Entry( + path: "Example.app/Info.plist", + data: try InstallationFixtures.info(bundleID: "org.example.Bulk.\(id)")), + InstallationFixtures.Entry( + path: "Example.app/Example", data: InstallationFixtures.machO(), mode: 0o755), + ] + let bytes = try Data(contentsOf: InstallationFixtures.archive(entries, in: directory)) + apps.append(Self.app(id: id, versions: [InstallationFixtures.version(data: bytes)])) + data[id] = bytes + } + self.apps = apps + self.data = data + } + + static func app(id: String, versions: [CatalogVersion]) -> CatalogApp { + CatalogApp( + id: id, name: id, bundleID: "org.example.Bulk.\(id)", + repository: URL(string: "https://example.com/\(id)")!, license: "MIT", + description: ["en": "An app."], categories: ["utilities"], versions: versions) + } + + @MainActor + func inventory() -> InstalledAppsStore { + InstalledAppsStore(load: { _ in + apps.map { + SimulatorApplication(bundleID: $0.bundleID, version: "0.9.0", isUserApplication: true) + } + }) + } + + @MainActor + func store(downloads: BatchDownloads, commands: BatchCommands, inventory: InstalledAppsStore) + -> AppInstallationStore + { + AppInstallationStore( + installer: AppInstaller( + download: { try await downloads.fetch($0, $1, $2) }, command: commands.run, + temporaryDirectory: directory.appending(component: "work")), showDevice: { _ in }, + didReplace: { inventory.recordReplacement($0, deviceID: $1) }) + } + + func requireClean() throws { + try InstallationFixtures.require( + try FileManager.default.contentsOfDirectory( + atPath: directory.appending(component: "work").path + ).isEmpty, + "Clean every batch member's temporary data.") + } + + func remove() { try? FileManager.default.removeItem(at: directory) } +} + +private actor BatchDownloads { + let data: [String: Data] + let blocked: Bool + private var failures: Set + private var pending: [Int: CheckedContinuation] = [:] + private(set) var urls: [URL] = [] + var count: Int { urls.count } + var appIDs: [String] { + urls.map { $0.lastPathComponent.replacingOccurrences(of: ".app.tar.bz2", with: "") } + } + + init(data: [String: Data], blocked: Bool = true, failures: Set = []) { + self.data = data + self.blocked = blocked + self.failures = failures + } + + func fetch(_ url: URL, _ destination: URL, _ progress: @escaping AppDownload.Progress) + async throws + { + let index = count + urls.append(url) + let id = appIDs[index] + if failures.contains(id) { throw InstallationFixtures.failure("Download failed: \(id)") } + if blocked { await withCheckedContinuation { pending[index] = $0 } } + try Task.checkCancellation() + guard let bytes = data[id] else { + throw InstallationFixtures.failure("Unexpected download: \(url)") + } + progress(Int64(bytes.count)) + try bytes.write(to: destination) + } + + func clearFailures() { failures = [] } + func finish(_ index: Int) { pending.removeValue(forKey: index)?.resume() } + + func waitForCount(_ expected: Int) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while count < expected { + guard ContinuousClock.now < deadline else { + throw InstallationFixtures.failure("A queued update did not start.") + } + try await Task.sleep(for: .milliseconds(1)) + } + } +} + +private final class BatchCommands: @unchecked Sendable { + struct Install { + let appID: String + let deviceID: String + } + let finish = DispatchSemaphore(value: 0) + private let lock = NSLock() + private let blocking: String? + private var failures: Set + private var recorded: [Install] = [] + var installs: [Install] { lock.withLock { recorded } } + + init(failures: Set = [], blocking: String? = nil) { + self.failures = failures + self.blocking = blocking + } + + func clearFailures() { lock.withLock { failures = [] } } + + func run(_ executable: String, _ arguments: [String]) throws -> Data { + try InstallationFixtures.require(!Thread.isMainThread, "Batch commands must not block the UI.") + if executable == "/usr/bin/tar" { return try Command.run(executable, arguments: arguments) } + try InstallationFixtures.require( + executable == "/usr/bin/xcrun" && arguments.prefix(2) == ["simctl", "install"], + "Update through simctl install without uninstalling or erasing app data.") + let info = + try PropertyListSerialization.propertyList( + from: Data( + contentsOf: URL(fileURLWithPath: arguments[3]).appending(component: "Info.plist")), + format: nil) as! [String: Any] + let id = (info["CFBundleIdentifier"] as! String).split(separator: ".").last.map(String.init)! + lock.withLock { recorded.append(Install(appID: id, deviceID: arguments[2])) } + if blocking == id { + try InstallationFixtures.require( + finish.wait(timeout: .now() + 5) == .success, "Release the blocked batch installation.") + } + if lock.withLock({ failures.contains(id) }) { + throw InstallationFixtures.failure("Install failed: \(id)") + } + return Data() + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ced8f9e..8a0f309 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,7 +52,7 @@ target_compile_options(SimStoreIconCacheTest PRIVATE -parse-as-library) add_test(NAME icon-cache COMMAND SimStoreIconCacheTest) set_tests_properties(icon-cache PROPERTIES TIMEOUT 20) -add_executable(SimStoreInstallationTest AppInstallationTests.swift AppDownloadTests.swift AppPackageTests.swift InstallationFixtures.swift +add_executable(SimStoreInstallationTest AppInstallationTests.swift AppDownloadTests.swift AppPackageTests.swift AppUpdateBatchTests.swift InstallationFixtures.swift "${PROJECT_SOURCE_DIR}/src/AppCatalog.swift" "${PROJECT_SOURCE_DIR}/src/AppConfiguration.swift" "${PROJECT_SOURCE_DIR}/src/AppDownload.swift" @@ -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/AppUpdateBatch.swift" "${PROJECT_SOURCE_DIR}/src/AppInstallationAction.swift" "${PROJECT_SOURCE_DIR}/src/InstalledAppsStore.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorApplications.swift" diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift index c88b355..093a206 100644 --- a/tests/LocalizationTests.swift +++ b/tests/LocalizationTests.swift @@ -57,6 +57,10 @@ struct LocalizationTests { ("Update and Restart", "Update and Restart", "更新并重新启动"), ("Install Nightly and Restart", "Install Nightly and Restart", "安装每夜构建并重新启动"), ("SimStore Is Up to Date", "SimStore Is Up to Date", "SimStore 已是最新版本"), + ("Update All", "Update All", "全部更新"), + ("Updating…", "Updating…", "正在更新…"), + ("Waiting…", "Waiting…", "等待中…"), + ("Cancel All", "Cancel All", "全部取消"), ] for (key, source, translated) in labels { try expect( @@ -64,6 +68,14 @@ struct LocalizationTests { } let tool = "Device Hub" + let updated = 2 + let failed = 1 + let canceled = 3 + try expect( + String( + localized: "\(updated) updated, \(failed) failed, \(canceled) canceled", bundle: bundle, + locale: locale), + english ? "2 updated, 1 failed, 3 canceled" : "2 个已更新,1 个失败,3 个已取消") let appName = "LocalSend" let targetName = "iPhone 测试" try expect(