#if os(macOS) import AppKit import Foundation import Libbox import Library import os import SwiftUI private let logger = Logger(category: "UpdateManager") @MainActor public class UpdateManager: ObservableObject { private static let minimumSemver = "0.0.0-0" @Published public var updateInfo: UpdateInfo? @Published public var isUpdateSheetPresented = false @Published public var isChecking = false @Published public var isDownloading = false @Published public var downloadProgress: Double = 0 @Published public var alert: AlertState? public init() {} public func updateTrackChanged(to track: UpdateTrack) async { await SharedPreferences.updateTrack.set(track.rawValue) guard let updateInfo, !track.allows(updateInfo) else { return } await setUpdateInfo(nil) } @discardableResult public func loadCachedUpdate() async -> Bool { let cached = await SharedPreferences.cachedUpdateInfo.get() guard !cached.isEmpty, let data = cached.data(using: .utf8), let info = try? JSONDecoder().decode(UpdateInfo.self, from: data) else { return false } let track = await currentTrack() guard track.allows(info), shouldKeepCachedUpdate(info.versionName, track: track, currentVersion: Bundle.main.version) else { await setUpdateInfo(nil) return false } updateInfo = info return await shouldAutomaticallyPresent(info) } @discardableResult public func checkForUpdate(presentIfFound: Bool = false, force: Bool = false, showsAlertOnFailure: Bool = true) async -> Bool { do { guard let info = try await refreshUpdateInfo(force: force, showsAlertOnFailure: showsAlertOnFailure) else { return false } guard presentIfFound else { return false } return await shouldAutomaticallyPresent(info) } catch { return false } } public func showUpdateSheet() async { guard let updateInfo else { return } await SharedPreferences.lastShownUpdateVersion.set(updateInfo.versionName) isUpdateSheetPresented = true } public func dismissUpdateSheet() { guard isUpdateSheetPresented else { return } isUpdateSheetPresented = false } public func downloadAndInstall(environments: ExtensionEnvironments) async { guard let updateInfo else { return } isDownloading = true downloadProgress = 0 alert = nil do { let pkgURL = try await PKGDownloader.download(from: updateInfo.downloadURL, expectedSize: updateInfo.fileSize) { [weak self] progress in Task { @MainActor in self?.downloadProgress = progress } } let authRef = try PKGInstaller.authorize() var profile = environments.extensionProfile if profile == nil { await environments.reload() profile = environments.extensionProfile } if let profile, profile.status.isConnected { try? await profile.stop() var waitCount = 0 while profile.status != .disconnected, waitCount < 10 { try? await Task.sleep(nanoseconds: 500_000_000) waitCount += 1 } } try await Task.detached { try PKGInstaller.install(pkgPath: pkgURL.path, authorization: authRef) }.value do { try PKGInstaller.scheduleInstalledApplicationRelaunch() } catch { logger.warning("relaunch failed: \(error.localizedDescription)") } exit(0) } catch PKGInstallerError.authorizationCancelled { isDownloading = false } catch { isDownloading = false logger.error("update failed: \(error.localizedDescription)") alert = AlertState(action: "install update", error: error) } } func refreshUpdateInfo(force: Bool = false, showsAlertOnFailure: Bool = true) async throws -> UpdateInfo? { guard !isChecking else { throw CancellationError() } isChecking = true if showsAlertOnFailure { alert = nil } defer { isChecking = false } do { let track = await currentTrack() let info = try await GitHubUpdateChecker.checkAsync(track: track, force: force) let currentTrack = await currentTrack() guard track == currentTrack else { throw CancellationError() } await setUpdateInfo(info) return info } catch is CancellationError { throw CancellationError() } catch { logger.error("check for update failed: \(error.localizedDescription)") if showsAlertOnFailure { alert = AlertState(action: "check for update", error: error) } throw error } } private func currentTrack() async -> UpdateTrack { let trackString = await SharedPreferences.updateTrack.get() return UpdateTrack.resolved(from: trackString) } private func shouldAutomaticallyPresent(_ updateInfo: UpdateInfo) async -> Bool { let lastShownVersion = await SharedPreferences.lastShownUpdateVersion.get() return lastShownVersion != updateInfo.versionName } private func shouldKeepCachedUpdate(_ version: String, track: UpdateTrack, currentVersion: String) -> Bool { guard Self.isValidSemver(version) else { return false } if LibboxCompareSemver(version, currentVersion) { return true } return track == .stable && Self.isValidPrereleaseSemver(currentVersion) } private static func isValidSemver(_ version: String) -> Bool { let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedVersion == minimumSemver || LibboxCompareSemver(trimmedVersion, minimumSemver) } private static func isValidPrereleaseSemver(_ version: String) -> Bool { let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedVersion.contains("-") && isValidSemver(trimmedVersion) } private func setUpdateInfo(_ updateInfo: UpdateInfo?) async { self.updateInfo = updateInfo guard let updateInfo, let data = try? JSONEncoder().encode(updateInfo) else { dismissUpdateSheet() await SharedPreferences.cachedUpdateInfo.set("") await SharedPreferences.lastShownUpdateVersion.set("") return } await SharedPreferences.cachedUpdateInfo.set(String(decoding: data, as: UTF8.self)) } } #endif