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
12 changes: 12 additions & 0 deletions Xcodes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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 */; };
D10000010000000000000001 /* PinCodeInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000001 /* PinCodeInputTests.swift */; };
CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; };
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; };
CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; };
Expand Down Expand Up @@ -119,6 +120,8 @@
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 */; };
E8CBDB8927ADE32300B22292 /* unxip in Copy aria2c */ = {isa = PBXBuildFile; fileRef = E8CBDB8627ADD92000B22292 /* unxip */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
E8CBDB902EE5010000B22292 /* unxip-3.3.tar.gz in Resources */ = {isa = PBXBuildFile; fileRef = E8CBDB8E2EE5010000B22292 /* unxip-3.3.tar.gz */; };
E8CBDB912EE5010000B22292 /* unxip-3.3.provenance.json in Resources */ = {isa = PBXBuildFile; fileRef = E8CBDB8F2EE5010000B22292 /* unxip-3.3.provenance.json */; };
E8CBDB8B27AE02FF00B22292 /* ExperiementsPreferencePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */; };
E8D655C0288DD04700A139C2 /* SelectedActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D655BF288DD04700A139C2 /* SelectedActionType.swift */; };
E8DA461125FAF7FB002E85EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8DA461025FAF7FB002E85EF /* NotificationsView.swift */; };
Expand Down Expand Up @@ -207,6 +210,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>"; };
D10000020000000000000001 /* PinCodeInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeInputTests.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>"; };
CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -308,6 +312,8 @@
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>"; };
E8CBDB8627ADD92000B22292 /* unxip */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = unxip; sourceTree = "<group>"; };
E8CBDB8E2EE5010000B22292 /* unxip-3.3.tar.gz */ = {isa = PBXFileReference; lastKnownFileType = archive.gzip; path = "unxip-3.3.tar.gz"; sourceTree = "<group>"; };
E8CBDB8F2EE5010000B22292 /* unxip-3.3.provenance.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "unxip-3.3.provenance.json"; sourceTree = "<group>"; };
E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperiementsPreferencePane.swift; sourceTree = "<group>"; };
E8D655BF288DD04700A139C2 /* SelectedActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedActionType.swift; sourceTree = "<group>"; };
E8DA461025FAF7FB002E85EF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -527,6 +533,8 @@
CAD2E7AE2449575000113D76 /* Xcodes.entitlements */,
CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */,
E8CBDB8627ADD92000B22292 /* unxip */,
E8CBDB8E2EE5010000B22292 /* unxip-3.3.tar.gz */,
E8CBDB8F2EE5010000B22292 /* unxip-3.3.provenance.json */,
9DD4FFCA2B13EC1800C974F1 /* Localizable.xcstrings */,
);
path = Resources;
Expand Down Expand Up @@ -591,6 +599,7 @@
CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */,
CAD2E7B72449575100113D76 /* AppStateTests.swift */,
CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */,
D10000020000000000000001 /* PinCodeInputTests.swift */,
CAD2E7B92449575100113D76 /* Info.plist */,
);
path = XcodesTests;
Expand Down Expand Up @@ -795,6 +804,8 @@
E8EEAD1D2E79174F00BE67E8 /* XcodesIcon.icon in Resources */,
CA9FF83F2594FBC000E47BAF /* Licenses.rtf in Resources */,
CAA858DB25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz in Resources */,
E8CBDB902EE5010000B22292 /* unxip-3.3.tar.gz in Resources */,
E8CBDB912EE5010000B22292 /* unxip-3.3.provenance.json in Resources */,
CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -966,6 +977,7 @@
CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */,
CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */,
CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */,
D10000010000000000000001 /* PinCodeInputTests.swift in Sources */,
CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
164 changes: 140 additions & 24 deletions Xcodes/Backend/AppState+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,105 @@ import DockProgress
import XcodesKit
import XcodesLoginKit

