diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 9c21539..e3e7247 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -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 { diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 445ad17..ea083bf 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -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 in - if dataSource == .apple { - return loginIfNeeded().map { version } + return firstly { () -> Promise 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 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 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 in guard let xcode = self.xcodeList.availableXcodes.first(withVersion: version) else { throw Error.unavailableVersion(version) } + return Promise.value(xcode) + } + .then { xcode -> Promise 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("") diff --git a/Sources/XcodesKit/XcodeList.swift b/Sources/XcodesKit/XcodeList.swift index 3b9dbd3..2b1d6b9 100644 --- a/Sources/XcodesKit/XcodeList.swift +++ b/Sources/XcodesKit/XcodeList.swift @@ -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 { @@ -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]> { @@ -30,6 +35,7 @@ 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 } @@ -37,6 +43,7 @@ public final class XcodeList { return xcodeReleases() .map { xcodes in self.availableXcodes = xcodes + self.lastUpdated = Date() try? self.cacheAvailableXcodes(xcodes) return xcodes } @@ -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 { diff --git a/Sources/xcodes/App.swift b/Sources/xcodes/App.swift index de9ae87..336684f 100644 --- a/Sources/xcodes/App.swift +++ b/Sources/xcodes/App.swift @@ -303,7 +303,7 @@ struct Xcodes: AsyncParsableCommand { let directory = getDirectory(possibleDirectory: globalDirectory.directory) firstly { () -> Promise in - if xcodeList.shouldUpdate { + if xcodeList.shouldUpdateBeforeListingVersions { return installer.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) } else { diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index 4b0edf1..53197b6 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -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") { diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 7a5fbc6..4e68e70 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -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! @@ -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]) + } }