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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ Xcodes.app and CLI is updated, maintained with contributors like yourself. Even

You'll need macOS 15.6 Ventura and Xcode 26 in order to build and run Xcodes.app.

### Known build warnings

The following dependency and helper limitations remain:

- `ld: building for macOS-14.6, but linking with dylib '@rpath/libcbor.0.11.0.dylib' which was built for newer version 15.0`: the prebuilt `libcbor` in LibFido2Swift targets macOS 15 while the app targets 14.6. Resolving it means raising the app's deployment target or rebuilding that dependency for 14.6.
- `SMJobBless` is deprecated in favor of `SMAppService`. The one legacy call is isolated in a marked shim in `HelperClient.swift` until the privileged helper moves to `SMAppService`, which uses a different install and approval model.

`Unxip` and `aria2` must be compiled as a universal binary
```
# compile for Intel
Expand Down
1 change: 1 addition & 0 deletions Xcodes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,7 @@
};
D971F84C2E79102E005F84C9 /* Fix libfido2 structure */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
Expand Down
35 changes: 23 additions & 12 deletions Xcodes/Backend/AppState+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,7 @@ extension AppState {
do {
try await performPostInstallStepsAsync(for: installedXcode)
} catch {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription)
presentPostInstallRecovery(for: installedXcode)
}
resetDockProgressTracking()

Expand All @@ -256,8 +255,14 @@ extension AppState {
fileExists: { path in Current.files.fileExists(atPath: path) },
moveItem: { source, destination in
if Current.helper.usePrivilegedHelperForFileOperations {
try await self.installHelperIfNecessaryAsync()
try await Current.helper.moveAppAsync(source.path, destination.path)
do {
try await self.installHelperIfNecessaryAsync()
try await Current.helper.moveAppAsync(source.path, destination.path)
} catch is CancellationError {
throw CancellationError()
} catch {
throw HelperFileOperationError(underlyingError: error, command: HelperRecovery.moveCommand(source: source.path, destination: destination.path))
}
} else {
try Current.files.moveItem(at: source, to: destination)
}
Expand Down Expand Up @@ -354,8 +359,7 @@ extension AppState {
} catch is CancellationError {
} catch {
guard postInstallTaskID == taskID else { return }
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription)
presentPostInstallRecovery(for: xcode)
}
}
}
Expand All @@ -373,7 +377,9 @@ extension AppState {
try await xcodePostInstallWorkflowService.performPostInstallSteps(for: xcode)
} catch {
Logger.appState.error("Performing post-install steps failed: \(error.legibleLocalizedDescription)")
throw InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: helperInstallState)
// Keep the underlying reason (unless the user simply declined), so the alert can say why it failed.
let reason: String? = if case InstallationError.postInstallStepsNotPerformed = error { nil } else { error.legibleLocalizedDescription }
throw InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: helperInstallState, reason: reason)
}
}

Expand Down Expand Up @@ -411,7 +417,8 @@ extension AppState {
helperConsent.resume(
throwing: InstallationError.postInstallStepsNotPerformed(
version: version,
helperInstallState: self.helperInstallState
helperInstallState: self.helperInstallState,
reason: nil
)
)
}
Expand Down Expand Up @@ -511,7 +518,8 @@ public enum InstallationError: LocalizedError, Equatable {
case versionAlreadyInstalled(InstalledXcode)
case invalidVersion(String)
case versionNotInstalled(Version)
case postInstallStepsNotPerformed(version: Version, helperInstallState: HelperInstallState)
/// `reason` describes the underlying failure, if there was one besides the user declining to install the helper
case postInstallStepsNotPerformed(version: Version, helperInstallState: HelperInstallState, reason: String?)

public var errorDescription: String? {
switch self {
Expand Down Expand Up @@ -545,13 +553,16 @@ public enum InstallationError: LocalizedError, Equatable {
return String(format: localizeString("InstallationError.InvalidVersion"), version)
case let .versionNotInstalled(version):
return String(format: localizeString("InstallationError.VersionNotInstalled"), version.appleDescription)
case let .postInstallStepsNotPerformed(version, helperInstallState):
case let .postInstallStepsNotPerformed(version, helperInstallState, reason):
let message: String
switch helperInstallState {
case .installed:
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.Installed"), version.appleDescription)
message = String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.Installed"), version.appleDescription)
case .notInstalled, .unknown:
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.NotInstalled"), version.appleDescription)
message = String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.NotInstalled"), version.appleDescription)
}
guard let reason else { return message }
return "\(message)\n\n\(reason)"
}
}
}
Expand Down
45 changes: 39 additions & 6 deletions Xcodes/Backend/AppState+Update.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ extension AppState {
}

func updateIfNeeded() {
// Never cancel a full refresh just because the window became active again.
if isUpdating, updateTaskIsFullRefresh { return }
guard
isReadyForUpdate
isReadyForUpdate || !hasRefreshedAvailableXcodesThisLaunch
else {
updateTask?.cancel()
let taskID = UUID()
updateTaskIsFullRefresh = false
updateTaskID = taskID
let task = Task { @MainActor in
defer {
Expand All @@ -35,8 +38,19 @@ extension AppState {
update() as Void
}

func update() {
guard !isUpdating else { return }
/// Refreshes installed and available Xcodes and runtimes.
/// - Parameter restartingInFlightUpdate: Cancel a running refresh and start over, e.g. when the
/// data source or sign-in state changed and the running refresh would return stale results.
func update(restartingInFlightUpdate: Bool = false) {
if isUpdating {
// A quick installed-only rescan is always superseded; a full refresh only when asked.
guard restartingInFlightUpdate || !updateTaskIsFullRefresh else { return }
updateTask?.cancel()
updateTask = nil
updateTaskID = nil
}
hasRefreshedAvailableXcodesThisLaunch = true
updateTaskIsFullRefresh = true
updateDownloadableRuntimes()
updateInstalledRuntimes()

Expand All @@ -52,14 +66,22 @@ extension AppState {
do {
await self.updateInstalledXcodesAsync()
await self.updateSelectedXcodePathAsync()
try Task.checkCancellation()
let xcodes = try await self.updateAvailableXcodes(from: self.dataSource)
try Task.checkCancellation()
self.availableXcodes = xcodes
Current.defaults.setDate(Current.date(), forKey: "lastUpdated")
} catch is CancellationError {
} catch {
// Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
if error as? AuthenticationError != .invalidSession {
// A restarted refresh can surface its cancellation as a URLError instead
guard !Task.isCancelled else { return }

if self.dataSource == .apple, Self.isUnauthorizedInstallError(error) || error as? AuthenticationError == .invalidSession {
// The Apple data source needs a developer session; ask the user to sign in rather than
// showing a raw 401.
self.presentedAlert = .unauthenticatedDataSource
} else if error as? AuthenticationError != .invalidSession {
// Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: error.legibleLocalizedDescription)
}
Expand All @@ -68,10 +90,21 @@ extension AppState {
updateTask = task
}

/// Selection can change in Terminal while a full catalog refresh is still running.
func refreshSelectedXcodePath() {
guard selectedXcodeRefreshTask == nil else { return }
selectedXcodeRefreshTask = Task { @MainActor in
defer { selectedXcodeRefreshTask = nil }
await updateSelectedXcodePathAsync()
}
}

func updateSelectedXcodePathAsync() async {
do {
let output = try await Current.shell.xcodeSelectPrintPath()
selectedXcodePath = output.out
if selectedXcodePath != output.out {
selectedXcodePath = output.out
}
} catch {
// Ignore xcode-select failures
}
Expand Down
Loading