mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 20:40:12 +00:00
326 lines
11 KiB
Swift
326 lines
11 KiB
Swift
import Foundation
|
|
|
|
struct GitHubReleaseCheckService: @unchecked Sendable {
|
|
private let owner: String
|
|
private let repository: String
|
|
private let fetch: @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
|
|
|
init(
|
|
owner: String = "nimbold",
|
|
repository: String = "Firelink",
|
|
fetch: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in
|
|
try await URLSession.shared.data(for: request)
|
|
}
|
|
) {
|
|
self.owner = owner
|
|
self.repository = repository
|
|
self.fetch = fetch
|
|
}
|
|
|
|
func checkForUpdate(currentVersion: String) async throws -> ReleaseCheckOutcome {
|
|
guard let current = AppVersion(currentVersion) else {
|
|
throw ReleaseCheckFailure.invalidCurrentVersion(currentVersion)
|
|
}
|
|
|
|
let release = try await latestStableRelease()
|
|
guard let latest = AppVersion(release.tagName) else {
|
|
throw ReleaseCheckFailure.invalidReleaseVersion(release.tagName)
|
|
}
|
|
|
|
let update = AvailableReleaseUpdate(
|
|
version: latest.description,
|
|
tagName: release.tagName,
|
|
title: release.name?.isEmpty == false ? release.name! : release.tagName,
|
|
releaseNotes: release.body?.isEmpty == false ? release.body! : "No release notes were provided for this version.",
|
|
releaseURL: release.htmlURL,
|
|
publishedAt: release.publishedAt
|
|
)
|
|
|
|
if latest > current {
|
|
return .updateAvailable(update)
|
|
}
|
|
|
|
return .upToDate(latestVersion: latest.description, localVersion: current.description)
|
|
}
|
|
|
|
private func latestStableRelease() async throws -> GitHubRelease {
|
|
guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repository)/releases?per_page=30") else {
|
|
throw ReleaseCheckFailure.invalidReleaseURL
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
|
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, response) = try await fetch(request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw ReleaseCheckFailure.invalidResponse
|
|
}
|
|
|
|
guard (200..<300).contains(httpResponse.statusCode) else {
|
|
throw ReleaseCheckFailure.httpStatus(httpResponse.statusCode)
|
|
}
|
|
|
|
let releases = try JSONDecoder.githubReleaseDecoder.decode([GitHubRelease].self, from: data)
|
|
let stableReleases = releases.filter { !$0.draft && !$0.prerelease }
|
|
guard !stableReleases.isEmpty else {
|
|
throw ReleaseCheckFailure.noStableRelease
|
|
}
|
|
|
|
let versionedReleases = stableReleases.compactMap { release -> (release: GitHubRelease, version: AppVersion)? in
|
|
guard let version = AppVersion(release.tagName) else { return nil }
|
|
return (release, version)
|
|
}
|
|
|
|
guard let latest = versionedReleases.max(by: { $0.version < $1.version }) else {
|
|
throw ReleaseCheckFailure.invalidReleaseVersion(stableReleases[0].tagName)
|
|
}
|
|
|
|
return latest.release
|
|
}
|
|
}
|
|
|
|
struct GitHubRelease: Decodable, Equatable {
|
|
let tagName: String
|
|
let name: String?
|
|
let body: String?
|
|
let htmlURL: URL
|
|
let draft: Bool
|
|
let prerelease: Bool
|
|
let publishedAt: Date?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case tagName = "tag_name"
|
|
case name
|
|
case body
|
|
case htmlURL = "html_url"
|
|
case draft
|
|
case prerelease
|
|
case publishedAt = "published_at"
|
|
}
|
|
}
|
|
|
|
struct AvailableReleaseUpdate: Equatable, Identifiable {
|
|
var id: String { tagName }
|
|
let version: String
|
|
let tagName: String
|
|
let title: String
|
|
let releaseNotes: String
|
|
let releaseURL: URL
|
|
let publishedAt: Date?
|
|
}
|
|
|
|
enum ReleaseCheckOutcome: Equatable {
|
|
case updateAvailable(AvailableReleaseUpdate)
|
|
case upToDate(latestVersion: String, localVersion: String)
|
|
}
|
|
|
|
enum ReleaseUpdateState: Equatable {
|
|
case idle
|
|
case checking
|
|
case updateAvailable(AvailableReleaseUpdate)
|
|
case upToDate(latestVersion: String, localVersion: String)
|
|
case failed(message: String, recovery: String)
|
|
}
|
|
|
|
enum ReleaseCheckFailure: Error, Equatable {
|
|
case invalidReleaseURL
|
|
case invalidResponse
|
|
case httpStatus(Int)
|
|
case noStableRelease
|
|
case invalidCurrentVersion(String)
|
|
case invalidReleaseVersion(String)
|
|
}
|
|
|
|
@MainActor
|
|
final class ReleaseUpdateChecker: ObservableObject {
|
|
@Published private(set) var state: ReleaseUpdateState = .idle
|
|
@Published var automaticallyChecksForUpdates: Bool {
|
|
didSet {
|
|
UserDefaults.standard.set(automaticallyChecksForUpdates, forKey: Self.automaticChecksKey)
|
|
}
|
|
}
|
|
|
|
private let service: GitHubReleaseCheckService
|
|
private let bundle: Bundle
|
|
private var automaticCheckTask: Task<Void, Never>?
|
|
|
|
private static let automaticChecksKey = "AutomaticallyCheckForReleaseUpdates"
|
|
private static let lastAutomaticCheckKey = "LastReleaseUpdateCheckDate"
|
|
private static let automaticCheckInterval: TimeInterval = 24 * 60 * 60
|
|
|
|
init(service: GitHubReleaseCheckService = GitHubReleaseCheckService(), bundle: Bundle = .main) {
|
|
self.service = service
|
|
self.bundle = bundle
|
|
|
|
if UserDefaults.standard.object(forKey: Self.automaticChecksKey) == nil {
|
|
self.automaticallyChecksForUpdates = true
|
|
} else {
|
|
self.automaticallyChecksForUpdates = UserDefaults.standard.bool(forKey: Self.automaticChecksKey)
|
|
}
|
|
}
|
|
|
|
var currentVersion: String {
|
|
bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
|
|
}
|
|
|
|
func checkForUpdates() {
|
|
guard state != .checking else { return }
|
|
state = .checking
|
|
|
|
Task {
|
|
do {
|
|
let outcome = try await service.checkForUpdate(currentVersion: currentVersion)
|
|
UserDefaults.standard.set(Date(), forKey: Self.lastAutomaticCheckKey)
|
|
apply(outcome)
|
|
} catch {
|
|
state = Self.failedState(for: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkAutomaticallyIfNeeded() {
|
|
guard automaticallyChecksForUpdates else { return }
|
|
guard state == .idle else { return }
|
|
|
|
if let lastCheck = UserDefaults.standard.object(forKey: Self.lastAutomaticCheckKey) as? Date,
|
|
Date().timeIntervalSince(lastCheck) < Self.automaticCheckInterval {
|
|
return
|
|
}
|
|
|
|
automaticCheckTask?.cancel()
|
|
automaticCheckTask = Task { [weak self] in
|
|
self?.checkForUpdates()
|
|
}
|
|
}
|
|
|
|
private func apply(_ outcome: ReleaseCheckOutcome) {
|
|
switch outcome {
|
|
case .updateAvailable(let update):
|
|
state = .updateAvailable(update)
|
|
case .upToDate(let latestVersion, let localVersion):
|
|
state = .upToDate(latestVersion: latestVersion, localVersion: localVersion)
|
|
}
|
|
}
|
|
|
|
private static func failedState(for error: Error) -> ReleaseUpdateState {
|
|
if let urlError = error as? URLError {
|
|
switch urlError.code {
|
|
case .notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, .timedOut:
|
|
return .failed(
|
|
message: "Couldn't reach GitHub.",
|
|
recovery: "Check your internet connection, then try again."
|
|
)
|
|
default:
|
|
return .failed(
|
|
message: "The update check couldn't finish.",
|
|
recovery: urlError.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
|
|
if let failure = error as? ReleaseCheckFailure {
|
|
switch failure {
|
|
case .httpStatus(let statusCode):
|
|
return .failed(
|
|
message: "GitHub returned HTTP \(statusCode).",
|
|
recovery: "Try again in a moment, or open the releases page manually."
|
|
)
|
|
case .noStableRelease:
|
|
return .failed(
|
|
message: "No stable release was found.",
|
|
recovery: "Open GitHub Releases to check the project manually."
|
|
)
|
|
case .invalidCurrentVersion(let version):
|
|
return .failed(
|
|
message: "Firelink's current version could not be read.",
|
|
recovery: "The app reported version \(version)."
|
|
)
|
|
case .invalidReleaseVersion(let tag):
|
|
return .failed(
|
|
message: "The latest release tag could not be compared.",
|
|
recovery: "GitHub reported \(tag)."
|
|
)
|
|
case .invalidReleaseURL, .invalidResponse:
|
|
return .failed(
|
|
message: "The GitHub response could not be read.",
|
|
recovery: "Try again in a moment."
|
|
)
|
|
}
|
|
}
|
|
|
|
return .failed(
|
|
message: "The update check failed.",
|
|
recovery: error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
|
|
struct AppVersion: Comparable, CustomStringConvertible {
|
|
private let components: [Int]
|
|
|
|
init?(_ rawValue: String) {
|
|
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if value.hasPrefix("v") || value.hasPrefix("V") {
|
|
value.removeFirst()
|
|
}
|
|
|
|
let prefix = value.prefix { character in
|
|
character.isNumber || character == "."
|
|
}
|
|
|
|
let version = String(prefix).trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
|
guard !version.isEmpty else { return nil }
|
|
|
|
let components = version.split(separator: ".").compactMap { Int($0) }
|
|
guard !components.isEmpty, components.count == version.split(separator: ".").count else {
|
|
return nil
|
|
}
|
|
|
|
self.components = components
|
|
}
|
|
|
|
var description: String {
|
|
components.map(String.init).joined(separator: ".")
|
|
}
|
|
|
|
static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
|
|
let count = max(lhs.components.count, rhs.components.count)
|
|
|
|
for index in 0..<count {
|
|
let left = index < lhs.components.count ? lhs.components[index] : 0
|
|
let right = index < rhs.components.count ? rhs.components[index] : 0
|
|
|
|
if left != right {
|
|
return left < right
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
static func == (lhs: AppVersion, rhs: AppVersion) -> Bool {
|
|
let count = max(lhs.components.count, rhs.components.count)
|
|
|
|
for index in 0..<count {
|
|
let left = index < lhs.components.count ? lhs.components[index] : 0
|
|
let right = index < rhs.components.count ? rhs.components[index] : 0
|
|
|
|
if left != right {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
private extension JSONDecoder {
|
|
static var githubReleaseDecoder: JSONDecoder {
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
return decoder
|
|
}
|
|
}
|