diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index aed5d7a..1eeb33f 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -129,6 +129,22 @@ } } }, + "All Apps Are Up to Date": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All Apps Are Up to Date" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部应用已是最新" + } + } + } + }, "App Repository": { "localizations": { "en": { @@ -369,6 +385,22 @@ } } }, + "Change Installed Version?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change Installed Version?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更改已安装版本?" + } + } + } + }, "Check Again": { "localizations": { "en": { @@ -689,6 +721,22 @@ } } }, + "Downgrade": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downgrade" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "降级" + } + } + } + }, "Download Size": { "localizations": { "en": { @@ -753,6 +801,38 @@ } } }, + "Install %@ on %@? The installed version could not be compared.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install %1$@ on %2$@? The installed version could not be compared." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 %2$@ 上安装 %1$@?无法比较已安装版本。" + } + } + } + }, + "Install %@ on %@? This is an older version and may not work with existing app data.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install %1$@ on %2$@? This is an older version and may not work with existing app data." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 %2$@ 上安装 %1$@?这是较旧的版本,可能无法使用现有应用数据。" + } + } + } + }, "Install Xcode to add iPhone and iPad simulators.": { "localizations": { "en": { @@ -913,6 +993,38 @@ } } }, + "Newer compatible versions of your installed apps will appear here.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Newer compatible versions of your installed apps will appear here." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装应用有兼容的新版本时,会显示在这里。" + } + } + } + }, + "Newer versions for your simulator.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Newer versions for your simulator." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "适用于此模拟器的新版本。" + } + } + } + }, "No Apps Available": { "localizations": { "en": { @@ -1889,6 +2001,22 @@ } } }, + "Update": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, "Update SimStore to read this app catalog.": { "localizations": { "en": { diff --git a/src/AppCatalog.swift b/src/AppCatalog.swift index 8fb0f33..12fe392 100644 --- a/src/AppCatalog.swift +++ b/src/AppCatalog.swift @@ -34,7 +34,18 @@ struct CatalogApp: Identifiable, Hashable, Sendable { } func recommendedVersion(for runtime: String) -> CatalogVersion { - versions.first { $0.isCompatible(with: runtime) } ?? latestVersion + latestCompatibleVersion(for: runtime) ?? latestVersion + } + + func latestCompatibleVersion(for runtime: String) -> CatalogVersion? { + versions.first { $0.isCompatible(with: runtime) } + } + + func availableUpdate(from installedVersion: String?, runtime: String) -> CatalogVersion? { + guard let version = latestCompatibleVersion(for: runtime), + version.comparison(with: installedVersion) == .orderedDescending + else { return nil } + return version } } @@ -55,6 +66,14 @@ struct CatalogVersion: Identifiable, Hashable, Sendable { else { return false } return installed >= minimum } + + func comparison(with installedVersion: String?) -> ComparisonResult? { + guard let installedVersion, let installed = CatalogVersionNumber(installedVersion), + let candidate = CatalogVersionNumber(version) + else { return nil } + if candidate == installed { return .orderedSame } + return candidate > installed ? .orderedDescending : .orderedAscending + } } enum AppCatalogError: LocalizedError, Equatable { diff --git a/src/AppInstallButton.swift b/src/AppInstallButton.swift index d685fda..93ac877 100644 --- a/src/AppInstallButton.swift +++ b/src/AppInstallButton.swift @@ -5,52 +5,80 @@ struct AppInstallButton: View { let version: CatalogVersion let device: SimulatorDevice let store: AppInstallationStore - let isInstalled: Bool? + let installedApps: InstalledAppsStore var allowsIncompatible = false + var allowsVersionChanges = false var isDefaultAction = false - private enum Action: CaseIterable { - case install, open, cancel, installing, canceling, opening, uninstalling + @State private var showsConfirmation = false + @State private var pendingVersionChange: VersionChange? - var title: String { - switch self { - case .install: String(localized: "Install") - case .open: String(localized: "Open") - case .cancel: String(localized: "Cancel") - case .installing: AppInstallationPhase.installing.title - case .canceling: AppInstallationPhase.canceling.title - case .opening: AppInstallationPhase.opening.title - case .uninstalling: AppInstallationPhase.uninstalling.title - } - } + private struct VersionChange { + let app: CatalogApp + let version: CatalogVersion + let device: SimulatorDevice + let action: AppInstallationAction + } + + private var isInstalled: Bool? { + installedApps.isInstalled(bundleID: app.bundleID, deviceID: device.id) + } + + private var idleAction: AppInstallationAction { + .forVersion( + version, installed: installedApps.application(bundleID: app.bundleID, deviceID: device.id), + allowsChanges: allowsVersionChanges) } private var phase: AppInstallationPhase? { store.phase(appID: app.id, deviceID: device.id) } - private var action: Action { + private var action: AppInstallationAction { switch phase { case .downloading, .verifying, .extracting: .cancel case .installing: .installing case .canceling: .canceling case .opening: .opening case .uninstalling: .uninstalling - case nil: isInstalled == true ? .open : .install + case nil: idleAction } } private var enabled: Bool { if let phase { return phase.canCancel } guard device.state == "Booted", let isInstalled else { return false } - return isInstalled || allowsIncompatible || version.isCompatible(with: device.runtime) + if idleAction == .open { return isInstalled } + return allowsIncompatible || version.isCompatible(with: device.runtime) } var body: some View { - if phase == nil { - button.buttonStyle(.borderedProminent) - } else { - button.buttonStyle(.bordered) + Group { + if phase == nil { + button.buttonStyle(.borderedProminent) + } else { + button.buttonStyle(.bordered) + } + } + .alert( + "Change Installed Version?", isPresented: $showsConfirmation, presenting: pendingVersionChange + ) { request in + Button("Cancel", role: .cancel) {} + Button(role: .destructive) { + store.replace(app: request.app, version: request.version, on: request.device) + } label: { + Text(verbatim: request.action.title) + } + } message: { request in + if request.action == .downgrade { + Text( + "Install \(request.version.version) on \(request.device.name)? This is an older version and may not work with existing app data." + ) + } else { + Text( + "Install \(request.version.version) on \(request.device.name)? The installed version could not be compared." + ) + } } } @@ -58,12 +86,20 @@ struct AppInstallButton: View { Button { switch action { case .install: store.install(app: app, version: version, on: device) + case .update, .downgrade, .replace: + if action.needsConfirmation { + pendingVersionChange = VersionChange( + app: app, version: version, device: device, action: action) + showsConfirmation = true + } else { + store.replace(app: app, version: version, on: device) + } case .open: store.open(app: app, on: device) case .cancel: store.cancel(appID: app.id, deviceID: device.id) case .installing, .canceling, .opening, .uninstalling: break } } label: { - StableSizeView(action, states: Action.allCases) { value in + StableSizeView(action, states: AppInstallationAction.allCases) { value in Text(verbatim: value.title) } .lineLimit(1) diff --git a/src/AppInstallationAction.swift b/src/AppInstallationAction.swift new file mode 100644 index 0000000..bb4df1f --- /dev/null +++ b/src/AppInstallationAction.swift @@ -0,0 +1,40 @@ +import Foundation + +enum AppInstallationAction: CaseIterable { + case install, open, update, downgrade, replace + case cancel, installing, canceling, opening, uninstalling + + var title: String { + switch self { + case .install: String(localized: "Install") + case .open: String(localized: "Open") + case .update: String(localized: "Update") + case .downgrade: String(localized: "Downgrade") + case .replace: Self.install.title + case .cancel: String(localized: "Cancel") + case .installing: AppInstallationPhase.installing.title + case .canceling: AppInstallationPhase.canceling.title + case .opening: AppInstallationPhase.opening.title + case .uninstalling: AppInstallationPhase.uninstalling.title + } + } + + var isVersionChange: Bool { + self == .update || self == .downgrade || self == .replace + } + + var needsConfirmation: Bool { self == .downgrade || self == .replace } + + static func forVersion( + _ version: CatalogVersion, installed: SimulatorApplication?, allowsChanges: Bool + ) -> Self { + guard let installed else { return .install } + guard allowsChanges, installed.isUserApplication else { return .open } + switch version.comparison(with: installed.version) { + case .orderedSame: return .open + case .orderedDescending: return .update + case .orderedAscending: return .downgrade + case nil: return .replace + } + } +} diff --git a/src/AppInstallationStore.swift b/src/AppInstallationStore.swift index 1d7d00b..b0d8c65 100644 --- a/src/AppInstallationStore.swift +++ b/src/AppInstallationStore.swift @@ -15,6 +15,7 @@ final class AppInstallationStore { private(set) var uninstalledApp: Key? @ObservationIgnored private let installer: AppInstaller @ObservationIgnored private let didInstall: @MainActor (SimulatorApplication, String) -> Void + @ObservationIgnored private let didReplace: @MainActor (SimulatorApplication, String) -> Void @ObservationIgnored private let didUninstall: @MainActor (String, String) -> Void @ObservationIgnored private let showDevice: @MainActor (SimulatorDevice) async throws -> Void @ObservationIgnored private let didOpen: @MainActor (String, String) -> Void @@ -25,11 +26,13 @@ final class AppInstallationStore { installer: AppInstaller = AppInstaller(), showDevice: @escaping @MainActor (SimulatorDevice) async throws -> Void, 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 } ) { self.installer = installer self.didInstall = didInstall + self.didReplace = didReplace self.didUninstall = didUninstall self.showDevice = showDevice self.didOpen = didOpen @@ -44,6 +47,17 @@ final class AppInstallationStore { } func install(app: CatalogApp, version: CatalogVersion, on device: SimulatorDevice) { + startInstallation(app: app, version: version, on: device, completion: didInstall) + } + + func replace(app: CatalogApp, version: CatalogVersion, on device: SimulatorDevice) { + startInstallation(app: app, version: version, on: device, completion: didReplace) + } + + private func startInstallation( + app: CatalogApp, version: CatalogVersion, on device: SimulatorDevice, + completion: @escaping @MainActor (SimulatorApplication, String) -> Void + ) { let key = Key(appID: app.id, deviceID: device.id) guard jobs[key] == nil else { return } let request = UUID() @@ -63,7 +77,7 @@ final class AppInstallationStore { phases[key] = phase } } - didInstall( + completion( SimulatorApplication( bundleID: app.bundleID, version: version.version, isUserApplication: true), device.id) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d5322ae..d3f37cc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(SimStore MACOSX_BUNDLE AppPackage.swift AppInstaller.swift AppInstallationStore.swift + AppInstallationAction.swift AppInstallButton.swift AppUninstallButton.swift DownloadProgressView.swift diff --git a/src/CatalogAppCard.swift b/src/CatalogAppCard.swift index b1831d3..dca0cca 100644 --- a/src/CatalogAppCard.swift +++ b/src/CatalogAppCard.swift @@ -5,11 +5,12 @@ struct CatalogAppCard: View { let device: SimulatorDevice let installedApps: InstalledAppsStore let installations: AppInstallationStore - let showsInstallation: Bool + let section: LibrarySection @FocusState.Binding var focusedAppID: String? let showDetails: () -> Void private var runtime: String { device.runtime } + private var showsInstallation: Bool { section != .discover } var body: some View { let version = app.recommendedVersion(for: runtime) @@ -35,35 +36,16 @@ struct CatalogAppCard: View { .allowsHitTesting(false) .accessibilityHidden(true) HStack { - HStack(spacing: 4) { - if let phase = installations.phase(appID: app.id, deviceID: device.id), !showsInstallation - { - Text(verbatim: phase.summary(totalBytes: version.size)).foregroundStyle(.secondary) - .monospacedDigit() - } else if installations.error(appID: app.id, deviceID: device.id) != nil { - Image(systemName: "exclamationmark.triangle").foregroundStyle(.red) - versionLabel(version) - } else if showsInstallation { - versionLabel(version) - } else if !version.isCompatible(with: runtime) { - Label("iOS \(version.minSimulatorOS)+", systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - } else { - Text(verbatim: version.version).monospacedDigit() - } - } - .lineLimit(1) - .allowsHitTesting(false) - .accessibilityHidden(true) + versionSummary(version) Spacer(minLength: 4) - if !showsInstallation { + if section != .installed { Text(verbatim: ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file)) .foregroundStyle(.secondary) .lineLimit(1) .allowsHitTesting(false) .accessibilityHidden(true) } - if showsInstallation { + if section == .installed { AppUninstallButton( app: app, device: device, installedApps: installedApps, installations: installations ) @@ -73,7 +55,7 @@ struct CatalogAppCard: View { } AppInstallButton( app: app, version: version, device: device, store: installations, - isInstalled: installedApps.isInstalled(bundleID: app.bundleID, deviceID: device.id) + installedApps: installedApps, allowsVersionChanges: section == .updates ) .controlSize(.small) .accessibilityIdentifier("catalog-install-\(app.id)") @@ -93,9 +75,9 @@ struct CatalogAppCard: View { .accessibilityValue( Text( verbatim: - showsInstallation - ? "\(app.displayDescription) · \(installedVersion)" - : "\(app.displayDescription) · \(version.version) · \(version.minimumOSLabel) · \(ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file))" + section == .installed + ? "\(app.displayDescription) · \(versionText(version))" + : "\(app.displayDescription) · \(versionText(version)) · \(version.minimumOSLabel) · \(ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file))" ) ) .accessibilityHint(Text("View app details and versions")) @@ -117,7 +99,37 @@ struct CatalogAppCard: View { ?? String(localized: "Version Unavailable") } - private func versionLabel(_ version: CatalogVersion) -> some View { - Text(verbatim: showsInstallation ? installedVersion : version.version).monospacedDigit() + private func versionText(_ version: CatalogVersion) -> String { + if !showsInstallation { return version.version } + if section == .updates, + version.comparison( + with: installedApps.application(bundleID: app.bundleID, deviceID: device.id)?.version) + == .orderedDescending + { + return "\(installedVersion) → \(version.version)" + } + return installedVersion + } + + private func versionSummary(_ version: CatalogVersion) -> some View { + HStack(spacing: 4) { + if let phase = installations.phase(appID: app.id, deviceID: device.id), + section != .installed, phase != .uninstalling + { + Text(verbatim: phase.summary(totalBytes: version.size)).foregroundStyle(.secondary) + .monospacedDigit() + } else if installations.error(appID: app.id, deviceID: device.id) != nil { + Image(systemName: "exclamationmark.triangle").foregroundStyle(.red) + Text(verbatim: versionText(version)).monospacedDigit() + } else if showsInstallation || version.isCompatible(with: runtime) { + Text(verbatim: versionText(version)).monospacedDigit() + } else { + Label("iOS \(version.minSimulatorOS)+", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + .lineLimit(1) + .allowsHitTesting(false) + .accessibilityHidden(true) } } diff --git a/src/CatalogAppDetailView.swift b/src/CatalogAppDetailView.swift index 9353afa..29c15f0 100644 --- a/src/CatalogAppDetailView.swift +++ b/src/CatalogAppDetailView.swift @@ -183,10 +183,27 @@ struct CatalogAppDetailView: View { .accessibilityIdentifier("detail-uninstall") } Spacer() + if AppInstallationAction.forVersion( + version, + installed: installedApps.application(bundleID: app.bundleID, deviceID: device.id), + allowsChanges: true + ).isVersionChange { + Button { + installations.open(app: app, on: device) + } label: { + Text(verbatim: AppInstallationAction.open.title) + } + .buttonStyle(.bordered) + .disabled( + device.state != "Booted" + || installations.phase(appID: app.id, deviceID: device.id) != nil + ) + .accessibilityIdentifier("detail-open") + } AppInstallButton( app: app, version: version, device: device, store: installations, - isInstalled: installedApps.isInstalled(bundleID: app.bundleID, deviceID: device.id), - allowsIncompatible: true, isDefaultAction: true + installedApps: installedApps, + allowsIncompatible: true, allowsVersionChanges: true, isDefaultAction: true ) .accessibilityIdentifier("detail-install") } diff --git a/src/CatalogBrowserView.swift b/src/CatalogBrowserView.swift index b1f1864..9564e77 100644 --- a/src/CatalogBrowserView.swift +++ b/src/CatalogBrowserView.swift @@ -18,8 +18,8 @@ struct CatalogBrowserView: View { @State private var showsSimulator = false @FocusState private var focusedAppID: String? - private var categories: [String] { - Array(Set(availableApps?.flatMap(\.categories) ?? [])).sorted() + private var categories: [String]? { + availableApps.map { Array(Set($0.flatMap(\.categories))).sorted() } } private var availableApps: [CatalogApp]? { @@ -27,6 +27,7 @@ struct CatalogBrowserView: View { switch section { case .discover: return installedApps.discoverableApps(in: catalog, for: device.id) case .installed: return installedApps.installedApps(in: catalog, for: device.id) + case .updates: return installedApps.updatableApps(in: catalog, for: device) } } @@ -92,7 +93,7 @@ struct CatalogBrowserView: View { dismissDetails(matching: uninstalled) } .onChange(of: categories) { _, values in - guard availableApps != nil else { return } + guard let values else { return } if !category.isEmpty && !values.contains(category) { category = "" } } .sheet(item: $selectedApp) { app in @@ -158,7 +159,7 @@ struct CatalogBrowserView: View { HStack(spacing: 12) { Picker("Category", selection: $category) { Text("All Apps").tag("") - ForEach(categories, id: \.self) { value in + ForEach(categories ?? [], id: \.self) { value in Text(verbatim: CatalogCategory.title(value)).tag(value) } } @@ -184,9 +185,24 @@ struct CatalogBrowserView: View { Text("Apps you install from this catalog will appear here.") } .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if availableApps.isEmpty && section == .updates { + ContentUnavailableView { + Label { + Text("All Apps Are Up to Date") + } icon: { + Image(systemName: "checkmark.circle").foregroundStyle(.green) + } + } description: { + Text("Newer compatible versions of your installed apps will appear here.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if availableApps.isEmpty { ContentUnavailableView { - Label("All Apps Are Installed", systemImage: "checkmark.circle") + Label { + Text("All Apps Are Installed") + } icon: { + Image(systemName: "checkmark.circle").foregroundStyle(.green) + } } description: { Text("Every app in the catalog is already installed on this simulator.") } @@ -216,7 +232,7 @@ struct CatalogBrowserView: View { let app = visibleApps[index] CatalogAppCard( app: app, device: device, installedApps: installedApps, - installations: installations, showsInstallation: section == .installed, + installations: installations, section: section, focusedAppID: $focusedAppID, showDetails: { selectedApp = app } ) diff --git a/src/ContentView.swift b/src/ContentView.swift index 70dd860..4418d3b 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -53,6 +53,9 @@ struct ContentView: View { didInstall: { application, deviceID in installedApps.recordInstallation(application, deviceID: deviceID) }, + didReplace: { application, deviceID in + installedApps.recordReplacement(application, deviceID: deviceID) + }, didUninstall: { bundleID, deviceID in installedApps.recordUninstallation(bundleID: bundleID, deviceID: deviceID) }, @@ -74,12 +77,17 @@ struct ContentView: View { discovery?.deviceManager } + private var updateCount: Int? { + guard let device = simulators.selectedDevice, let catalog = catalog.catalog else { return nil } + return installedApps.updatableApps(in: catalog, for: device, includingRecent: false)?.count + } + var body: some View { NavigationSplitView { GeometryReader { geometry in SidebarView( discovery: discovery, isChecking: simulators.isChecking, needsXcode: needsXcode, - section: $librarySection, + section: $librarySection, updateCount: updateCount, selectedDeviceID: simulators.selectedDeviceID, startingDeviceIDs: simulators.startingDeviceIDs, selectDevice: simulators.selectDevice, openRepository: openRepository @@ -123,6 +131,7 @@ struct ContentView: View { .navigationSplitViewStyle(.balanced) .frame(minWidth: 900, minHeight: 600) .task { await simulators.refresh() } + .onChange(of: librarySection) { _, _ in installedApps.clearRecentInstallations() } .task(id: shouldMonitorState) { while shouldMonitorState && !Task.isCancelled { do { diff --git a/src/InstalledAppsStore.swift b/src/InstalledAppsStore.swift index 93eb408..b2b9944 100644 --- a/src/InstalledAppsStore.swift +++ b/src/InstalledAppsStore.swift @@ -10,6 +10,7 @@ final class InstalledAppsStore { private(set) var isLoading = false private(set) var error: String? private var recentInstallations: Set = [] + private var recentReplacements: Set = [] @ObservationIgnored private let load: @Sendable (SimulatorDevice) async throws -> [SimulatorApplication] @@ -36,6 +37,25 @@ final class InstalledAppsStore { return catalog.apps.filter { installed.contains($0.bundleID.lowercased()) } } + func updatableApps( + in catalog: AppCatalog, for device: SimulatorDevice, includingRecent: Bool = true + ) -> [CatalogApp]? { + guard device.state == "Booted", self.deviceID == device.id, applications != nil else { + return nil + } + return catalog.apps.filter { app in + guard let installed = application(bundleID: app.bundleID, deviceID: device.id), + installed.isUserApplication + else { return false } + if app.availableUpdate(from: installed.version, runtime: device.runtime) != nil { + return true + } + return includingRecent && recentReplacements.contains(app.bundleID.lowercased()) + && app.latestCompatibleVersion(for: device.runtime)?.comparison(with: installed.version) + == .orderedSame + } + } + func application(bundleID: String, deviceID: String) -> SimulatorApplication? { guard self.deviceID == deviceID else { return nil } return applications?.first { $0.bundleID.caseInsensitiveCompare(bundleID) == .orderedSame } @@ -50,20 +70,34 @@ final class InstalledAppsStore { } } - func clearRecentInstallations() { recentInstallations = [] } + func clearRecentInstallations() { + recentInstallations = [] + recentReplacements = [] + } func removeRecentInstallation(bundleID: String, deviceID: String) { guard self.deviceID == deviceID else { return } recentInstallations.remove(bundleID.lowercased()) + recentReplacements.remove(bundleID.lowercased()) } func recordInstallation(_ application: SimulatorApplication, deviceID: String) { guard self.deviceID == deviceID else { return } + mergeInstallation(application) + recentInstallations.insert(application.bundleID.lowercased()) + } + + func recordReplacement(_ application: SimulatorApplication, deviceID: String) { + guard self.deviceID == deviceID else { return } + mergeInstallation(application) + recentReplacements.insert(application.bundleID.lowercased()) + } + + private func mergeInstallation(_ application: SimulatorApplication) { let bundleID = application.bundleID.lowercased() installedDuringRefresh.removeAll { $0.bundleID.lowercased() == bundleID } installedDuringRefresh.append(application) removedDuringRefresh.remove(bundleID) - recentInstallations.insert(bundleID) applications?.removeAll { $0.bundleID.lowercased() == bundleID } applications?.append(application) } @@ -74,6 +108,7 @@ final class InstalledAppsStore { installedDuringRefresh.removeAll { $0.bundleID.lowercased() == bundleID } removedDuringRefresh.insert(bundleID) recentInstallations.remove(bundleID) + recentReplacements.remove(bundleID) applications?.removeAll { $0.bundleID.lowercased() == bundleID } } diff --git a/src/LibrarySection.swift b/src/LibrarySection.swift index 3eb6e9e..bfc345e 100644 --- a/src/LibrarySection.swift +++ b/src/LibrarySection.swift @@ -1,12 +1,13 @@ import SwiftUI enum LibrarySection: String, CaseIterable { - case discover, installed + case discover, installed, updates var title: String { switch self { case .discover: String(localized: "Discover") case .installed: String(localized: "Installed") + case .updates: String(localized: "Updates") } } @@ -14,6 +15,7 @@ enum LibrarySection: String, CaseIterable { switch self { case .discover: String(localized: "Open-source favorites. Built for your simulator.") case .installed: String(localized: "Apps from the catalog on this simulator.") + case .updates: String(localized: "Newer versions for your simulator.") } } @@ -21,6 +23,7 @@ enum LibrarySection: String, CaseIterable { switch self { case .discover: "safari" case .installed: "square.grid.2x2" + case .updates: "arrow.triangle.2.circlepath" } } } diff --git a/src/SidebarView.swift b/src/SidebarView.swift index 156e658..2c4894a 100644 --- a/src/SidebarView.swift +++ b/src/SidebarView.swift @@ -7,6 +7,7 @@ struct SidebarView: View { let isChecking: Bool let needsXcode: Bool @Binding var section: LibrarySection + let updateCount: Int? let selectedDeviceID: String? let startingDeviceIDs: Set let selectDevice: (String) -> Void @@ -32,11 +33,25 @@ struct SidebarView: View { Button { section = item } label: { - Label { - Text(verbatim: item.title) - } icon: { - Image(systemName: item.symbolName) + HStack(spacing: 8) { + Label { + Text(verbatim: item.title) + } icon: { + Image(systemName: item.symbolName) + } + .frame(maxWidth: .infinity, alignment: .leading) + if item == .updates, let count = visibleUpdateCount { + Text(count, format: .number) + .font(.system(size: 10, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.white) + .padding(.horizontal, 6) + .frame(height: 18) + .background(Color.accentColor, in: Capsule()) + .accessibilityHidden(true) + } } + .frame(minHeight: 18) .font(.system(size: 13, weight: section == item ? .semibold : .regular)) .foregroundStyle(section == item ? Color.accentColor : Color.primary) .frame(maxWidth: .infinity, alignment: .leading) @@ -49,10 +64,13 @@ struct SidebarView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityLabel(Text(verbatim: item.title)) + .accessibilityValue( + Text(verbatim: item == .updates ? visibleUpdateCount?.formatted() ?? "" : "") + ) .accessibilityAddTraits(section == item ? .isSelected : []) .accessibilityIdentifier("library-\(item.rawValue)") } - sidebarPlaceholder("Updates", systemImage: "arrow.triangle.2.circlepath") } .padding(.horizontal, 12) .fixedSize(horizontal: false, vertical: true) @@ -143,12 +161,8 @@ struct SidebarView: View { private var simulatorListTitle: Text { Text("Simulators") } - private func sidebarPlaceholder(_ title: LocalizedStringKey, systemImage: String) -> some View { - Label(title, systemImage: systemImage) - .font(.system(size: 13)) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 11) + private var visibleUpdateCount: Int? { + guard let updateCount, updateCount > 0 else { return nil } + return updateCount } } diff --git a/tests/AppCatalogTests.swift b/tests/AppCatalogTests.swift index a8cf252..8c87f15 100644 --- a/tests/AppCatalogTests.swift +++ b/tests/AppCatalogTests.swift @@ -5,6 +5,7 @@ struct AppCatalogTests { static func main() throws { try decodeAndSort() try localizationAndCompatibility() + try compatibleUpdates() try rejectInvalidCatalogs() print("PASS: catalog decoding, version ordering, language fallback, and compatibility.") } @@ -56,6 +57,40 @@ struct AppCatalogTests { !entry.latestVersion.isCompatible(with: "unknown"), "Unknown runtime is not compatible.") } + static func compatibleUpdates() throws { + let entry = try decode(apps: [ + app(versions: [ + version("1.9.0", minimum: "16.0"), version("1.11.0", minimum: "28.0"), + version("1.10.0", minimum: "27.0"), + ]) + ]).apps[0] + try require( + entry.availableUpdate(from: "1.9.0", runtime: "27.0")?.version == "1.10.0", + "Offer the newest compatible build, comparing version components numerically.") + try require( + entry.availableUpdate(from: "1.8", runtime: "16.4")?.version == "1.9.0", + "An older runtime may update to a compatible historical build.") + for current in ["1.10", "1.10.0", "1.11.0", "2.0.0"] { + try require( + entry.availableUpdate(from: current, runtime: "27.0") == nil, + "Equal or newer installed versions must never be treated as updates.") + } + for current: String? in [ + nil, "", "1.0-beta", "v1.0.0", "1.0.0.1", String(repeating: "9", count: 30), + ] { + try require( + entry.availableUpdate(from: current, runtime: "27.0") == nil, + "Unknown and uncomparable installed versions cannot imply an automatic update.") + } + try require( + entry.availableUpdate(from: "1.0.0", runtime: "15.0") == nil + && entry.availableUpdate(from: "1.0.0", runtime: "unknown") == nil, + "No compatible build must produce no automatic update, rather than falling back to latest.") + try require( + entry.versions.last?.comparison(with: "1.10.0") == .orderedAscending, + "Selecting a historical version must distinguish a downgrade from an update.") + } + static func rejectInvalidCatalogs() throws { try reject(["schemaVersion": 2, "apps": []], expected: .unsupportedSchema) try reject(["schemaVersion": 1, "apps": [app(), app()]]) diff --git a/tests/AppInstallationTests.swift b/tests/AppInstallationTests.swift index 283a187..79965c8 100644 --- a/tests/AppInstallationTests.swift +++ b/tests/AppInstallationTests.swift @@ -16,6 +16,9 @@ struct AppInstallationTests { try await stoppedDevice() try await uninstallJobs() try await uninstallFailure() + try versionActions() + try await replacementPipeline() + try await replacementWhileSwitchingDevices() print( "PASS: package verification, safe extraction, Simulator binaries, install/open, per-device jobs, cancellation, failures, and cleanup." ) @@ -371,6 +374,113 @@ struct AppInstallationTests { } } + static func versionActions() throws { + let version = InstallationFixtures.version() + let cases: [(String?, AppInstallationAction)] = [ + ("0.9.0", .update), ("1.0", .open), ("2.0.0", .downgrade), (nil, .replace), + ("1.0-beta", .replace), + ] + for (installedVersion, expected) in cases { + let installed = SimulatorApplication( + bundleID: "org.example.App", version: installedVersion, isUserApplication: true) + try InstallationFixtures.require( + AppInstallationAction.forVersion(version, installed: installed, allowsChanges: true) + == expected, + "Choose an action that accurately describes replacing the installed version.") + try InstallationFixtures.require( + AppInstallationAction.forVersion(version, installed: installed, allowsChanges: false) + == .open, + "Installed cards keep Open rather than silently replacing an app.") + } + let system = SimulatorApplication( + bundleID: "com.apple.Example", version: "0.9.0", isUserApplication: false) + try InstallationFixtures.require( + AppInstallationAction.forVersion(version, installed: system, allowsChanges: true) == .open, + "Do not offer version replacement for system applications.") + } + + static func replacementPipeline() async throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try archiveData(in: directory) + let app = InstallationFixtures.app(version: InstallationFixtures.version(data: data)) + let device = InstallationFixtures.device + for fails in [false, true] { + let commands = InstallationCommands(failsInstallation: fails) + let inventory = InstalledAppsStore(load: { _ in + [SimulatorApplication(bundleID: app.bundleID, version: "0.9.0", isUserApplication: true)] + }) + await inventory.refresh(for: device) + var freshInstall = false + let store = AppInstallationStore( + installer: AppInstaller( + download: { _, destination, _ in try data.write(to: destination) }, command: commands.run, + temporaryDirectory: directory.appending(component: "work")), + showDevice: { _ in }, didInstall: { _, _ in freshInstall = true }, + didReplace: { inventory.recordReplacement($0, deviceID: $1) }) + store.replace(app: app, version: app.latestVersion, on: device) + try await InstallationFixtures.eventually { + store.phase(appID: app.id, deviceID: device.id) == nil + } + let simulatorCommands = commands.calls.filter { $0.first == "simctl" } + try InstallationFixtures.require( + simulatorCommands.count == 1 + && simulatorCommands[0].prefix(3) == ["simctl", "install", device.id], + "Replace through simctl install without an uninstall or erase step.") + try InstallationFixtures.require( + !freshInstall, "Replacement must not be recorded as a new Discover installation.") + try InstallationFixtures.require( + inventory.application(bundleID: app.bundleID, deviceID: device.id)?.version + == (fails ? "0.9.0" : "1.0.0"), + "Only a successful replacement may change the installed version.") + try InstallationFixtures.require( + (store.error(appID: app.id, deviceID: device.id) != nil) == fails, + "Report failed replacements and retain the old installed version for retry.") + } + } + + static func replacementWhileSwitchingDevices() 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 a = InstallationFixtures.device + let b = SimulatorDevice(id: "device-B", name: a.name, runtime: a.runtime, state: a.state) + let gate = InstallationDownloadGate(data: data) + let commands = InstallationCommands() + let inventory = InstalledAppsStore(load: { _ in + [SimulatorApplication(bundleID: app.bundleID, version: "0.9.0", isUserApplication: true)] + }) + await inventory.refresh(for: a) + var completedDevices: [String] = [] + let store = AppInstallationStore( + installer: AppInstaller( + download: { try await gate.download($0, $1, $2) }, command: commands.run, + temporaryDirectory: directory.appending(component: "work")), showDevice: { _ in }, + didReplace: { application, deviceID in + completedDevices.append(deviceID) + inventory.recordReplacement(application, deviceID: deviceID) + }) + store.replace(app: app, version: version, on: a) + try await gate.waitForCount(1) + await inventory.refresh(for: b) + store.replace(app: app, version: version, on: a) + store.install(app: app, version: version, on: a) + store.uninstall(app: app, on: a) + try InstallationFixtures.require( + await gate.count == 1, "Replacement shares the existing per-app/device job lock.") + await gate.finish(0) + try await InstallationFixtures.eventually { store.phase(appID: app.id, deviceID: a.id) == nil } + try InstallationFixtures.require( + completedDevices == [a.id] && commands.calls.last?.prefix(3) == ["simctl", "install", a.id], + "A background update must complete on its captured device.") + try InstallationFixtures.require( + inventory.deviceID == b.id + && inventory.application(bundleID: app.bundleID, deviceID: b.id)?.version == "0.9.0", + "A completed update on the previous device cannot replace the selected device's version.") + } + static func archiveData(in directory: URL) throws -> Data { try Data( contentsOf: InstallationFixtures.archive(InstallationFixtures.entries(), in: directory)) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 58ed554..ab37606 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -46,6 +46,7 @@ add_executable(SimStoreInstallationTest AppInstallationTests.swift AppDownloadTe "${PROJECT_SOURCE_DIR}/src/AppPackage.swift" "${PROJECT_SOURCE_DIR}/src/AppInstaller.swift" "${PROJECT_SOURCE_DIR}/src/AppInstallationStore.swift" + "${PROJECT_SOURCE_DIR}/src/AppInstallationAction.swift" "${PROJECT_SOURCE_DIR}/src/InstalledAppsStore.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorApplications.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" diff --git a/tests/InstalledAppsStoreTests.swift b/tests/InstalledAppsStoreTests.swift index a535db5..14345ed 100644 --- a/tests/InstalledAppsStoreTests.swift +++ b/tests/InstalledAppsStoreTests.swift @@ -14,6 +14,7 @@ struct InstalledAppsStoreTests { try await retainInstalledAppsForCurrentVisit() try await installedVersionsAndMatching() try await recordingUninstallationDuringRefresh() + try await updateFilteringAndRetention() print( "PASS: per-device installed apps, filtering, stale responses, errors, and cancellation.") } catch { @@ -323,6 +324,75 @@ struct InstalledAppsStoreTests { "The latest completed mutation wins when an app is reinstalled during a refresh.") } + static func updateFilteringAndRetention() async throws { + let current = catalog() + let store = InstalledAppsStore(load: { device in + [ + application("IO.EXAMPLE.INSTALLED", version: device.id == "A" ? "0.8.0" : "2.0.0"), + application("io.example.Available", version: nil), + SimulatorApplication( + bundleID: "io.example.Unknown", version: "0.8.0", isUserApplication: false), + ] + }) + try require( + store.updatableApps(in: current, for: device("A")) == nil, + "Unknown inventories must not claim no updates.") + await store.refresh(for: device("A")) + try require( + store.updatableApps(in: current, for: device("A"))?.map(\.id) == ["installed"], + "Offer updates only for matching user apps with comparable older versions.") + try require( + store.updatableApps(in: current, for: device("A"), includingRecent: false)?.count == 1, + "The sidebar count includes pending compatible updates.") + try require( + store.updatableApps(in: current, for: device("B")) == nil + && store.updatableApps(in: current, for: device("A", state: "Shutdown")) == nil, + "Updates belong to a known running device.") + store.recordReplacement(application("io.example.INSTALLED", version: "1.0.0"), deviceID: "A") + try require( + store.updatableApps(in: current, for: device("A"))?.map(\.id) == ["installed"], + "Retain a completed update for Open in the current visit.") + try require( + store.updatableApps(in: current, for: device("A"), includingRecent: false)?.isEmpty == true, + "A completed update retained for Open must not inflate the sidebar count.") + try require( + store.discoverableApps(in: current, for: "A")?.isEmpty == true, + "Updating an existing app must not expose it as newly installed in Discover.") + store.removeRecentInstallation(bundleID: "io.example.installed", deviceID: "B") + try require( + store.updatableApps(in: current, for: device("A"))?.count == 1, + "Another device's Open must not remove this card.") + store.removeRecentInstallation(bundleID: "io.example.installed", deviceID: "A") + try require( + store.updatableApps(in: current, for: device("A"))?.isEmpty == true, + "A successful Open removes a completed update.") + store.recordReplacement(application("io.example.Installed", version: "1.0.0"), deviceID: "A") + store.clearRecentInstallations() + try require( + store.updatableApps(in: current, for: device("A"))?.isEmpty == true, + "Explicit refresh or navigation ends retained updates.") + store.recordReplacement(application("io.example.Installed", version: "2.0.0"), deviceID: "A") + try require( + store.updatableApps(in: current, for: device("A"))?.isEmpty == true, + "A newer installed version must not create a downgrade card.") + await store.refresh(for: device("B")) + try require( + store.updatableApps(in: current, for: device("B"))?.isEmpty == true, + "A device with a newer app has no update.") + + let probe = InventoryProbe() + let pendingStore = InstalledAppsStore(load: { try await probe.load($0) }) + let pending = Task { await pendingStore.refresh(for: device("A")) } + try await eventually { await probe.calls.count == 1 } + pendingStore.recordReplacement( + application("io.example.Installed", version: "1.0.0"), deviceID: "A") + await probe.finish(0, with: .success(["io.example.Installed"])) + await pending.value + try require( + pendingStore.application(bundleID: "io.example.Installed", deviceID: "A")?.version == "1.0.0", + "An older query cannot roll back the version from a completed update.") + } + static func eventually(_ condition: () async -> Bool) async throws { let deadline = ContinuousClock.now.advanced(by: .seconds(2)) while !(await condition()) {