struct XcodeExtractionWorkspace: Sendable {
private static let directoryPrefix = ".xcodes-extract-"

let directoryURL: URL
let stagedArchiveURL: URL
private let parentURL: URL
private let parentIdentity: FileSystemIdentity
private let directoryIdentity: FileSystemIdentity
private let files: Files

private init(
directoryURL: URL,
stagedArchiveURL: URL,
parentURL: URL,
parentIdentity: FileSystemIdentity,
directoryIdentity: FileSystemIdentity,
files: Files
) {
self.directoryURL = directoryURL
self.stagedArchiveURL = stagedArchiveURL
self.parentURL = parentURL
self.parentIdentity = parentIdentity
self.directoryIdentity = directoryIdentity
self.files = files
}

static func create(for archiveURL: URL) throws -> Self {
let files = Current.files
let archiveURL = archiveURL.standardizedFileURL
let parentURL = files.canonicalURL(archiveURL.deletingLastPathComponent())
let parentIdentity = try files.fileSystemIdentity(parentURL)
guard parentIdentity.isDirectory, parentIdentity.isSymbolicLink == false else {
throw CocoaError(.fileWriteInvalidFileName)
}

let directoryURL = parentURL.appendingPathComponent(directoryPrefix + UUID().uuidString, isDirectory: true)
let stagedArchiveURL = directoryURL.appendingPathComponent(archiveURL.lastPathComponent)

try files.createDirectory(
at: directoryURL,
withIntermediateDirectories: false
)

let directoryIdentity = try files.fileSystemIdentity(directoryURL)
guard directoryIdentity.isDirectory,
directoryIdentity.isSymbolicLink == false,
try files.fileSystemIdentity(parentURL) == parentIdentity
else {
throw CocoaError(.fileWriteInvalidFileName)
}

let workspace = Self(
directoryURL: directoryURL,
stagedArchiveURL: stagedArchiveURL,
parentURL: parentURL,
parentIdentity: parentIdentity,
directoryIdentity: directoryIdentity,
files: files
)

do {
try files.linkItem(at: archiveURL, to: stagedArchiveURL)
} catch {
try? workspace.remove()
throw error
}

return workspace
}

func remove() throws {
let currentParentURL = files.canonicalURL(directoryURL.deletingLastPathComponent())
let currentDirectoryURL = files.canonicalURL(directoryURL)
guard currentParentURL == parentURL,
currentDirectoryURL == directoryURL,
directoryURL.lastPathComponent.hasPrefix(Self.directoryPrefix),
stagedArchiveURL.standardizedFileURL.deletingLastPathComponent() == directoryURL,
try files.fileSystemIdentity(parentURL) == parentIdentity,
try files.fileSystemIdentity(directoryURL) == directoryIdentity
else {
throw CocoaError(.fileWriteInvalidFileName)
}
try files.quarantineAndRemoveOwnedDirectory(
parentURL,
parentIdentity,
directoryURL,
directoryIdentity,
files.beforeOwnedDirectoryQuarantine
)
}
}

/// Downloads and installs Xcodes
extension AppState {

static func installNotificationTitle(for version: Version) -> String {
version.appleDescription
}

// check to see if we should auto install for the user
public func autoInstallIfNeeded() {
guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
Expand Down Expand Up @@ -48,7 +144,7 @@ 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 {
guard let index = xcodeIndex(for: xcode) else {
return installedXcode
}
allXcodes[index].installState = .installed(installedXcode.path)
Expand Down Expand Up @@ -131,15 +227,15 @@ extension AppState {
}

let isAlreadyDownloading: Bool
if let xcode = self.allXcodes.first(where: { $0.version.isEquivalent(to: availableXcode.version) }),
case .installing(.downloading) = xcode.installState
if let index = self.xcodeIndex(for: availableXcode),
case .installing(.downloading) = self.allXcodes[index].installState
{
isAlreadyDownloading = true
} else {
isAlreadyDownloading = false
}

self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress), postNotification: !isAlreadyDownloading)
self.setInstallationStep(of: availableXcode, to: .downloading(progress: progress), postNotification: !isAlreadyDownloading)
self.addDockProgressChildIfNeeded(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight)
}
})
Expand Down Expand Up @@ -212,29 +308,32 @@ extension AppState {
unxipProgress.completedUnitCount = 0
addDockProgressChildIfNeeded(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight)

let workspace = try XcodeExtractionWorkspace.create(for: archiveURL)
defer { try? workspace.remove() }

let installedXcode: InstalledXcode
do {
installedXcode = try await xcodeArchiveInstallService.installArchivedXcode(
availableXcode,
at: archiveURL,
cleanArchive: { try Current.files.trashItem(at: $0) }
at: workspace.stagedArchiveURL,
cleanArchive: { _ in try Current.files.trashItem(at: archiveURL) }
) { step in
switch step {
case .unarchive(.unarchiving):
await self.setInstallationStep(of: availableXcode.version, to: .unarchiving)
await self.setInstallationStep(of: availableXcode, to: .unarchiving)
case let .unarchive(.moving(destination)):
await self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination))
await self.setInstallationStep(of: availableXcode, to: .moving(destination: destination))
case .cleaningArchive:
await self.setInstallationStep(of: availableXcode.version, to: .trashingArchive)
await self.setInstallationStep(of: availableXcode, to: .trashingArchive)
case .checkingSecurity:
await self.setInstallationStep(of: availableXcode.version, to: .checkingSecurity)
await self.setInstallationStep(of: availableXcode, to: .checkingSecurity)
}
}
} catch {
throw mapXcodeArchiveInstallError(error, availableXcode: availableXcode)
throw mapXcodeArchiveInstallError(error, availableXcode: availableXcode, archiveURL: archiveURL)
}

