Skip to content

Commit f4b45a8

Browse files
committed
Merge tag '1.0.0' into xip-multivolume-expasion
# Conflicts: # Sources/XcodesKit/XcodeInstaller.swift # Sources/xcodes/main.swift # Tests/XcodesKitTests/XcodesKitTests.swift
2 parents d5aead5 + d18bf48 commit f4b45a8

14 files changed

+143
-61
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ name: CI
22
on: [push, pull_request]
33
jobs:
44
test:
5-
runs-on: macOS-10.15
5+
runs-on: macOS-11
66
steps:
77
- uses: actions/checkout@v3
88
- name: Run tests
99
env:
10-
DEVELOPER_DIR: /Applications/Xcode_12.3.app
10+
DEVELOPER_DIR: /Applications/Xcode_13.2.1.app
1111
run: swift test

Sources/Unxip/Unxip.swift

+9-5
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,9 @@ struct File {
133133
compressionStream.addTask {
134134
try Task.checkCancellation()
135135
let position = _position
136-
let data = [UInt8](unsafeUninitializedCapacity: blockSize + blockSize / 16) { buffer, count in
137-
data[position..<min(position + blockSize, data.endIndex)].withUnsafeBufferPointer { data in
136+
let end = min(position + blockSize, data.endIndex)
137+
let data = [UInt8](unsafeUninitializedCapacity: (end - position) + (end - position) / 16) { buffer, count in
138+
data[position..<end].withUnsafeBufferPointer { data in
138139
count = compression_encode_buffer(buffer.baseAddress!, buffer.count, data.baseAddress!, data.count, nil, COMPRESSION_LZFSE)
139140
guard count < buffer.count else {
140141
count = 0
@@ -365,7 +366,7 @@ public struct Unxip {
365366

366367
// The assumption is that all directories are provided without trailing slashes
367368
func parentDirectory<S: StringProtocol>(of path: S) -> S.SubSequence {
368-
return path[..<path.lastIndex(of: "/")!]
369+
path[..<path.lastIndex(of: "/")!]
369370
}
370371

371372
// https://bugs.swift.org/browse/SR-15816
@@ -384,9 +385,11 @@ public struct Unxip {
384385
continue
385386
}
386387

387-
if let (original, task) = hardlinks[file.identifier] {
388+
if let (original, originalTask) = hardlinks[file.identifier] {
389+
let task = parentDirectoryTask(for: file)
390+
assert(task != nil, file.name)
388391
_ = taskStream.addRunningTask {
389-
await task.value
392+
_ = await (originalTask.value, task?.value)
390393
warn(link(original, file.name), "linking")
391394
}
392395
continue
@@ -399,6 +402,7 @@ public struct Unxip {
399402
let task = parentDirectoryTask(for: file)
400403
assert(task != nil, file.name)
401404
_ = taskStream.addRunningTask {
405+
await task?.value
402406
warn(symlink(String(data: Data(file.data.map(Array.init).reduce([], +)), encoding: .utf8), file.name), "symlinking")
403407
setStickyBit(on: file)
404408
}

Sources/XcodesKit/Models.swift

+4
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ public struct Xcode: Codable, Equatable {
5050
public let filename: String
5151
public let releaseDate: Date?
5252

53+
public var downloadPath: String {
54+
return url.path
55+
}
56+
5357
public init(version: Version, url: URL, filename: String, releaseDate: Date?) {
5458
self.version = version
5559
self.url = url

Sources/XcodesKit/URLRequest+Apple.swift

+11
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ extension URL {
44
static let download = URL(string: "https://developer.apple.com/download")!
55
static let downloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")!
66
static let downloadXcode = URL(string: "https://developer.apple.com/devcenter/download.action")!
7+
static let downloadADCAuth = URL(string: "https://developerservices2.apple.com/services/download")!
78
}
89

910
extension URLRequest {
@@ -25,4 +26,14 @@ extension URLRequest {
2526
request.allHTTPHeaderFields?["Accept"] = "*/*"
2627
return request
2728
}
29+
30+
// default to a known download path if none passed in
31+
static func downloadADCAuth(path: String? = "/Developer_Tools/Xcode_14/Xcode_14.xip") -> URLRequest {
32+
var components = URLComponents(url: .downloadADCAuth, resolvingAgainstBaseURL: false)!
33+
components.queryItems = [URLQueryItem(name: "path", value: path)]
34+
var request = URLRequest(url: components.url!)
35+
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
36+
request.allHTTPHeaderFields?["Accept"] = "*/*"
37+
return request
38+
}
2839
}

Sources/XcodesKit/Version.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
import Version
22

3-
public let version = Version("0.20.0")!
3+
public let version = Version("1.0.0")!

Sources/XcodesKit/XcodeInstaller.swift

+76-39
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public final class XcodeInstaller {
8282
case downloading(version: String, progress: String?, willInstall: Bool)
8383
case unarchiving(experimentalUnxip: Bool)
8484
case moving(destination: String)
85-
case trashingArchive(archiveName: String)
85+
case cleaningArchive(archiveName: String, shouldDelete: Bool)
8686
case checkingSecurity
8787
case finishing
8888

@@ -114,7 +114,10 @@ public final class XcodeInstaller {
114114
"""
115115
case .moving(let destination):
116116
return "Moving Xcode to \(destination)"
117-
case .trashingArchive(let archiveName):
117+
case .cleaningArchive(let archiveName, let shouldDelete):
118+
if shouldDelete {
119+
return "Deleting Xcode archive \(archiveName)"
120+
}
118121
return "Moving Xcode archive \(archiveName) to the Trash"
119122
case .checkingSecurity:
120123
return "Checking security assessment and code signing"
@@ -128,7 +131,7 @@ public final class XcodeInstaller {
128131
case .downloading: return 1
129132
case .unarchiving: return 2
130133
case .moving: return 3
131-
case .trashingArchive: return 4
134+
case .cleaningArchive: return 4
132135
case .checkingSecurity: return 5
133136
case .finishing: return 6
134137
}
@@ -163,22 +166,22 @@ public final class XcodeInstaller {
163166
case aria2(Path)
164167
}
165168

166-
public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, shouldExpandXipInplace: Bool) -> Promise<Void> {
169+
public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool = false, shouldExpandXipInplace: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise<Void> {
167170
return firstly { () -> Promise<InstalledXcode> in
168-
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace)
171+
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
169172
}
170173
.done { xcode in
171174
Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)".green)
172175
Current.shell.exit(0)
173176
}
174177
}
175178

176-
private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, shouldExpandXipInplace: Bool) -> Promise<InstalledXcode> {
179+
private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, shouldExpandXipInplace: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
177180
return firstly { () -> Promise<(Xcode, URL)> in
178181
return self.getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true)
179182
}
180183
.then { xcode, url -> Promise<InstalledXcode> in
181-
return self.installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace)
184+
return self.installArchivedXcode(xcode, at: url, to: destination, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
182185
}
183186
.recover { error -> Promise<InstalledXcode> in
184187
switch error {
@@ -195,7 +198,7 @@ public final class XcodeInstaller {
195198
Current.logging.log(error.legibleLocalizedDescription.red)
196199
Current.logging.log("Removing damaged XIP and re-attempting installation.\n")
197200
try Current.files.removeItem(at: damagedXIPURL)
198-
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace)
201+
return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: shouldExpandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
199202
}
200203
}
201204
default:
@@ -287,7 +290,15 @@ public final class XcodeInstaller {
287290

288291
private func downloadXcode(version: Version, dataSource: DataSource, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> {
289292
return firstly { () -> Promise<Version> in
290-
loginIfNeeded().map { version }
293+
if dataSource == .apple {
294+
return loginIfNeeded().map { version }
295+
} else {
296+
guard let xcode = self.xcodeList.availableXcodes.first(withVersion: version) else {
297+
throw Error.unavailableVersion(version)
298+
}
299+
300+
return validateADCSession(path: xcode.downloadPath).map { version }
301+
}
291302
}
292303
.then { version -> Promise<Version> in
293304
if self.xcodeList.shouldUpdate {
@@ -297,14 +308,6 @@ public final class XcodeInstaller {
297308
return Promise.value(version)
298309
}
299310
}
300-
.then { version -> Promise<Version> in
301-
// This request would've already been made if the Apple data source were being used.
302-
// That's not the case for the Xcode Releases data source.
303-
// We need the cookies from its response in order to download Xcodes though,
304-
// so perform it here first just to be sure.
305-
Current.network.dataTask(with: URLRequest.downloads)
306-
.map { _ in version }
307-
}
308311
.then { version -> Promise<(Xcode, URL)> in
309312
guard let xcode = self.xcodeList.availableXcodes.first(withVersion: version) else {
310313
throw Error.unavailableVersion(version)
@@ -334,7 +337,11 @@ public final class XcodeInstaller {
334337
.map { return (xcode, $0) }
335338
}
336339
}
337-
340+
341+
func validateADCSession(path: String) -> Promise<Void> {
342+
return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid()
343+
}
344+
338345
func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise<Void> {
339346
return firstly { () -> Promise<Void> in
340347
return Current.network.validateSession()
@@ -528,15 +535,7 @@ public final class XcodeInstaller {
528535
}
529536
}
530537

531-
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, shouldExpandXipInplace: Bool) -> Promise<InstalledXcode> {
532-
let passwordInput = {
533-
Promise<String> { seal in
534-
Current.logging.log("xcodes requires superuser privileges in order to finish installation.")
535-
guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(Error.missingSudoerPassword); return }
536-
seal.fulfill(password + "\n")
537-
}
538-
}
539-
538+
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, experimentalUnxip: Bool = false, shouldExpandXipInplace: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise<InstalledXcode> {
540539
return firstly { () -> Promise<InstalledXcode> in
541540
let destinationURL = destination.join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url
542541
switch archiveURL.pathExtension {
@@ -556,15 +555,38 @@ public final class XcodeInstaller {
556555
}
557556
}
558557
.then { xcode -> Promise<InstalledXcode> in
559-
Current.logging.log(InstallationStep.trashingArchive(archiveName: archiveURL.lastPathComponent).description)
560-
try Current.files.trashItem(at: archiveURL)
558+
Current.logging.log(InstallationStep.cleaningArchive(archiveName: archiveURL.lastPathComponent, shouldDelete: emptyTrash).description)
559+
if emptyTrash {
560+
try Current.files.removeItem(at: archiveURL)
561+
}
562+
else {
563+
try Current.files.trashItem(at: archiveURL)
564+
}
561565
Current.logging.log(InstallationStep.checkingSecurity.description)
562566

563567
return when(fulfilled: self.verifySecurityAssessment(of: xcode),
564568
self.verifySigningCertificate(of: xcode.path.url))
565569
.map { xcode }
566570
}
567571
.then { xcode -> Promise<InstalledXcode> in
572+
if noSuperuser {
573+
Current.logging.log(InstallationStep.finishing.description)
574+
Current.logging.log("Skipping asking for superuser privileges.")
575+
return Promise.value(xcode)
576+
}
577+
return self.postInstallXcode(xcode)
578+
}
579+
}
580+
581+
public func postInstallXcode(_ xcode: InstalledXcode) -> Promise<InstalledXcode> {
582+
let passwordInput = {
583+
Promise<String> { seal in
584+
Current.logging.log("xcodes requires superuser privileges in order to finish installation.")
585+
guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(Error.missingSudoerPassword); return }
586+
seal.fulfill(password + "\n")
587+
}
588+
}
589+
return firstly { () -> Promise<InstalledXcode> in
568590
Current.logging.log(InstallationStep.finishing.description)
569591

570592
return self.enableDeveloperMode(passwordInput: passwordInput).map { xcode }
@@ -577,7 +599,7 @@ public final class XcodeInstaller {
577599
}
578600
}
579601

580-
public func uninstallXcode(_ versionString: String, directory: Path) -> Promise<Void> {
602+
public func uninstallXcode(_ versionString: String, directory: Path, emptyTrash: Bool) -> Promise<Void> {
581603
return firstly { () -> Promise<InstalledXcode> in
582604
guard let version = Version(xcodeVersion: versionString) else {
583605
Current.logging.log(Error.invalidVersion(versionString).legibleLocalizedDescription)
@@ -591,11 +613,17 @@ public final class XcodeInstaller {
591613

592614
return Promise.value(installedXcode)
593615
}
594-
.map { ($0, try Current.files.trashItem(at: $0.path.url)) }
595-
.then { (installedXcode, trashURL) -> Promise<(InstalledXcode, URL)> in
616+
.map { installedXcode -> (InstalledXcode, URL?) in
617+
if emptyTrash {
618+
try Current.files.removeItem(at: installedXcode.path.url)
619+
return (installedXcode, nil)
620+
}
621+
return (installedXcode, try Current.files.trashItem(at: installedXcode.path.url))
622+
}
623+
.then { (installedXcode, trashURL) -> Promise<(InstalledXcode, URL?)> in
596624
// If we just uninstalled the selected Xcode, try to select the latest installed version so things don't accidentally break
597625
Current.shell.xcodeSelectPrintPath()
598-
.then { output -> Promise<(InstalledXcode, URL)> in
626+
.then { output -> Promise<(InstalledXcode, URL?)> in
599627
if output.out.hasPrefix(installedXcode.path.string),
600628
let latestInstalledXcode = Current.files.installedXcodes(directory).sorted(by: { $0.version < $1.version }).last {
601629
return selectXcodeAtPath(latestInstalledXcode.path.string)
@@ -610,17 +638,26 @@ public final class XcodeInstaller {
610638
}
611639
}
612640
.done { (installedXcode, trashURL) in
613-
Current.logging.log("Xcode \(installedXcode.version.appleDescription) moved to Trash: \(trashURL.path)".green)
641+
if let trashURL = trashURL {
642+
Current.logging.log("Xcode \(installedXcode.version.appleDescription) moved to Trash: \(trashURL.path)".green)
643+
}
644+
else {
645+
Current.logging.log("Xcode \(installedXcode.version.appleDescription) deleted".green)
646+
}
614647
Current.shell.exit(0)
615648
}
616649
}
617650

618651
func update(dataSource: DataSource) -> Promise<[Xcode]> {
619-
return firstly { () -> Promise<Void> in
620-
loginIfNeeded()
621-
}
622-
.then { () -> Promise<[Xcode]> in
623-
self.xcodeList.update(dataSource: dataSource)
652+
if dataSource == .apple {
653+
return firstly { () -> Promise<Void> in
654+
loginIfNeeded()
655+
}
656+
.then { () -> Promise<[Xcode]> in
657+
self.xcodeList.update(dataSource: dataSource)
658+
}
659+
} else {
660+
return self.xcodeList.update(dataSource: dataSource)
624661
}
625662
}
626663

Sources/xcodes/main.swift

+11-2
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ struct Xcodes: ParsableCommand {
185185

186186
@Flag(help: "Use the experimental unxip functionality. May speed up unarchiving by up to 2-3x.")
187187
var experimentalUnxip: Bool = false
188+
189+
@Flag(help: "Don't ask for superuser (root) permission. Some optional steps of the installation will be skipped.")
190+
var noSuperuser: Bool = false
191+
192+
@Flag(help: "Completely delete Xcode .xip after installation, instead of keeping it on the user's Trash.")
193+
var emptyTrash: Bool = false
188194

189195
@Option(help: "The directory to install Xcode into. Defaults to /Applications.",
190196
completion: .directory)
@@ -224,7 +230,7 @@ struct Xcodes: ParsableCommand {
224230

225231
let destination = getDirectory(possibleDirectory: directory)
226232

227-
installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: expandXipInplace)
233+
installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, shouldExpandXipInplace: expandXipInplace, emptyTrash: emptyTrash, noSuperuser: noSuperuser)
228234
.done { Install.exit() }
229235
.catch { error in
230236
Install.processDownloadOrInstall(error: error)
@@ -348,6 +354,9 @@ struct Xcodes: ParsableCommand {
348354
completion: .custom { _ in Current.files.installedXcodes(getDirectory(possibleDirectory: nil)).sorted { $0.version < $1.version }.map { $0.version.appleDescription } })
349355
var version: [String] = []
350356

357+
@Flag(help: "Completely delete Xcode, instead of keeping it on the user's Trash.")
358+
var emptyTrash: Bool = false
359+
351360
@OptionGroup
352361
var globalDirectory: GlobalDirectoryOption
353362

@@ -359,7 +368,7 @@ struct Xcodes: ParsableCommand {
359368

360369
let directory = getDirectory(possibleDirectory: globalDirectory.directory)
361370

362-
installer.uninstallXcode(version.joined(separator: " "), directory: directory)
371+
installer.uninstallXcode(version.joined(separator: " "), directory: directory, emptyTrash: emptyTrash)
363372
.done { Uninstall.exit() }
364373
.catch { error in Uninstall.exit(withLegibleError: error) }
365374

0 commit comments

Comments
 (0)