- 列表发布后立即结束"正在扫描应用",计量在后台继续;新增 reloadApps 供显式刷新中断计量重新列举,已算出的大小保留 - 回收期间禁止勾选残留项,界面复选框同步禁用 - 后台检查失败时若已发现新版则保持"有更新",不再退回空闲 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
9.6 KiB
Swift
235 lines
9.6 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.hasKnownRelease = { [weak self] in self?.release != nil }
|
|
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"
|
|
}
|
|
}
|