Skip to content
Open
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
8 changes: 8 additions & 0 deletions Xcodes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */; };
CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; };
CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; };
AD0298B11A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0298B01A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift */; };
CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */; };
CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; };
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; };
Expand Down Expand Up @@ -122,6 +123,7 @@
E89CBD402D6434E10037ED95 /* SignInFederatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */; };
E8B20CBF2A2EDEC20057D816 /* SDKs+Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */; };
E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */; };
AD0298A11A2E5B0100000001 /* RuntimeAutoInstallService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0298A01A2E5B0100000001 /* RuntimeAutoInstallService.swift */; };
E8CBDB8927ADE32300B22292 /* unxip in Copy aria2c */ = {isa = PBXBuildFile; fileRef = E8CBDB8627ADD92000B22292 /* unxip */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
E8CBDB8B27AE02FF00B22292 /* ExperiementsPreferencePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */; };
E8D0296F284B029800647641 /* BottomStatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D0296E284B029800647641 /* BottomStatusBar.swift */; };
Expand Down Expand Up @@ -214,6 +216,7 @@
BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = "<group>"; };
CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = "<group>"; };
CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = "<group>"; };
AD0298B01A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeAutoInstallServiceTests.swift; sourceTree = "<group>"; };
CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsViewTests.swift; sourceTree = "<group>"; };
CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -315,6 +318,7 @@
E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInFederatedView.swift; sourceTree = "<group>"; };
E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SDKs+Xcode.swift"; sourceTree = "<group>"; };
E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Runtimes.swift"; sourceTree = "<group>"; };
AD0298A01A2E5B0100000001 /* RuntimeAutoInstallService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeAutoInstallService.swift; sourceTree = "<group>"; };
E8CBDB8627ADD92000B22292 /* unxip */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = unxip; sourceTree = "<group>"; };
E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperiementsPreferencePane.swift; sourceTree = "<group>"; };
E8D0296E284B029800647641 /* BottomStatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomStatusBar.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -480,6 +484,7 @@
children = (
CA378F982466567600A58CE0 /* AppState.swift */,
E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */,
AD0298A01A2E5B0100000001 /* RuntimeAutoInstallService.swift */,
CAE424B3259A764700B8B246 /* AppState+Install.swift */,
CABFA9A72592EEE900380FEE /* AppState+Update.swift */,
CABFAA2B2592FBFC00380FEE /* Configure.swift */,
Expand Down Expand Up @@ -602,6 +607,7 @@
CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */,
CAD2E7B72449575100113D76 /* AppStateTests.swift */,
CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */,
AD0298B01A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift */,
CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */,
884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */,
CAD2E7B92449575100113D76 /* Info.plist */,
Expand Down Expand Up @@ -964,6 +970,7 @@
CABFAA432593104F00380FEE /* AboutView.swift in Sources */,
E8D0296F284B029800647641 /* BottomStatusBar.swift in Sources */,
E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */,
AD0298A11A2E5B0100000001 /* RuntimeAutoInstallService.swift in Sources */,
E8E98A9025D8631800EC89A0 /* InstallationStepRowView.swift in Sources */,
CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */,
CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */,
Expand All @@ -982,6 +989,7 @@
CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */,
CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */,
CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */,
AD0298B11A2E5B0100000002 /* RuntimeAutoInstallServiceTests.swift in Sources */,
14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */,
CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */,
CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */,
Expand Down
8 changes: 5 additions & 3 deletions Xcodes/Backend/AppState+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ extension AppState {
try Task.checkCancellation()
let installedXcode = try await installArchivedXcodeAsync(xcode, at: url)

guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else {
return installedXcode
if let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) {
allXcodes[index].installState = .installed(installedXcode.path)
}
allXcodes[index].installState = .installed(installedXcode.path)

await autoInstallRuntimesIfNeeded()

return installedXcode
},
onAttemptFailed: { @MainActor _ in
Expand Down
56 changes: 54 additions & 2 deletions Xcodes/Backend/AppState+Runtimes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ extension AppState {
}
}

