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
6 changes: 6 additions & 0 deletions Sources/XcodesKit/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ public struct Files {
return fileExistsAtPath(path)
}

public var attributesOfItemAtPath: (String) throws -> [FileAttributeKey: Any] = { try FileManager.default.attributesOfItem(atPath: $0) }

public func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] {
return try attributesOfItemAtPath(path)
}

public var moveItem: (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) }

public func moveItem(at srcURL: URL, to dstURL: URL) throws {
Expand Down
54 changes: 37 additions & 17 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,30 +289,50 @@ public final class XcodeInstaller {
}

private func downloadXcode(version: Version, dataSource: DataSource, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> {
return firstly { () -> Promise<Version> in
if dataSource == .apple {
return loginIfNeeded().map { version }
return firstly { () -> Promise<Void> in
switch dataSource {
case .apple:
// When using the Apple data source, an authenticated session is required for both
// downloading the list of Xcodes as well as to actually download Xcode, so we'll
// establish our session right at the start.
return loginIfNeeded()

case .xcodeReleases:
// When using the Xcode Releases data source, we only need to establish an anonymous
// session once we're ready to download Xcode. Doing that requires us to know the
// URL we want to download though (and we may not know that yet), so we don't need
// to do anything session-related quite yet.
return Promise()
}
}
.then { () -> Promise<Void> in
if self.xcodeList.shouldUpdateBeforeDownloading(version: version) {
return self.xcodeList.update(dataSource: dataSource).asVoid()
} else {
guard let xcode = self.xcodeList.availableXcodes.first(withVersion: version) else {
throw Error.unavailableVersion(version)
}

return validateADCSession(path: xcode.downloadPath).map { version }
}
}
.then { version -> Promise<Version> in
if self.xcodeList.shouldUpdate {
return self.xcodeList.update(dataSource: dataSource).map { _ in version }
}
else {
return Promise.value(version)
return Promise()
}
}
.then { version -> Promise<(Xcode, URL)> in
.then { () -> Promise<Xcode> in
guard let xcode = self.xcodeList.availableXcodes.first(withVersion: version) else {
throw Error.unavailableVersion(version)
}

return Promise.value(xcode)
}
.then { xcode -> Promise<Xcode> in
switch dataSource {
case .apple:
/// We already established a session for the Apple data source at the beginning of
/// this download, so we don't need to do anything session-related at this point.
return Promise.value(xcode)

case .xcodeReleases:
/// Now that we've used Xcode Releases to determine what URL we should use to
/// download Xcode, we can use that to establish an anonymous session with Apple.
return self.validateADCSession(path: xcode.downloadPath).map { xcode }
}
}
.then { xcode -> Promise<(Xcode, URL)> in
if Current.shell.isatty() {
// Move to the next line so that the escape codes below can move up a line and overwrite it with download progress
Current.logging.log("")
Expand Down
25 changes: 22 additions & 3 deletions Sources/XcodesKit/XcodeList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Path
import Version
import PromiseKit
import SwiftSoup
import XCModel
import struct XCModel.Xcode

/// Provides lists of available and installed Xcodes
public final class XcodeList {
Expand All @@ -12,9 +12,14 @@ public final class XcodeList {
}

public private(set) var availableXcodes: [Xcode] = []
public private(set) var lastUpdated: Date?

public var shouldUpdate: Bool {
return availableXcodes.isEmpty
public var shouldUpdateBeforeListingVersions: Bool {
return availableXcodes.isEmpty || (cacheAge ?? 0) > Self.maxCacheAge
}

public func shouldUpdateBeforeDownloading(version: Version) -> Bool {
return availableXcodes.first(withVersion: version) == nil
}

public func update(dataSource: DataSource) -> Promise<[Xcode]> {
Expand All @@ -30,13 +35,15 @@ public final class XcodeList {
prereleaseXcodes.contains { $0.version.isEquivalent(to: releasedXcode.version) } == false
} + prereleaseXcodes
self.availableXcodes = xcodes
self.lastUpdated = Date()
try? self.cacheAvailableXcodes(xcodes)
return xcodes
}
case .xcodeReleases:
return xcodeReleases()
.map { xcodes in
self.availableXcodes = xcodes
self.lastUpdated = Date()
try? self.cacheAvailableXcodes(xcodes)
return xcodes
}
Expand All @@ -45,10 +52,22 @@ public final class XcodeList {
}

extension XcodeList {
private static let maxCacheAge = TimeInterval(86400) // 24 hours

private var cacheAge: TimeInterval? {
guard let lastUpdated = lastUpdated else { return nil }
return -lastUpdated.timeIntervalSinceNow
}

private func loadCachedAvailableXcodes() throws {
guard let data = Current.files.contents(atPath: Path.cacheFile.string) else { return }
let xcodes = try JSONDecoder().decode([Xcode].self, from: data)

let attributes = try? Current.files.attributesOfItem(atPath: Path.cacheFile.string)
let lastUpdated = attributes?[.modificationDate] as? Date

self.availableXcodes = xcodes
self.lastUpdated = lastUpdated
}

private func cacheAvailableXcodes(_ xcodes: [Xcode]) throws {
Expand Down
2 changes: 1 addition & 1 deletion Sources/xcodes/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ struct Xcodes: AsyncParsableCommand {
let directory = getDirectory(possibleDirectory: globalDirectory.directory)

firstly { () -> Promise<Void> in
if xcodeList.shouldUpdate {
if xcodeList.shouldUpdateBeforeListingVersions {
return installer.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory)
}
else {
Expand Down
1 change: 1 addition & 0 deletions Tests/XcodesKitTests/Environment+Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ extension Shell {
extension Files {
static var mock = Files(
fileExistsAtPath: { _ in return true },
attributesOfItemAtPath: { _ in [:] },
moveItem: { _, _ in return },
contentsAtPath: { path in
if path.contains("Info.plist") {
Expand Down
52 changes: 52 additions & 0 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import Rainbow
@testable import XcodesKit

final class XcodesKitTests: XCTestCase {
static let mockXcode = Xcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)

var installer: XcodeInstaller!
var runtimeList: RuntimeList!

Expand Down Expand Up @@ -1403,4 +1405,54 @@ final class XcodesKitTests: XCTestCase {
XCTAssertEqual(capturedError as? Client.Error, Client.Error.notAuthenticated)
}

func test_XcodeList_ShouldUpdate_NotWhenCacheFileIsRecent() {
Current.files.contentsAtPath = { _ in try! JSONEncoder().encode([Self.mockXcode]) }
Current.files.attributesOfItemAtPath = { _ in [.modificationDate: Date(timeIntervalSinceNow: -3600*12)] }

let xcodesList = XcodeList()

XCTAssertFalse(xcodesList.shouldUpdateBeforeListingVersions)
}

func test_XcodeList_ShouldUpdate_WhenCacheFileIsOld() {
Current.files.contentsAtPath = { _ in try! JSONEncoder().encode([Self.mockXcode]) }
Current.files.attributesOfItemAtPath = { _ in [.modificationDate: Date(timeIntervalSinceNow: -3600*24*2)] }

let xcodesList = XcodeList()

XCTAssertTrue(xcodesList.shouldUpdateBeforeListingVersions)
}

func test_XcodeList_ShouldUpdate_WhenCacheFileIsMissing() {
Current.files.contentsAtPath = { _ in nil }

let xcodesList = XcodeList()

XCTAssertTrue(xcodesList.shouldUpdateBeforeListingVersions)
}

func test_XcodeList_ShouldUpdate_WhenCacheFileIsEmpty() {
Current.files.contentsAtPath = { _ in "[]".data(using: .utf8) }

let xcodesList = XcodeList()

XCTAssertTrue(xcodesList.shouldUpdateBeforeListingVersions)
}

func test_XcodeList_ShouldUpdate_WhenCacheFileIsCorrupt() {
Current.files.contentsAtPath = { _ in "[".data(using: .utf8) }

let xcodesList = XcodeList()

XCTAssertTrue(xcodesList.shouldUpdateBeforeListingVersions)
}

func test_XcodeList_LoadsCacheEvenIfAttributesFailToLoad() {
Current.files.contentsAtPath = { _ in try! JSONEncoder().encode([Self.mockXcode]) }
Current.files.attributesOfItemAtPath = { _ in throw NSError(domain: "com.robotsandpencils.xcodes", code: 0) }

let xcodesList = XcodeList()

XCTAssert(xcodesList.availableXcodes == [Self.mockXcode])
}
}