secretive/Sources/Packages/Sources/Brief/Updater.swift

124 lines
4.6 KiB
Swift

import Foundation
import Combine
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
public actor Updater: ObservableObject, UpdaterProtocol {
@MainActor @Published public var update: Release?
public let testBuild: Bool
/// The current OS version.
private let osVersion: SemVer
/// The current version of the app that is running.
private let currentVersion: SemVer
/// The timer responsible for checking for updates regularly.
private var timer: Timer? = nil
/// Initializes an Updater.
/// - Parameters:
/// - osVersion: The current OS version.
/// - currentVersion: The current version of the app that is running.
public init(osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion), currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")) {
self.osVersion = osVersion
self.currentVersion = currentVersion
testBuild = currentVersion == SemVer("0.0.0")
}
/// Begins checking for updates with the specified frequency.
/// - Parameter checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
public func beginChecking(checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value) {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: checkFrequency, repeats: true) { _ in
Task {
await self.checkForUpdates()
}
}
timer?.tolerance = 60*60
}
/// Ends checking for updates.
public func stopChecking() {
timer?.invalidate()
timer = nil
}
/// Manually trigger an update check.
public func checkForUpdates() async {
guard let (data, _) = try? await URLSession.shared.data(from: Constants.updateURL),
let releases = try? JSONDecoder().decode([Release].self, from: data) else { return }
await evaluate(releases: releases)
}
/// Ignores a specified release. `update` will be nil if the user has ignored the latest available release.
/// - Parameter release: The release to ignore.
public func ignore(release: Release) async {
guard !release.critical else { return }
defaults.set(true, forKey: release.name)
await setUpdate(update: update)
}
}
extension Updater {
/// Evaluates the available downloadable releases, and selects the newest non-prerelease release that the user is able to run.
/// - Parameter releases: An array of ``Release`` objects.
func evaluate(releases: [Release]) async {
guard let release = releases
.sorted()
.reversed()
.filter({ !$0.prerelease })
.first(where: { $0.minimumOSVersion <= osVersion }) else { return }
guard !userIgnored(release: release) else { return }
guard !release.prerelease else { return }
let latestVersion = SemVer(release.name)
if latestVersion > currentVersion {
await setUpdate(update: update)
}
}
@MainActor private func setUpdate(update: Release?) {
self.update = update
}
/// Checks whether the user has ignored a release.
/// - Parameter release: The release to check.
/// - Returns: A boolean describing whether the user has ignored the release. Will always be false if the release is critical.
func userIgnored(release: Release) -> Bool {
guard !release.critical else { return false }
return defaults.bool(forKey: release.name)
}
/// The user defaults used to store user ignore state.
var defaults: UserDefaults {
UserDefaults(suiteName: "com.maxgoedjen.Secretive.updater.ignorelist")!
}
}
extension Updater {
enum Constants {
static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")!
}
}
@available(macOS, deprecated: 12)
extension URLSession {
// Backport for macOS 11
func data(from url: URL) async throws -> (Data, URLResponse) {
try await withCheckedThrowingContinuation { continuation in
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, let response = response else {
continuation.resume(throwing: error ?? NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: nil))
return
}
continuation.resume(returning: (data, response))
}
}
}
}