func downloadRuntime(runtime: DownloadableRuntime) {
/// Starts installing a runtime.
///
/// - Parameter presentingErrors: Whether a runtime that can't be installed right now should
/// raise an alert. Automatic installs only log, so that they never interrupt the user.
func downloadRuntime(runtime: DownloadableRuntime, presentingErrors: Bool = true) {
do {
let method = try RuntimeInstallPolicy().installMethod(
for: runtime,
Expand All @@ -69,8 +73,56 @@ extension AppState {
downloadRuntimeViaXcodeBuild(runtime: runtime, architecture: architecture)
}
} catch {
presentRuntimeInstallPolicyError(error)
if presentingErrors {
presentRuntimeInstallPolicyError(error)
} else {
Logger.appState.error("Skipping automatic install of \(runtime.visibleIdentifier): \(error.localizedDescription)")
}
}
}

/// Installs the newest runtime for each platform the user asked to keep up to date.
///
/// Runs after an Xcode install finishes so the simulators a developer always needs are
/// ready as soon as the Xcode they were installed alongside is.
func autoInstallRuntimesIfNeeded() async {
guard
let rawValue = Current.defaults.string(forKey: PreferenceKey.autoInstallRuntimePlatforms.rawValue),
let preference = AutoInstallRuntimePlatforms(rawValue: rawValue),
!preference.platforms.isEmpty
else { return }

// The runtime lists are populated lazily, and an install that just finished can have
// made an installed runtime stale, so make sure both are current before deciding.
if downloadableRuntimes.isEmpty {
updateDownloadableRuntimes()
await downloadableRuntimesTask?.value
}
updateInstalledRuntimes()
await installedRuntimesTask?.value

let runtimes = RuntimeAutoInstallService().runtimesToInstall(
platforms: preference.platforms,
downloadableRuntimes: downloadableRuntimes,
installedRuntimes: installedRuntimes,
includingPrereleases: isAutoInstallingXcodeBetas
)

guard !runtimes.isEmpty else {
Logger.appState.info("Every platform selected for automatic installation is already up to date")
return
}

for runtime in runtimes where runtimeTasks[runtime.identifier] == nil {
Logger.appState.info("Automatically installing runtime \(runtime.visibleIdentifier)")
downloadRuntime(runtime: runtime, presentingErrors: false)
}
}

/// Beta runtimes are only installed automatically for users who already opted into Xcode betas.
private var isAutoInstallingXcodeBetas: Bool {
guard let storageValue = Current.defaults.get(forKey: PreferenceKey.autoInstallation.rawValue) as? Int else { return false }
return AutoInstallationType(rawValue: storageValue) == .newestBeta
}

private func presentRuntimeInstallPolicyError(_ error: Error) {
Expand Down
1 change: 1 addition & 0 deletions Xcodes/Backend/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ enum PreferenceKey: String {
case expandedMajorXcodeVersions
case expandedMinorXcodeVersions
case usePrivilegeHelperForFileOperations
case autoInstallRuntimePlatforms

func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) }
}
Expand Down
88 changes: 88 additions & 0 deletions Xcodes/Backend/RuntimeAutoInstallService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Foundation
import XcodesKit
import Version

/// The platforms whose simulator runtimes should be kept installed automatically.
///
/// Stored as a comma separated list of platform identifiers so that it round trips
/// through `@AppStorage` and through a managed preference profile.
struct AutoInstallRuntimePlatforms: RawRepresentable, Equatable {
/// The platforms that can be installed automatically.
///
/// macOS is excluded because there is no downloadable macOS simulator runtime.
static let availablePlatforms: [DownloadableRuntime.Platform] = [.iOS, .watchOS, .tvOS, .visionOS]

var platforms: Set<DownloadableRuntime.Platform>

init(platforms: Set<DownloadableRuntime.Platform> = []) {
self.platforms = platforms
}

init?(rawValue: String) {
self.platforms = Set(
rawValue
.split(separator: ",")
.compactMap { DownloadableRuntime.Platform(rawValue: String($0)) }
)
}

var rawValue: String {
platforms.sorted(\.order)
.map(\.rawValue)
.joined(separator: ",")
}

func contains(_ platform: DownloadableRuntime.Platform) -> Bool {
platforms.contains(platform)
}

mutating func setContains(_ contains: Bool, for platform: DownloadableRuntime.Platform) {
if contains {
platforms.insert(platform)
} else {
platforms.remove(platform)
}
}
}

/// Decides which simulator runtimes should be installed automatically once an
/// Xcode installation finishes.
struct RuntimeAutoInstallService {
/// Returns the newest downloadable runtime for each requested platform.
///
/// A platform is skipped when its newest runtime is already installed, so that
/// repeated Xcode installs don't re-download runtimes that are already on disk.
func runtimesToInstall(
platforms: Set<DownloadableRuntime.Platform>,
downloadableRuntimes: [DownloadableRuntime],
installedRuntimes: [CoreSimulatorImage],
includingPrereleases: Bool
) -> [DownloadableRuntime] {
guard !platforms.isEmpty else { return [] }

let installedBuilds = Set(installedRuntimes.map(\.runtimeInfo.build))

return platforms.sorted(\.order).compactMap { platform in
let candidates = downloadableRuntimes.filter { runtime in
runtime.platform == platform && (includingPrereleases || runtime.betaNumber == nil)
}

guard let newest = candidates.max(by: isOlder) else { return nil }
guard !installedBuilds.contains(newest.simulatorVersion.buildUpdate) else { return nil }

return newest
}
}

/// Orders two runtimes of the same platform, newest last.
private func isOlder(_ lhs: DownloadableRuntime, _ rhs: DownloadableRuntime) -> Bool {
let lhsVersion = Version(tolerant: lhs.simulatorVersion.version) ?? Version(0, 0, 0)
let rhsVersion = Version(tolerant: rhs.simulatorVersion.version) ?? Version(0, 0, 0)

guard lhsVersion == rhsVersion else { return lhsVersion < rhsVersion }

// A release of a version always wins over a beta of the same version,
// otherwise the higher seed number wins.
return (lhs.betaNumber ?? .max) < (rhs.betaNumber ?? .max)
}
}
8 changes: 7 additions & 1 deletion Xcodes/Backend/SDKs+Xcode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import XcodesKit

extension DownloadableRuntime {
func icon() -> Image {
switch self.platform {
platform.icon()
}
}

extension DownloadableRuntime.Platform {
func icon() -> Image {
switch self {
case .iOS:
return Image(systemName: "iphone")
case .macOS:
Expand Down
30 changes: 30 additions & 0 deletions Xcodes/Frontend/Preferences/PlatformsListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,34 @@ import OrderedCollections

struct PlatformsListView: View {
@EnvironmentObject var appState: AppState
@AppStorage(PreferenceKey.autoInstallRuntimePlatforms.rawValue) private var autoInstallPlatforms = AutoInstallRuntimePlatforms()
@State private var runtimes: OrderedDictionary<DownloadableRuntime.Platform, [DownloadableRuntime]> = [:]
@State private var selectedRuntime: DownloadableRuntime?

var body: some View {
List(selection: $selectedRuntime) {
Section {
ForEach(AutoInstallRuntimePlatforms.availablePlatforms, id: \.self) { platform in
Toggle(isOn: autoInstallBinding(for: platform)) {
HStack {
platform.icon()
.aspectRatio(contentMode: .fit)
.frame(width: 20)
Text(platform.shortName)
}
}
.disabled(PreferenceKey.autoInstallRuntimePlatforms.isManaged())
}
} header: {
Text("PlatformsList.AutoInstall.Title")
.font(.headline)
} footer: {
Text("PlatformsList.AutoInstall.Description")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.bottom, 8)
}

Text("PlatformsList.Title")
.font(.body)
ForEach(runtimes.elements.sorted(\.key.order), id: \.key) { platform, runtimeList in
Expand Down Expand Up @@ -60,6 +83,13 @@ struct PlatformsListView: View {
}
}

private func autoInstallBinding(for platform: DownloadableRuntime.Platform) -> Binding<Bool> {
Binding(
get: { autoInstallPlatforms.contains(platform) },
set: { autoInstallPlatforms.setContains($0, for: platform) }
)
}

func loadRuntimes() {
let filteredRuntimes = appState.downloadableRuntimes.filter { runtime in
appState.installedRuntimes.contains { $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate
Expand Down
22 changes: 22 additions & 0 deletions Xcodes/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -20704,6 +20704,28 @@
}
}
},
"PlatformsList.AutoInstall.Description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "After Xcodes finishes installing a version of Xcode it downloads the newest runtime for each platform selected above, unless that runtime is already installed."
}
}
}
},
"PlatformsList.AutoInstall.Title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Install Automatically"
}
}
}
},
"PlatformsList.Title" : {
"extractionState" : "manual",
"localizations" : {
Expand Down
Loading