setInstallationStep(of: availableXcode.version, to: .finishing)
setInstallationStep(of: availableXcode, to: .finishing)
do {
try await performPostInstallStepsAsync(for: installedXcode)
} catch {
Expand All @@ -248,7 +347,12 @@ extension AppState {

private var xcodeUnarchiveService: XcodeUnarchiveService {
XcodeUnarchiveService(
unarchive: { _ = try await self.unxipOrUnxipExperimentAsync($0) },
unarchive: {
_ = try await self.unxipOrUnxipExperimentAsync(
$0,
workingDirectory: $0.deletingLastPathComponent()
)
},
fileExists: { path in Current.files.fileExists(atPath: path) },
moveItem: { source, destination in try Current.files.moveItem(at: source, to: destination) },
removeItem: { url in try Current.files.removeItem(at: url) }
Expand All @@ -271,7 +375,11 @@ extension AppState {
)
}

private func mapXcodeArchiveInstallError(_ error: Error, availableXcode: AvailableXcode) -> Error {
private func mapXcodeArchiveInstallError(
_ error: Error,
availableXcode: AvailableXcode,
archiveURL: URL
) -> Error {
switch error {
case let error as XcodeArchiveInstallError:
switch error {
Expand All @@ -282,11 +390,11 @@ extension AppState {
}
case let error as XcodeUnarchiveError:
switch error {
case let .damagedXIP(url):
return InstallationError.damagedXIP(url: url)
case let .notEnoughFreeSpaceToExpandArchive(url):
case .damagedXIP:
return InstallationError.damagedXIP(url: archiveURL)
case .notEnoughFreeSpaceToExpandArchive:
return InstallationError.notEnoughFreeSpaceToExpandArchive(
archivePath: Path(url: url)!,
archivePath: Path(url: archiveURL)!,
version: availableXcode.version
)
}
Expand All @@ -307,13 +415,13 @@ extension AppState {
}
}

func unxipOrUnxipExperimentAsync(_ source: URL) async throws -> ProcessOutput {
func unxipOrUnxipExperimentAsync(_ source: URL, workingDirectory: URL) async throws -> ProcessOutput {
if unxipExperiment {
// All hard work done by https://github.com/saagarjha/unxip
// Compiled to binary with `swiftc -parse-as-library -O unxip.swift`
return try await Current.shell.unxipExperiment(source)
return try await Current.shell.unxipExperiment(source, workingDirectory)
} else {
return try await Current.shell.unxip(source)
return try await Current.shell.unxip(source, workingDirectory)
}
}

Expand Down Expand Up @@ -465,13 +573,21 @@ extension AppState {

// MARK: -

func setInstallationStep(of version: Version, to step: XcodeInstallationStep, postNotification: Bool = true) {
guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
private func xcodeIndex(for availableXcode: AvailableXcode) -> Int? {
allXcodes.firstIndex {
$0.id == availableXcode.xcodeID ||
($0.version.isEquivalent(to: availableXcode.version) &&
$0.id.architectures == availableXcode.xcodeID.architectures)
}
}

func setInstallationStep(of availableXcode: AvailableXcode, to step: XcodeInstallationStep, postNotification: Bool = true) {
guard let index = xcodeIndex(for: availableXcode) else { return }
allXcodes[index].installState = .installing(step)

let xcode = allXcodes[index]
if postNotification {
Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal)
Current.notificationManager.scheduleNotification(title: Self.installNotificationTitle(for: xcode.version), body: step.description, category: .normal)
}
}

Expand Down
Loading