Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UpdateController.swift
T

217 lines
9.4 KiB
Swift

import AppKit
import Foundation
import Observation
import Updates
/// 在线升级:检查官网版本清单,发现新版时提示摘要,一键下载、校验、原地替换并重启
@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 terminate: () -> Void = { NSApp.terminate(nil) }
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private var task: Task<Void, Never>?
static let checkInterval: TimeInterval = 24 * 60 * 60
init(settings: AppSettings, defaults: UserDefaults = .standard) {
self.settings = settings
self.defaults = defaults
lastChecked = defaults.object(forKey: Keys.lastChecked) as? Date
skippedVersion = defaults.string(forKey: Keys.skippedVersion)
}
var currentVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
}
var isBusy: Bool {
switch phase {
case .checking, .downloading, .verifying, .installing: true
default: false
}
}
/// 开发构建没有 Developer ID 签名,无法校验新版的签名团队,只能手动安装
var installBlockedReason: String? {
guard let bundleID = Bundle.main.bundleIdentifier, bundleID == "com.openstats.app" else { return "开发构建不支持在线升级" }
guard UpdateInstaller.teamIdentifier(of: Bundle.main.bundleURL) != nil else { return "开发构建不支持在线升级,请下载安装包" }
return nil
}
// MARK: 检查
/// 启动后与每天一次的自动检查;距离上次检查不足一天时跳过
func checkIfNeeded() {
guard settings.autoCheckUpdates, !isBusy else { return }
if let lastChecked, Date().timeIntervalSince(lastChecked) < Self.checkInterval { return }
check(userInitiated: false)
}
func check(userInitiated: Bool) {
guard !isBusy else { return }
phase = .checking
task = Task {
let result = await Self.fetch()
lastChecked = Date()
switch result {
case .success(let latest):
guard UpdateFeed.isNewer(latest.version, than: currentVersion) else {
release = nil
phase = .upToDate
return
}
release = latest
phase = .available
Log.update.notice("发现新版本 \(latest.version, privacy: .public)")
// 手动检查时总是提示;自动检查时跳过用户选择忽略的版本
if userInitiated || latest.version != skippedVersion { onPrompt() }
case .failure(let error):
Log.update.error("检查更新失败:\(error.localizedDescription, privacy: .public)")
// 自动检查失败不打扰用户,只在关于页里显示
phase = userInitiated ? .failed(error.localizedDescription) : (release == nil ? .idle : .available)
}
}
}
private static func fetch() async -> Result<UpdateRelease, UpdateError> {
var request = URLRequest(url: UpdateFeed.url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20)
request.setValue("OpenStats", forHTTPHeaderField: "User-Agent")
do {
let (data, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
return .failure(.download("服务器返回 \(http.statusCode)"))
}
guard let release = UpdateFeed.parse(data) else { return .failure(.download("版本清单格式不正确")) }
return .success(release)
} catch {
return .failure(.download(error.localizedDescription))
}
}
/// 截图用:直接放入一个示例版本
func showPreview(_ release: UpdateRelease) {
self.release = release
phase = .available
}
func skipCurrentRelease() {
skippedVersion = release?.version
}
// MARK: 安装
func install() {
guard let release, !isBusy else { return }
if let reason = installBlockedReason {
phase = .failed(reason)
return
}
guard UpdateFeed.systemSatisfies(release.minimumSystem) else {
phase = .failed("新版本需要 macOS \(release.minimumSystem) 或更高版本")
return
}
let app = Bundle.main.bundleURL
guard let team = UpdateInstaller.teamIdentifier(of: app) else { return }
let bundleID = Bundle.main.bundleIdentifier ?? ""
phase = .downloading(0)
task = Task {
do {
// 临时目录与应用在同一卷上,替换时是原子改名
let work = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask,
appropriateFor: app, create: true)
defer { try? FileManager.default.removeItem(at: work) }
let archive = try await UpdateInstaller.download(release.url, into: work) { fraction in
Task { @MainActor in
guard case .downloading = self.phase else { return }
self.phase = .downloading(fraction)
}
}
phase = .verifying
let candidate = try await Task.detached {
guard try UpdateInstaller.sha256(of: archive) == release.sha256.lowercased() else {
throw UpdateError.checksumMismatch
}
let candidate = try UpdateInstaller.extractApp(from: archive, into: work)
try UpdateInstaller.verify(candidate, bundleIdentifier: bundleID, version: release.version, teamIdentifier: team)
return candidate
}.value
phase = .installing
if UpdateInstaller.canReplaceInPlace(app) {
try await Task.detached { try UpdateInstaller.replace(app, with: candidate, backupDirectory: work) }.value
} else {
try await replaceWithAdministratorPrompt(app, with: candidate)
}
skippedVersion = nil
Log.update.notice("已安装 \(release.version, privacy: .public),重新启动")
try? FileManager.default.removeItem(at: work)
try UpdateInstaller.relaunch(app)
terminate()
} catch {
// 取消下载时 URLSession 抛出的是 URLError,按任务是否被取消判断
if !Task.isCancelled { Log.update.error("安装更新失败:\(error.localizedDescription, privacy: .public)") }
phase = Task.isCancelled ? .available
: .failed((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
}
}
func cancel() {
task?.cancel()
}
func openManualDownload() {
if let url = release?.dmg ?? URL(string: "https://getopenstats.com/#download") {
NSWorkspace.shared.open(url)
}
}
/// 当前账户不能改写应用目录时(非管理员或应用放在受保护的位置),请求一次管理员授权完成替换
private func replaceWithAdministratorPrompt(_ app: URL, with candidate: URL) async throws {
let quote = { (path: String) in "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" }
let backup = candidate.deletingLastPathComponent().appendingPathComponent("previous-\(app.lastPathComponent)")
let (a, b, c) = (quote(app.path), quote(backup.path), quote(candidate.path))
// 旧版改名备份 → 新版移入;移入失败时还原旧版并以失败退出;成功后沿用旧版的属主
let shell = "/bin/mv -f \(a) \(b) && { /bin/mv -f \(c) \(a) || { /bin/mv -f \(b) \(a); exit 1; }; }"
+ " && /usr/sbin/chown -R \"$(/usr/bin/stat -f %u:%g \(b))\" \(a)"
if let error = await MaintenanceController.runWithAdministratorPrompt(shell: shell, prompt: "OpenStats 需要授权以安装新版本。") {
throw UpdateError.install(error)
}
guard FileManager.default.fileExists(atPath: app.appendingPathComponent("Contents/Info.plist").path) else {
throw UpdateError.install("替换后未找到应用")
}
}
private enum Keys {
static let lastChecked = "updateLastChecked"
static let skippedVersion = "updateSkippedVersion"
}
}