-
Notifications
You must be signed in to change notification settings - Fork 172
Expand file tree
/
Copy pathXcodeList.swift
More file actions
196 lines (168 loc) · 8.9 KB
/
XcodeList.swift
File metadata and controls
196 lines (168 loc) · 8.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import Foundation
import Path
import Version
import PromiseKit
import SwiftSoup
import struct XCModel.Xcode
/// Provides lists of available and installed Xcodes
public final class XcodeList {
public init() {
try? loadCachedAvailableXcodes()
}
public private(set) var availableXcodes: [Xcode] = []
public private(set) var lastUpdated: Date?
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]> {
switch dataSource {
case .apple:
return when(fulfilled: releasedXcodes(), prereleaseXcodes())
.map { releasedXcodes, prereleaseXcodes in
// Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode.
// Previously pre-release versions only appeared on developer.apple.com/download.
// /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build.
// If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes.
let xcodes = releasedXcodes.filter { releasedXcode in
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
}
}
}
}
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 {
let data = try JSONEncoder().encode(xcodes)
try FileManager.default.createDirectory(at: Path.cacheFile.url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try Current.files.write(data, to: Path.cacheFile.url)
}
}
extension XcodeList {
// MARK: - Apple
private func releasedXcodes() -> Promise<[Xcode]> {
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest.downloads)
}
.map { (data, response) -> [Xcode] in
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(.downloadsDateModified)
let downloads = try decoder.decode(Downloads.self, from: data)
let xcodes = downloads
.downloads
.filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil }
.compactMap { download -> Xcode? in
let urlPrefix = URL(string: "https://download.developer.apple.com/")!
guard
let xcodeFile = download.files.first(where: { $0.remotePath.hasSuffix("dmg") || $0.remotePath.hasSuffix("xip") }),
let version = Version(xcodeVersion: download.name)
else { return nil }
let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath)
return Xcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified)
}
return xcodes
}
}
private func prereleaseXcodes() -> Promise<[Xcode]> {
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest.download)
}
.map { (data, _) -> [Xcode] in
try self.parsePrereleaseXcodes(from: data)
}
}
func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] {
let body = String(data: data, encoding: .utf8)!
let document = try SwiftSoup.parse(body)
guard
let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(),
let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""),
let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""),
let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion),
let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"),
let url = URL(string: "https://developer.apple.com" + path)
else { return [] }
let filename = String(path.suffix(fromLast: "/"))
return [Xcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))]
}
}
extension XcodeList {
// MARK: - XcodeReleases
private func xcodeReleases() -> Promise<[Xcode]> {
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!))
}
.map { (data, response) in
let decoder = JSONDecoder()
let xcReleasesXcodes = try decoder.decode([XCModel.Xcode].self, from: data)
let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> Xcode? in
guard
let downloadURL = xcReleasesXcode.links?.download?.url,
let version = Version(xcReleasesXcode: xcReleasesXcode)
else { return nil }
let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents(
year: xcReleasesXcode.date.year,
month: xcReleasesXcode.date.month,
day: xcReleasesXcode.date.day
))
return Xcode(
version: version,
url: downloadURL,
filename: String(downloadURL.path.suffix(fromLast: "/")),
releaseDate: releaseDate
)
}
return xcodes
}
.map(XcodeList.filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers)
}
/// Xcode Releases may have multiple releases with the same build metadata when a build doesn't change between candidate and final releases.
/// For example, 12.3 RC and 12.3 are both build 12C33
/// We don't care about that difference, so only keep the final release (GM or Release, in XCModel terms).
/// The downside of this is that a user could technically have both releases installed, and so they won't both be shown in the list, but I think most users wouldn't do this.
/// This may not preserve order.
static func filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers(_ xcodes: [Xcode]) -> [Xcode] {
let xcodesByBuildMetadataIdentifiers =
Dictionary(grouping: xcodes, by: { $0.version.buildMetadataIdentifiers })
var filteredXcodes: [Xcode] = []
for (buildMetadataIdentifiers, xcodes) in xcodesByBuildMetadataIdentifiers {
if buildMetadataIdentifiers.isEmpty || xcodes.count == 1 {
filteredXcodes.append(contentsOf: xcodes)
continue
}
// Use the final release if there is one, otherwise just (arbitrarily) pick the first.
let finalRelease = xcodes.first(where: {
$0.version.prereleaseIdentifiers.isEmpty || $0.version.prereleaseIdentifiers == ["GM"] })
filteredXcodes.append(finalRelease ?? xcodes.first!)
}
return filteredXcodes
}
}