Files
XStats/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift
T

234 lines
9.5 KiB
Swift

import AppKit
import Foundation
import Localization
import Observation
import Sparkle
import Updates
/// 在线升级:Sparkle 管理检查、跳过和安装,XStats 提供现有界面与系统通知。
@MainActor
@Observable
public final class UpdateController {
public enum Phase: Equatable {
case idle
case checking
case upToDate
case available
case downloading(Double)
case verifying
case installing
case failed(String)
}
public private(set) var phase: Phase = .idle
public private(set) var release: UpdateRelease?
public private(set) var lastChecked: Date? {
didSet { defaults.set(lastChecked, forKey: Keys.lastChecked) }
}
public private(set) var skippedVersion: String? {
didSet { defaults.set(skippedVersion, forKey: Keys.skippedVersion) }
}
/// 手动检查发现新版本时调用,由 AppController 打开升级提示窗口
@ObservationIgnored var onPrompt: () -> Void = {}
/// 后台发现新版本时请求系统通知;成功提交后才记录去重状态。
@ObservationIgnored var onUpdateAvailable: (String) async -> Bool = { _ in false }
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private var updater: SPUUpdater?
@ObservationIgnored private var sparkleInstaller: SparkleInstaller?
@ObservationIgnored private let launchedAt: Date
@ObservationIgnored private var isNotifying = false
@ObservationIgnored private var started = false
init(settings: AppSettings, defaults: UserDefaults = .standard, launchedAt: Date = Date()) {
self.settings = settings
self.defaults = defaults
self.launchedAt = launchedAt
lastChecked = defaults.object(forKey: Keys.lastChecked) as? Date
skippedVersion = defaults.string(forKey: Keys.skippedVersion)
// Sparkle 公开记录键:首轮迁移继承旧成功时间,避免“刚检查过”被当成首次检查。
// 只初始化一次,之后不覆盖 Sparkle 自己管理的检查时间。
if defaults.object(forKey: "SULastCheckTime") == nil, let lastChecked {
defaults.set(lastChecked, forKey: "SULastCheckTime")
}
}
var currentVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
}
var isBusy: Bool {
switch phase {
case .checking, .downloading, .verifying, .installing: true
default: false
}
}
var isDownloading: Bool {
if case .downloading = phase { return true }
return false
}
/// 未签名或临时签名的开发构建不执行在线安装,只提供手动下载
var installBlockedReason: String? {
// 仅为当前产品启用安装器,防止开发/旧产品构建误用发布更新源。
guard let bundleID = Bundle.main.bundleIdentifier, bundleID == "work.12306.xstats.app" else { return tr("开发构建不支持在线升级") }
guard UpdateInstaller.teamIdentifier(of: Bundle.main.bundleURL) != nil else { return tr("开发构建不支持在线升级,请下载安装包") }
return nil
}
// MARK: 检查
/// 启动后创建唯一的 Sparkle 调度器;不另设 XStats 轮询定时器。
func start() {
guard !started else { return }
do {
try ensureUpdater()
started = true
applySchedule(initial: true)
observeSchedule()
checkIfNeeded()
} catch {
phase = .failed(error.localizedDescription)
}
}
private func ensureUpdater() throws {
guard updater == nil else { return }
let driver = SparkleInstaller(onPhase: { [weak self] in self?.phase = $0 },
onRelaunch: { [weak self] in self?.skippedVersion = nil })
driver.onChecked = { [weak self] in self?.lastChecked = Date() }
driver.onNoUpdate = { [weak self] in self?.release = nil }
driver.onRelease = { [weak self] release, manual in
guard let self else { return }
self.release = release
Task { [weak self] in
await self?.announceUpdate(version: release.version, userInitiated: manual,
suppressPrompt: !(self?.settings.updateCheckSchedule.promptsForUpdates ?? false))
}
}
driver.legacySkippedVersion = { [weak self] in self?.skippedVersion }
driver.onLegacySkipMigrated = { [weak self] in self?.skippedVersion = nil }
driver.onCycleFinished = { [weak self] success in self?.applySchedule(retry: !success) }
sparkleInstaller = driver
updater = try driver.start()
}
private func observeSchedule() {
withObservationTracking {
_ = settings.updateCheckSchedule
} onChange: { [weak self] in
Task { @MainActor [weak self] in
self?.applySchedule(initial: true)
self?.observeSchedule()
self?.checkIfNeeded()
}
}
}
/// Sparkle 的 timer 负责唤醒,薄策略层只保留启动检查、成功后间隔和自然月语义。
private func applySchedule(initial: Bool = false, retry: Bool = false) {
guard let updater else { return }
let now = Date()
guard let interval = settings.updateCheckSchedule.sparkleInterval(lastChecked: lastChecked, now: now,
launchedAt: launchedAt, retry: retry) else {
if updater.automaticallyChecksForUpdates { updater.automaticallyChecksForUpdates = false }
return
}
// 首次配置会按 Sparkle 的上次尝试时间减去 elapsed;补偿该值,保持旧成功记录的到期日。
let elapsed = initial ? max(0, now.timeIntervalSince(updater.lastUpdateCheckDate ?? now)) : 0
let nextInterval = max(60 * 60, interval + elapsed)
// Sparkle 的 setter 会触发 KVO/reset,即使值相同;无变化时不要重新安排周期。
if abs(updater.updateCheckInterval - nextInterval) >= 1 { updater.updateCheckInterval = nextInterval }
if !updater.automaticallyChecksForUpdates { updater.automaticallyChecksForUpdates = true }
}
func checkIfNeeded() {
let schedule = settings.updateCheckSchedule
guard !isBusy, schedule.shouldCheck(lastChecked: lastChecked, now: Date(), launchedAt: launchedAt) else { return }
check(userInitiated: false)
}
func check(userInitiated: Bool) {
guard !isBusy, sparkleInstaller?.isRunning != true else { return }
do {
try ensureUpdater()
sparkleInstaller?.check(userInitiated: userInitiated)
} catch {
phase = userInitiated ? .failed(error.localizedDescription) : .idle
}
}
/// 手动检查始终展示结果;后台通知遵循静默策略、开关、跳过版本及跨重启去重。
func announceUpdate(version: String, userInitiated: Bool, suppressPrompt: Bool) async {
if userInitiated {
onPrompt()
return
}
guard !suppressPrompt, settings.notifyUpdates, version != skippedVersion,
!isNotifying else { return }
if let last = defaults.string(forKey: Keys.notifiedVersion), !UpdateFeed.isNewer(version, than: last) { return }
// 通知权限弹窗期间允许查看更新,但不能重复提交后台通知。
isNotifying = true
defer { isNotifying = false }
if await onUpdateAvailable(version) { defaults.set(version, forKey: Keys.notifiedVersion) }
}
/// 截图用:直接放入一个示例版本
func showPreview(_ release: UpdateRelease) {
self.release = release
phase = .available
}
func skipCurrentRelease() {
guard let release, !isBusy else { return }
// 点击后立即生效,即使此时离线;签名条目重新可达时再交给 Sparkle 持久化。
skippedVersion = release.version
do {
try ensureUpdater()
sparkleInstaller?.check(userInitiated: false, action: .skip(release))
} catch {
phase = .failed(error.localizedDescription)
}
}
// MARK: 安装
func install() {
guard let release, !isBusy, sparkleInstaller?.isRunning != true else { return }
if let reason = installBlockedReason {
phase = .failed(reason)
return
}
guard UpdateFeed.systemSatisfies(release.minimumSystem) else {
phase = .failed(tr("新版本需要 macOS \(release.minimumSystem) 或更高版本"))
return
}
do {
try ensureUpdater()
sparkleInstaller?.check(userInitiated: false, action: .install(release))
} catch {
phase = .failed(error.localizedDescription)
}
}
func cancel() { sparkleInstaller?.cancel() }
/// 手动下载地址:拿到清单时用其中的 DMG,否则回到 GitHub Releases——那里始终挂着最新版 dmg
var manualDownloadURL: URL {
release?.dmg ?? URL(string: "https://github.com/ysicing/xstats/releases/latest")!
}
func openManualDownload() {
NSWorkspace.shared.open(manualDownloadURL)
}
private enum Keys {
static let lastChecked = "updateLastChecked"
static let skippedVersion = "updateSkippedVersion"
static let notifiedVersion = "updateNotifiedVersion"
}
}