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
128 changes: 128 additions & 0 deletions assets/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -689,6 +721,22 @@
}
}
},
"Downgrade": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Downgrade"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "降级"
}
}
}
},
"Download Size": {
"localizations": {
"en": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
21 changes: 20 additions & 1 deletion src/AppCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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 {
Expand Down
80 changes: 58 additions & 22 deletions src/AppInstallButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,65 +5,101 @@ 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."
)
}
}
}

private var button: some 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)
Expand Down
40 changes: 40 additions & 0 deletions src/AppInstallationAction.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading