在线升级:版本清单、摘要提示、一键安装与自动重启
This commit is contained in:
1 parent
0538dbc3ca
commit
b901bb8b67
19 files changed
+1015
-16
No files matched your search
@@ -3,6 +3,14 @@
|
||||
记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。
|
||||
官网、构建与发布脚本等不影响应用本身的改动不在此记录。
|
||||
|
||||
## 0.3.0 · 2026-09-13
|
||||
|
||||
### 2026-09-13
|
||||
|
||||
#### 新增
|
||||
|
||||
- 在线升级:启动时和之后每天检查官网版本清单,发现新版本时弹出版本号与更新摘要,可一键安装、以后再说或跳过此版本;下载后依次核对 sha256、包名与版本、开发者签名团队和 Apple 公证,任何一项不通过都放弃安装;旧版先备份再原地替换,失败自动还原,装完自动重启到新版。「设置 · 关于」与应用菜单里可以手动检查更新。
|
||||
|
||||
## 0.2.0 · 2026-09-13
|
||||
|
||||
### 2026-09-13
|
||||
|
||||
@@ -10,6 +10,7 @@ let package = Package(
|
||||
.library(name: "Metrics", targets: ["Metrics"]),
|
||||
.library(name: "HelperShared", targets: ["HelperShared"]),
|
||||
.library(name: "Cleaner", targets: ["Cleaner"]),
|
||||
.library(name: "Updates", targets: ["Updates"]),
|
||||
.library(name: "OpenStatsUI", targets: ["OpenStatsUI"]),
|
||||
],
|
||||
targets: [
|
||||
@@ -17,11 +18,13 @@ let package = Package(
|
||||
.target(name: "Metrics", dependencies: ["SMC"]),
|
||||
.target(name: "HelperShared"),
|
||||
.target(name: "Cleaner"),
|
||||
.target(name: "OpenStatsUI", dependencies: ["Metrics", "SMC", "HelperShared", "Cleaner"],
|
||||
.target(name: "Updates"),
|
||||
.target(name: "OpenStatsUI", dependencies: ["Metrics", "SMC", "HelperShared", "Cleaner", "Updates"],
|
||||
resources: [.copy("Resources/Flags")]),
|
||||
.testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]),
|
||||
.testTarget(name: "CleanerTests", dependencies: ["Cleaner"]),
|
||||
.testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]),
|
||||
.testTarget(name: "UpdatesTests", dependencies: ["Updates"]),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
@@ -7,6 +7,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
private let model = AppModel()
|
||||
private var menuBar: MenuBarController!
|
||||
private var mainWindow: MainWindowController!
|
||||
private var updateWindow: UpdateWindowController!
|
||||
private var updateTimer: Timer?
|
||||
private var workspaceObservers: [NSObjectProtocol] = []
|
||||
|
||||
public override init() {
|
||||
@@ -14,10 +16,12 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
public func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.mainMenu = MainMenu.make(settingsTarget: self, settingsAction: #selector(openSettingsFromMenu))
|
||||
NSApp.mainMenu = MainMenu.make(target: self, settingsAction: #selector(openSettingsFromMenu),
|
||||
updateAction: #selector(checkForUpdatesFromMenu))
|
||||
menuBar = MenuBarController(model: model)
|
||||
mainWindow = MainWindowController(model: model)
|
||||
mainWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
|
||||
updateWindow = UpdateWindowController(model: model)
|
||||
menuBar.update()
|
||||
|
||||
// 设置已并入主窗口:所有“设置…”入口都打开主窗口的通用设置
|
||||
@@ -44,6 +48,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
applyAppearance()
|
||||
model.network.updateProbing(networkShown: model.networkShown)
|
||||
Task { await model.geo.updateIfNeeded() }
|
||||
startUpdateChecks()
|
||||
|
||||
// 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口
|
||||
let arguments = CommandLine.arguments
|
||||
@@ -93,6 +98,26 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
|
||||
@objc private func openSettingsFromMenu() { model.openSettings() }
|
||||
|
||||
@objc private func checkForUpdatesFromMenu() {
|
||||
model.updates.check(userInitiated: true)
|
||||
mainWindow.show(tab: .settingsAbout)
|
||||
}
|
||||
|
||||
/// 启动 10 秒后检查一次,之后每小时看一眼是否已满一天
|
||||
private func startUpdateChecks() {
|
||||
model.updates.onPrompt = { [weak self] in
|
||||
self?.menuBar.dismissPopovers()
|
||||
self?.updateWindow.show()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in
|
||||
self?.model.updates.checkIfNeeded()
|
||||
}
|
||||
updateTimer = Timer.scheduledTimer(withTimeInterval: 60 * 60, repeats: true) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.model.updates.checkIfNeeded() }
|
||||
}
|
||||
updateTimer?.tolerance = 5 * 60
|
||||
}
|
||||
|
||||
/// 有窗口打开时显示在程序坞与 ⌘Tab 里,全部关闭后回到仅菜单栏
|
||||
private func updateActivationPolicy() {
|
||||
let hasWindow = mainWindow.isVisible
|
||||
|
||||
@@ -3,14 +3,14 @@ import AppKit
|
||||
/// 应用菜单。仅菜单栏运行时不显示,但仍负责 ⌘C / ⌘V / ⌘W 等快捷键
|
||||
@MainActor
|
||||
enum MainMenu {
|
||||
static func make(settingsTarget: AnyObject, settingsAction: Selector) -> NSMenu {
|
||||
static func make(target: AnyObject, settingsAction: Selector, updateAction: Selector) -> NSMenu {
|
||||
let main = NSMenu()
|
||||
|
||||
let app = NSMenu(title: "OpenStats")
|
||||
app.addItem(withTitle: "关于 OpenStats", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
|
||||
app.addItem(withTitle: "检查更新…", action: updateAction, keyEquivalent: "").target = target
|
||||
app.addItem(.separator())
|
||||
let settings = app.addItem(withTitle: "设置…", action: settingsAction, keyEquivalent: ",")
|
||||
settings.target = settingsTarget
|
||||
app.addItem(withTitle: "设置…", action: settingsAction, keyEquivalent: ",").target = target
|
||||
app.addItem(.separator())
|
||||
app.addItem(withTitle: "隐藏 OpenStats", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
||||
app.addItem(.separator())
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// 升级提示窗口:显示新版本摘要与“一键安装”
|
||||
@MainActor
|
||||
final class UpdateWindowController: NSObject, NSWindowDelegate {
|
||||
private let model: AppModel
|
||||
private var window: NSWindow?
|
||||
|
||||
init(model: AppModel) {
|
||||
self.model = model
|
||||
super.init()
|
||||
}
|
||||
|
||||
func show() {
|
||||
let window = self.window ?? makeWindow()
|
||||
self.window = window
|
||||
if !window.isVisible { window.center() }
|
||||
NSApp.activate()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
private func makeWindow() -> NSWindow {
|
||||
let window = NSWindow(contentRect: .zero, styleMask: [.titled, .closable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.title = "软件更新"
|
||||
WindowChrome.apply(to: window)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
let host = NSHostingView(rootView: UpdatePromptView { [weak window] in window?.performClose(nil) }.environment(model))
|
||||
window.contentView = host
|
||||
window.setContentSize(host.fittingSize)
|
||||
return window
|
||||
}
|
||||
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
// 正在替换应用时不允许关闭,避免用户以为已取消
|
||||
![.verifying, .installing].contains(model.updates.phase)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,9 @@ extension DS {
|
||||
/// 主窗口默认内容区宽度(侧边栏另计)
|
||||
static let windowDefaultContentWidth: CGFloat = 880
|
||||
static let sidebarWidth: CGFloat = 192
|
||||
/// 升级提示窗口
|
||||
static let updateWindowWidth: CGFloat = 480
|
||||
static let updateNotesHeight: CGFloat = 192
|
||||
static let valueColumn: CGFloat = 64
|
||||
static let labelColumn: CGFloat = 48
|
||||
}
|
||||
|
||||
@@ -597,6 +597,8 @@ struct AboutSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
UpdateSettings()
|
||||
|
||||
SettingsGroup(caption: "致谢") {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: "exelban/stats", subtitle: "SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License") {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Updates
|
||||
|
||||
/// 新版本信息:版本号、日期、体积与更新摘要,升级提示窗口与“关于”页共用
|
||||
struct ReleaseSummary: View {
|
||||
let release: UpdateRelease
|
||||
let currentVersion: String
|
||||
/// 升级提示窗口里限制摘要区高度,超出时滚动;关于页里完整展开
|
||||
var notesHeight: CGFloat?
|
||||
var showsHeader = true
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||||
if showsHeader { header }
|
||||
|
||||
Text("更新内容").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
|
||||
notes
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
AppGlyph(size: DS.Space.s12)
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
Text(verbatim: "OpenStats \(release.version) 已发布")
|
||||
.dsFont(.lg, weight: .semibold)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(verbatim: "当前版本 \(currentVersion) · \(release.date) · \(ByteCountFormatter.string(fromByteCount: release.size, countStyle: .file))")
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var notes: some View {
|
||||
let list = VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||||
ForEach(Array(release.notes.enumerated()), id: \.offset) { _, note in
|
||||
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
|
||||
Circle().fill(DS.Palette.primary).frame(width: DS.Space.s1 + DS.Space.s1 / 2, height: DS.Space.s1 + DS.Space.s1 / 2)
|
||||
.alignmentGuide(.firstTextBaseline) { $0[VerticalAlignment.center] + DS.Space.s1 }
|
||||
Text(note).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
if let changelog = release.changelog {
|
||||
Button { NSWorkspace.shared.open(changelog) } label: {
|
||||
Text("查看完整更新日志").dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if let notesHeight {
|
||||
ViewThatFits(in: .vertical) {
|
||||
list.padding(DS.Space.s3)
|
||||
ScrollView { list.padding(DS.Space.s3) }.overlayScrollers()
|
||||
}
|
||||
.frame(maxHeight: notesHeight)
|
||||
.background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md))
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载 / 校验 / 安装的进度与失败提示
|
||||
struct UpdateProgress: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let updates = model.updates
|
||||
switch updates.phase {
|
||||
case .downloading(let fraction):
|
||||
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||||
HStack {
|
||||
Text("正在下载").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
Spacer()
|
||||
Text(verbatim: "\(Int((fraction * 100).rounded()))%").dsFont(.xs, weight: .medium).monospacedDigit()
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
ProgressTrack(fraction: fraction)
|
||||
}
|
||||
case .verifying:
|
||||
InfoBanner(icon: "checkmark.shield", text: "正在核对校验值、开发者签名与 Apple 公证…")
|
||||
case .installing:
|
||||
InfoBanner(icon: "arrow.triangle.2.circlepath", text: "正在安装,完成后 OpenStats 会自动重启。")
|
||||
case .failed(let message):
|
||||
InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) {
|
||||
if updates.release != nil {
|
||||
Button("手动下载") { updates.openManualDownload() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
}
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动检查发现新版本时弹出的窗口
|
||||
struct UpdatePromptView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let close: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let updates = model.updates
|
||||
VStack(alignment: .leading, spacing: DS.Space.s4) {
|
||||
if let release = updates.release {
|
||||
ReleaseSummary(release: release, currentVersion: updates.currentVersion, notesHeight: DS.Size.updateNotesHeight)
|
||||
}
|
||||
UpdateProgress()
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
if case .downloading = updates.phase {
|
||||
Spacer()
|
||||
Button("取消") { updates.cancel() }.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
} else {
|
||||
Button("跳过此版本") {
|
||||
updates.skipCurrentRelease()
|
||||
close()
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .ghost))
|
||||
Spacer()
|
||||
Button("以后再说", action: close).buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
Button("一键安装") { updates.install() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.disabled(updates.phase == .verifying || updates.phase == .installing)
|
||||
}
|
||||
.padding(.horizontal, DS.Space.s6)
|
||||
.padding(.bottom, DS.Space.s6)
|
||||
.padding(.top, DS.Size.windowHeader)
|
||||
.frame(width: DS.Size.updateWindowWidth)
|
||||
.background(DS.Palette.background)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 · 关于 里的“软件更新”分组
|
||||
struct UpdateSettings: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
@Bindable var settings = model.settings
|
||||
let updates = model.updates
|
||||
|
||||
SettingsGroup(caption: "软件更新") {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: statusTitle, subtitle: statusSubtitle) {
|
||||
Button(updates.phase == .checking ? "正在检查…" : "检查更新") { updates.check(userInitiated: true) }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(updates.isBusy)
|
||||
}
|
||||
}
|
||||
GroupRow {
|
||||
SettingRow(title: "自动检查更新", subtitle: "启动时和之后每天检查一次,发现新版本时显示更新摘要") {
|
||||
DSToggle(isOn: $settings.autoCheckUpdates, label: "自动检查更新")
|
||||
}
|
||||
}
|
||||
if let release = updates.release {
|
||||
GroupRow {
|
||||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||||
ReleaseSummary(release: release, currentVersion: updates.currentVersion, showsHeader: false)
|
||||
UpdateProgress()
|
||||
HStack {
|
||||
Spacer()
|
||||
if case .downloading = updates.phase {
|
||||
Button("取消") { updates.cancel() }.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
} else {
|
||||
Button("一键安装") { updates.install() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(updates.isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if case .failed = updates.phase {
|
||||
GroupRow { UpdateProgress() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
let updates = model.updates
|
||||
switch updates.phase {
|
||||
case .checking: return "正在检查更新"
|
||||
case .upToDate: return "已是最新版本"
|
||||
case .failed where updates.release == nil: return "检查更新失败"
|
||||
default: return updates.release.map { "发现新版本 \($0.version)" } ?? "当前版本 \(updates.currentVersion)"
|
||||
}
|
||||
}
|
||||
|
||||
private var statusSubtitle: String {
|
||||
let checked = model.updates.lastChecked.map { "上次检查:\($0.formatted(.relative(presentation: .named)))" } ?? "尚未检查"
|
||||
guard let release = model.updates.release else { return checked }
|
||||
let size = ByteCountFormatter.string(fromByteCount: release.size, countStyle: .file)
|
||||
return "当前 \(model.updates.currentVersion) · 发布于 \(release.date) · \(size) · \(checked)"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import AppKit
|
||||
import Metrics
|
||||
import SMC
|
||||
import SwiftUI
|
||||
import Updates
|
||||
|
||||
/// `OpenStats --snapshot <目录>`:用本机实时数据渲染各页面 PNG,用于设计走查
|
||||
@MainActor
|
||||
@@ -39,6 +40,12 @@ enum SnapshotRenderer {
|
||||
// 清理页展示真实扫描结果(只读,不删除任何文件)
|
||||
model.cleaner.scan()
|
||||
while model.cleaner.isBusy { try? await Task.sleep(for: .milliseconds(200)) }
|
||||
model.updates.showPreview(UpdateRelease(
|
||||
version: "0.3.0", build: "3", date: "2026-09-20", minimumSystem: "14.0",
|
||||
url: URL(string: "https://getopenstats.com/download/OpenStats-0.3.0.zip")!, sha256: String(repeating: "0", count: 64),
|
||||
size: 9_600_000, dmg: nil,
|
||||
notes: ["在线升级:发现新版本时显示更新摘要,一键安装并自动重启", "连接探测只在需要时运行,更省电", "进程页刷新频率调整为每 2 秒"],
|
||||
changelog: URL(string: "https://getopenstats.com/#changelog")))
|
||||
|
||||
for (appearanceName, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] {
|
||||
guard let appearance = NSAppearance(named: appearanceName) else { continue }
|
||||
@@ -48,6 +55,8 @@ enum SnapshotRenderer {
|
||||
write(MainWindowView(), model: model, appearance: appearance,
|
||||
to: outputDirectory.appendingPathComponent("window-\(tab.rawValue)-\(suffix).png"))
|
||||
}
|
||||
write(UpdatePromptView {}, model: model, appearance: appearance,
|
||||
to: outputDirectory.appendingPathComponent("update-prompt-\(suffix).png"))
|
||||
for item in MenuBarItem.allCases where item != .fan {
|
||||
write(PopoverRootView(item: item), model: model, appearance: appearance,
|
||||
to: outputDirectory.appendingPathComponent("popover-\(item.rawValue)-\(suffix).png"))
|
||||
|
||||
@@ -18,6 +18,7 @@ public final class AppModel {
|
||||
public let network: NetworkController
|
||||
public let geo: GeoDatabaseController
|
||||
public let explainer = ProcessExplainer()
|
||||
public let updates: UpdateController
|
||||
@ObservationIgnored public let hub = MetricsHub()
|
||||
|
||||
public var isMainWindowVisible = false
|
||||
@@ -42,6 +43,7 @@ public final class AppModel {
|
||||
maintenance = MaintenanceController(helper: helper)
|
||||
geo = GeoDatabaseController(settings: settings)
|
||||
network = NetworkController(settings: settings, geo: geo)
|
||||
updates = UpdateController(settings: settings)
|
||||
}
|
||||
|
||||
/// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项
|
||||
|
||||
@@ -367,6 +367,10 @@ public final class AppSettings {
|
||||
public var geoAutoUpdate: Bool {
|
||||
didSet { defaults.set(geoAutoUpdate, forKey: Keys.geoAutoUpdate) }
|
||||
}
|
||||
/// 启动时与每天检查一次新版本
|
||||
public var autoCheckUpdates: Bool {
|
||||
didSet { defaults.set(autoCheckUpdates, forKey: Keys.autoCheckUpdates) }
|
||||
}
|
||||
/// 可再生的缓存也先移到废纸篓(可恢复,但不会立即释放空间)
|
||||
public var cleanPrefersTrash: Bool {
|
||||
didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) }
|
||||
@@ -408,6 +412,7 @@ public final class AppSettings {
|
||||
publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true
|
||||
geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity)
|
||||
geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true
|
||||
autoCheckUpdates = defaults.object(forKey: Keys.autoCheckUpdates) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// 按固定顺序返回已启用的菜单栏项目
|
||||
@@ -457,5 +462,6 @@ public final class AppSettings {
|
||||
static let publicIPLookup = "publicIPLookup"
|
||||
static let geoIncludeCity = "geoIncludeCity"
|
||||
static let geoAutoUpdate = "geoAutoUpdate"
|
||||
static let autoCheckUpdates = "autoCheckUpdates"
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ public final class MaintenanceController {
|
||||
|
||||
/// 未安装辅助工具时的回退:通过 AppleScript 请求一次性管理员授权执行命令。
|
||||
/// 命令只由固定路径与已校验的参数拼成,这里再做 AppleScript 字符串转义
|
||||
private static func runWithAdministratorPrompt(shell: String, prompt: String) async -> String? {
|
||||
static func runWithAdministratorPrompt(shell: String, prompt: String) async -> String? {
|
||||
let escape = { (text: String) in text.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") }
|
||||
let script = "do shell script \"\(escape(shell))\" with administrator privileges with prompt \"\(escape(prompt))\""
|
||||
return await Task.detached {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
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
|
||||
// 手动检查时总是提示;自动检查时跳过用户选择忽略的版本
|
||||
if userInitiated || latest.version != skippedVersion { onPrompt() }
|
||||
case .failure(let error):
|
||||
// 自动检查失败不打扰用户,只在关于页里显示
|
||||
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
|
||||
try? FileManager.default.removeItem(at: work)
|
||||
try UpdateInstaller.relaunch(app)
|
||||
terminate()
|
||||
} catch {
|
||||
// 取消下载时 URLSession 抛出的是 URLError,按任务是否被取消判断
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
/// 官网上的版本清单(https://getopenstats.com/download/appcast.json),由 Scripts/publish_release.sh 生成
|
||||
public struct UpdateRelease: Codable, Sendable, Equatable {
|
||||
public let version: String
|
||||
public let build: String
|
||||
public let date: String
|
||||
public let minimumSystem: String
|
||||
/// 应用内升级下载的 zip(已公证、已装订的 OpenStats.app)
|
||||
public let url: URL
|
||||
public let sha256: String
|
||||
public let size: Int64
|
||||
/// 手动安装用的 DMG
|
||||
public let dmg: URL?
|
||||
/// 最近更新的摘要,每条一句
|
||||
public let notes: [String]
|
||||
public let changelog: URL?
|
||||
|
||||
public init(version: String, build: String, date: String, minimumSystem: String, url: URL, sha256: String,
|
||||
size: Int64, dmg: URL?, notes: [String], changelog: URL?) {
|
||||
self.version = version
|
||||
self.build = build
|
||||
self.date = date
|
||||
self.minimumSystem = minimumSystem
|
||||
self.url = url
|
||||
self.sha256 = sha256
|
||||
self.size = size
|
||||
self.dmg = dmg
|
||||
self.notes = notes
|
||||
self.changelog = changelog
|
||||
}
|
||||
}
|
||||
|
||||
public enum UpdateFeed {
|
||||
public static let url = URL(string: "https://getopenstats.com/download/appcast.json")!
|
||||
|
||||
public static func parse(_ data: Data) -> UpdateRelease? {
|
||||
guard let release = try? JSONDecoder().decode(UpdateRelease.self, from: data),
|
||||
!release.version.isEmpty, release.sha256.count == 64,
|
||||
release.url.scheme == "https" else { return nil }
|
||||
return release
|
||||
}
|
||||
|
||||
/// 按数字逐段比较版本号:“0.10.0” 比 “0.9.3” 新,“1.0” 与 “1.0.0” 相同
|
||||
public static func isNewer(_ candidate: String, than current: String) -> Bool {
|
||||
let lhs = components(candidate), rhs = components(current)
|
||||
for index in 0..<max(lhs.count, rhs.count) {
|
||||
let a = index < lhs.count ? lhs[index] : 0
|
||||
let b = index < rhs.count ? rhs[index] : 0
|
||||
if a != b { return a > b }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// 当前系统是否满足清单要求的最低版本
|
||||
public static func systemSatisfies(_ minimum: String, current: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion) -> Bool {
|
||||
let running = "\(current.majorVersion).\(current.minorVersion).\(current.patchVersion)"
|
||||
return !isNewer(minimum, than: running)
|
||||
}
|
||||
|
||||
private static func components(_ version: String) -> [Int] {
|
||||
version.split(whereSeparator: { !$0.isNumber }).map { Int($0) ?? 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public enum UpdateError: Error, LocalizedError, Equatable {
|
||||
case download(String)
|
||||
case checksumMismatch
|
||||
case archive(String)
|
||||
case invalidBundle(String)
|
||||
case signature(String)
|
||||
case gatekeeper
|
||||
case install(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .download(let reason): "下载失败:\(reason)"
|
||||
case .checksumMismatch: "安装包校验值与版本清单不一致,已放弃安装"
|
||||
case .archive(let reason): "解压失败:\(reason)"
|
||||
case .invalidBundle(let reason): "安装包内容不正确:\(reason)"
|
||||
case .signature(let reason): "签名校验未通过:\(reason)"
|
||||
case .gatekeeper: "新版本未通过 Apple 公证检查,已放弃安装"
|
||||
case .install(let reason): "替换应用失败:\(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 在线升级的各个步骤:下载 → 校验 sha256 → 解压 → 校验包名 / 版本 / 签名团队 / 公证 → 原地替换 → 重启
|
||||
public enum UpdateInstaller {
|
||||
// MARK: 下载
|
||||
|
||||
public static func download(_ url: URL, into directory: URL,
|
||||
progress: @escaping @Sendable (Double) -> Void) async throws -> URL {
|
||||
let destination = directory.appendingPathComponent(url.lastPathComponent)
|
||||
try await Downloader(destination: destination, progress: progress).run(url)
|
||||
return destination
|
||||
}
|
||||
|
||||
public static func sha256(of file: URL) throws -> String {
|
||||
let handle = try FileHandle(forReadingFrom: file)
|
||||
defer { try? handle.close() }
|
||||
var hasher = SHA256()
|
||||
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
|
||||
hasher.update(data: chunk)
|
||||
}
|
||||
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// MARK: 解压
|
||||
|
||||
/// ditto 解压,保留包内符号链接与扩展属性;返回解出的 .app
|
||||
public static func extractApp(from archive: URL, into directory: URL) throws -> URL {
|
||||
let output = directory.appendingPathComponent("extracted", isDirectory: true)
|
||||
try? FileManager.default.removeItem(at: output)
|
||||
try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
|
||||
let result = run("/usr/bin/ditto", ["-x", "-k", archive.path, output.path])
|
||||
guard result.status == 0 else { throw UpdateError.archive(result.output) }
|
||||
let apps = (try? FileManager.default.contentsOfDirectory(at: output, includingPropertiesForKeys: nil))?
|
||||
.filter { $0.pathExtension == "app" } ?? []
|
||||
guard apps.count == 1, let app = apps.first else { throw UpdateError.archive("压缩包里应当只有一个 .app") }
|
||||
return app
|
||||
}
|
||||
|
||||
// MARK: 校验
|
||||
|
||||
/// 包名、版本号、签名团队必须与预期一致,并通过严格签名校验与 Gatekeeper(公证)检查
|
||||
public static func verify(_ app: URL, bundleIdentifier: String, version: String, teamIdentifier: String) throws {
|
||||
guard let bundle = Bundle(url: app) else { throw UpdateError.invalidBundle("无法读取") }
|
||||
guard bundle.bundleIdentifier == bundleIdentifier else {
|
||||
throw UpdateError.invalidBundle("包名是 \(bundle.bundleIdentifier ?? "空")")
|
||||
}
|
||||
let bundleVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
guard bundleVersion == version else {
|
||||
throw UpdateError.invalidBundle("版本是 \(bundleVersion ?? "空"),清单写的是 \(version)")
|
||||
}
|
||||
|
||||
var staticCode: SecStaticCode?
|
||||
guard SecStaticCodeCreateWithPath(app as CFURL, [], &staticCode) == errSecSuccess, let code = staticCode else {
|
||||
throw UpdateError.signature("无法读取签名")
|
||||
}
|
||||
let requirementText = "anchor apple generic and identifier \"\(bundleIdentifier)\" and certificate leaf[subject.OU] = \"\(teamIdentifier)\""
|
||||
var requirement: SecRequirement?
|
||||
guard SecRequirementCreateWithString(requirementText as CFString, [], &requirement) == errSecSuccess else {
|
||||
throw UpdateError.signature("无法创建签名要求")
|
||||
}
|
||||
let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode | kSecCSStrictValidate)
|
||||
let status = SecStaticCodeCheckValidity(code, flags, requirement)
|
||||
guard status == errSecSuccess else {
|
||||
throw UpdateError.signature("不是团队 \(teamIdentifier) 签名的完整应用(\(status))")
|
||||
}
|
||||
|
||||
let assessment = run("/usr/sbin/spctl", ["--assess", "--type", "execute", "-vv", app.path])
|
||||
guard assessment.status == 0, assessment.output.contains("accepted") else { throw UpdateError.gatekeeper }
|
||||
}
|
||||
|
||||
/// 应用自身的签名团队;开发构建(未签名或临时签名)返回 nil
|
||||
public static func teamIdentifier(of app: URL) -> String? {
|
||||
var staticCode: SecStaticCode?
|
||||
guard SecStaticCodeCreateWithPath(app as CFURL, [], &staticCode) == errSecSuccess, let code = staticCode else { return nil }
|
||||
var info: CFDictionary?
|
||||
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &info) == errSecSuccess,
|
||||
let dictionary = info as? [String: Any] else { return nil }
|
||||
return dictionary[kSecCodeInfoTeamIdentifier as String] as? String
|
||||
}
|
||||
|
||||
// MARK: 替换
|
||||
|
||||
/// 旧版先改名为备份,再把新版移到原位置;移动失败时还原旧版。两者在同一卷上,改名是原子的。
|
||||
public static func replace(_ current: URL, with candidate: URL, backupDirectory: URL) throws {
|
||||
let manager = FileManager.default
|
||||
let backup = backupDirectory.appendingPathComponent("previous-\(current.lastPathComponent)")
|
||||
try? manager.removeItem(at: backup)
|
||||
do {
|
||||
try manager.moveItem(at: current, to: backup)
|
||||
} catch {
|
||||
throw UpdateError.install(error.localizedDescription)
|
||||
}
|
||||
do {
|
||||
try manager.moveItem(at: candidate, to: current)
|
||||
} catch {
|
||||
try? manager.moveItem(at: backup, to: current)
|
||||
throw UpdateError.install(error.localizedDescription)
|
||||
}
|
||||
try? manager.removeItem(at: backup)
|
||||
}
|
||||
|
||||
/// 当前用户能否直接改写应用所在目录(管理员账户对 /Applications 通常可以)
|
||||
public static func canReplaceInPlace(_ app: URL) -> Bool {
|
||||
let parent = app.deletingLastPathComponent().path
|
||||
return FileManager.default.isWritableFile(atPath: parent) && FileManager.default.isWritableFile(atPath: app.path)
|
||||
}
|
||||
|
||||
/// 等当前进程退出后重新打开应用。路径与 PID 作为位置参数传入,不拼进脚本
|
||||
public static func relaunch(_ app: URL, afterExitOf pid: Int32 = ProcessInfo.processInfo.processIdentifier) throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
process.arguments = ["-c", "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.2; done; /usr/bin/open \"$2\"",
|
||||
"openstats-relaunch", String(pid), app.path]
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = FileHandle.nullDevice
|
||||
try process.run()
|
||||
}
|
||||
|
||||
// MARK: 工具
|
||||
|
||||
@discardableResult
|
||||
static func run(_ executable: String, _ arguments: [String]) -> (status: Int32, output: String) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return (-1, error.localizedDescription)
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return (process.terminationStatus, String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
}
|
||||
|
||||
/// 带进度的下载:系统把文件下载到临时位置,完成回调里立即移到目标位置
|
||||
private final class Downloader: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||
private let destination: URL
|
||||
private let progress: @Sendable (Double) -> Void
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Void, Error>?
|
||||
private var moveError: Error?
|
||||
|
||||
init(destination: URL, progress: @escaping @Sendable (Double) -> Void) {
|
||||
self.destination = destination
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
func run(_ url: URL) async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 60
|
||||
configuration.timeoutIntervalForResource = 15 * 60
|
||||
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
lock.withLock { self.continuation = continuation }
|
||||
session.downloadTask(with: url).resume()
|
||||
}
|
||||
} onCancel: {
|
||||
session.invalidateAndCancel()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
guard totalBytesExpectedToWrite > 0 else { return }
|
||||
progress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
if let response = downloadTask.response as? HTTPURLResponse, !(200..<300).contains(response.statusCode) {
|
||||
moveError = UpdateError.download("服务器返回 \(response.statusCode)")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
try FileManager.default.moveItem(at: location, to: destination)
|
||||
} catch {
|
||||
moveError = error
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
let continuation = lock.withLock { () -> CheckedContinuation<Void, Error>? in
|
||||
defer { self.continuation = nil }
|
||||
return self.continuation
|
||||
}
|
||||
if let error = error ?? moveError {
|
||||
let message = (error as? UpdateError)?.errorDescription ?? error.localizedDescription
|
||||
continuation?.resume(throwing: error as? UpdateError ?? UpdateError.download(message))
|
||||
} else {
|
||||
continuation?.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Updates
|
||||
|
||||
@Suite struct UpdateFeedTests {
|
||||
@Test func comparesVersionsNumerically() {
|
||||
#expect(UpdateFeed.isNewer("0.10.0", than: "0.9.3"))
|
||||
#expect(UpdateFeed.isNewer("0.2.1", than: "0.2.0"))
|
||||
#expect(!UpdateFeed.isNewer("0.2.0", than: "0.2.0"))
|
||||
#expect(!UpdateFeed.isNewer("1.0", than: "1.0.0"))
|
||||
#expect(!UpdateFeed.isNewer("0.1.9", than: "0.2.0"))
|
||||
}
|
||||
|
||||
@Test func checksMinimumSystem() {
|
||||
let sonoma = OperatingSystemVersion(majorVersion: 14, minorVersion: 5, patchVersion: 0)
|
||||
#expect(UpdateFeed.systemSatisfies("14.0", current: sonoma))
|
||||
#expect(!UpdateFeed.systemSatisfies("15.0", current: sonoma))
|
||||
}
|
||||
|
||||
@Test func parsesAndRejectsBadFeeds() throws {
|
||||
let good = #"{"version":"0.3.0","build":"3","date":"2026-09-20","minimumSystem":"14.0","url":"https://getopenstats.com/download/OpenStats-0.3.0.zip","sha256":"\#(String(repeating: "a", count: 64))","size":123,"dmg":"https://getopenstats.com/download/OpenStats-0.3.0.dmg","notes":["新增在线升级"],"changelog":null}"#
|
||||
let release = try #require(UpdateFeed.parse(Data(good.utf8)))
|
||||
#expect(release.version == "0.3.0")
|
||||
#expect(release.notes == ["新增在线升级"])
|
||||
let insecure = good.replacingOccurrences(of: "https://getopenstats.com/download/OpenStats-0.3.0.zip", with: "http://example.com/x.zip")
|
||||
#expect(UpdateFeed.parse(Data(insecure.utf8)) == nil)
|
||||
let badHash = good.replacingOccurrences(of: String(repeating: "a", count: 64), with: "abc")
|
||||
#expect(UpdateFeed.parse(Data(badHash.utf8)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct UpdateInstallerTests {
|
||||
private func scratch() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("openstats-update-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
@Test func hashesFiles() throws {
|
||||
let dir = try scratch()
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let file = dir.appendingPathComponent("a.txt")
|
||||
try Data("abc".utf8).write(to: file)
|
||||
#expect(try UpdateInstaller.sha256(of: file) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
|
||||
}
|
||||
|
||||
@Test func replacesAndKeepsOldOnFailure() throws {
|
||||
let dir = try scratch()
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let current = dir.appendingPathComponent("App.app")
|
||||
let candidate = dir.appendingPathComponent("New.app")
|
||||
try FileManager.default.createDirectory(at: current, withIntermediateDirectories: true)
|
||||
try Data("old".utf8).write(to: current.appendingPathComponent("v"))
|
||||
try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: true)
|
||||
try Data("new".utf8).write(to: candidate.appendingPathComponent("v"))
|
||||
|
||||
try UpdateInstaller.replace(current, with: candidate, backupDirectory: dir)
|
||||
#expect(String(decoding: try Data(contentsOf: current.appendingPathComponent("v")), as: UTF8.self) == "new")
|
||||
#expect(!FileManager.default.fileExists(atPath: candidate.path))
|
||||
|
||||
// 新版不存在时移动失败,旧版应还原到原位置
|
||||
#expect(throws: UpdateError.self) {
|
||||
try UpdateInstaller.replace(current, with: dir.appendingPathComponent("Missing.app"), backupDirectory: dir)
|
||||
}
|
||||
#expect(String(decoding: try Data(contentsOf: current.appendingPathComponent("v")), as: UTF8.self) == "new")
|
||||
}
|
||||
|
||||
@Test func extractsSingleApp() throws {
|
||||
let dir = try scratch()
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let app = dir.appendingPathComponent("src/Demo.app/Contents")
|
||||
try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true)
|
||||
let zip = dir.appendingPathComponent("Demo.zip")
|
||||
#expect(UpdateInstaller.run("/usr/bin/ditto", ["-c", "-k", "--keepParent", dir.appendingPathComponent("src/Demo.app").path, zip.path]).status == 0)
|
||||
let extracted = try UpdateInstaller.extractApp(from: zip, into: dir)
|
||||
#expect(extracted.lastPathComponent == "Demo.app")
|
||||
}
|
||||
|
||||
@Test func rejectsUnsignedBundle() throws {
|
||||
let dir = try scratch()
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let app = dir.appendingPathComponent("Demo.app")
|
||||
try FileManager.default.createDirectory(at: app.appendingPathComponent("Contents"), withIntermediateDirectories: true)
|
||||
let plist: [String: Any] = ["CFBundleIdentifier": "com.openstats.app", "CFBundleShortVersionString": "9.9.9", "CFBundlePackageType": "APPL"]
|
||||
try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
.write(to: app.appendingPathComponent("Contents/Info.plist"))
|
||||
#expect(throws: UpdateError.self) {
|
||||
try UpdateInstaller.verify(app, bundleIdentifier: "com.openstats.app", version: "9.9.9", teamIdentifier: "ABCDE12345")
|
||||
}
|
||||
#expect(throws: UpdateError.invalidBundle("版本是 9.9.9,清单写的是 1.0.0")) {
|
||||
try UpdateInstaller.verify(app, bundleIdentifier: "com.openstats.app", version: "1.0.0", teamIdentifier: "ABCDE12345")
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成应用内在线升级读取的版本清单 appcast.json。
|
||||
|
||||
Scripts/appcast.py <版本> <build> <zip 路径> <dmg 路径> <下载地址前缀> > dist/appcast.json
|
||||
|
||||
更新摘要取自 CHANGELOG.md 中该版本的条目:每条取冒号或句号之前的部分(过短时带上冒号后的内容),最多 10 条。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
MAX_NOTES = 10
|
||||
MAX_LENGTH = 48
|
||||
|
||||
|
||||
def summarize(line: str) -> str:
|
||||
text = re.sub(r"`([^`]*)`", r"\1", line).strip()
|
||||
text = re.sub(r"\*\*([^*]*)\*\*", r"\1", text)
|
||||
head = re.split(r"[:。;]", text, maxsplit=1)[0]
|
||||
# 冒号前只是个标题(如“主窗口”)时带上后面的内容,否则摘要看不出改了什么
|
||||
text = head if len(head) >= 10 else text.split("。", 1)[0]
|
||||
text = text.rstrip(",、 ")
|
||||
return text if len(text) <= MAX_LENGTH else text[: MAX_LENGTH - 1] + "…"
|
||||
|
||||
|
||||
def release_notes(changelog: str, version: str) -> tuple[str, list[str]]:
|
||||
section = re.search(rf"^## {re.escape(version)} · (\S+)\n(.*?)(?=^## |\Z)", changelog, re.S | re.M)
|
||||
if not section:
|
||||
sys.exit(f"CHANGELOG.md 里没有 {version} 的条目")
|
||||
date, body = section.group(1), section.group(2)
|
||||
notes = []
|
||||
for line in body.splitlines():
|
||||
if line.startswith("- "):
|
||||
summary = summarize(line[2:])
|
||||
if summary and summary not in notes:
|
||||
notes.append(summary)
|
||||
if len(notes) > MAX_NOTES:
|
||||
rest = len(notes) - MAX_NOTES + 1
|
||||
notes = notes[: MAX_NOTES - 1] + [f"以及另外 {rest} 项新增、调整与修复"]
|
||||
return date, notes
|
||||
|
||||
|
||||
def sha256(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
version, build, zip_path, dmg_path, base = sys.argv[1:6]
|
||||
root = os.path.join(os.path.dirname(__file__), "..")
|
||||
with open(os.path.join(root, "CHANGELOG.md"), encoding="utf-8") as handle:
|
||||
date, notes = release_notes(handle.read(), version)
|
||||
feed = {
|
||||
"version": version,
|
||||
"build": build,
|
||||
"date": date,
|
||||
"minimumSystem": "14.0",
|
||||
"url": f"{base}/{os.path.basename(zip_path)}",
|
||||
"sha256": sha256(zip_path),
|
||||
"size": os.path.getsize(zip_path),
|
||||
"dmg": f"{base}/{os.path.basename(dmg_path)}",
|
||||
"notes": notes,
|
||||
"changelog": "https://getopenstats.com/#changelog",
|
||||
}
|
||||
json.dump(feed, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
# 发布已公证的版本:DMG 上传到官网 /download/,cask 提交到 gentpan/homebrew-tap,并校验线上文件。
|
||||
# 发布已公证的版本:DMG 与在线升级包上传到官网 /download/,最后更新版本清单 appcast.json(已安装的应用据此提示升级),
|
||||
# cask 提交到 gentpan/homebrew-tap,并校验线上文件。
|
||||
#
|
||||
# ./Scripts/release.sh # 先打包、公证
|
||||
# ./Scripts/publish_release.sh # 再发布
|
||||
@@ -12,10 +13,20 @@ ROOT="${SITE_ROOT:-/var/www/getopenstats.com}"
|
||||
TAP="${TAP:-gentpan/homebrew-tap}"
|
||||
VERSION="$(sed -nE 's/^ *MARKETING_VERSION: *"?([0-9.]+)"?.*/\1/p' project.yml | head -1)"
|
||||
DMG="dist/OpenStats-${VERSION}.dmg"
|
||||
ZIP="dist/OpenStats-${VERSION}.zip"
|
||||
APPCAST="dist/appcast.json"
|
||||
CASK="dist/openstats.rb"
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes "$HOST")
|
||||
|
||||
[ -f "$DMG" ] && [ -f "$CASK" ] || { echo "缺少 $DMG 或 $CASK,先运行 ./Scripts/release.sh" >&2; exit 1; }
|
||||
for file in "$DMG" "$ZIP" "$APPCAST" "$CASK"; do
|
||||
[ -f "$file" ] || { echo "缺少 $file,先运行 ./Scripts/release.sh" >&2; exit 1; }
|
||||
done
|
||||
python3 - "$APPCAST" "$VERSION" "$ZIP" <<'PY' || { echo "$APPCAST 与 $ZIP 不一致" >&2; exit 1; }
|
||||
import hashlib, json, sys
|
||||
feed = json.load(open(sys.argv[1]))
|
||||
digest = hashlib.sha256(open(sys.argv[3], "rb").read()).hexdigest()
|
||||
assert feed["version"] == sys.argv[2] and feed["sha256"] == digest and feed["notes"], feed
|
||||
PY
|
||||
grep -q "version \"${VERSION}\"" "$CASK" || { echo "$CASK 的版本不是 ${VERSION}" >&2; exit 1; }
|
||||
xcrun stapler validate "$DMG" >/dev/null || { echo "$DMG 没有装订公证票据" >&2; exit 1; }
|
||||
SHA="$(shasum -a 256 "$DMG" | cut -d' ' -f1)"
|
||||
@@ -23,13 +34,20 @@ grep -q "$SHA" "$CASK" || { echo "$CASK 里的 sha256 与 DMG 不一致" >&2; ex
|
||||
|
||||
# 上传:先传临时名再改名,下载中途不会拿到半个文件
|
||||
"${SSH[@]}" "sudo install -d -o \$(id -un) -m 755 $ROOT/download"
|
||||
scp -i "$KEY" -o BatchMode=yes "$DMG" "$HOST:$ROOT/download/.OpenStats-${VERSION}.dmg.part"
|
||||
"${SSH[@]}" "mv -f $ROOT/download/.OpenStats-${VERSION}.dmg.part $ROOT/download/OpenStats-${VERSION}.dmg && chmod 644 $ROOT/download/OpenStats-${VERSION}.dmg"
|
||||
|
||||
URL="https://getopenstats.com/download/OpenStats-${VERSION}.dmg"
|
||||
ONLINE="$(curl -fsSL --max-time 300 "$URL" | shasum -a 256 | cut -d' ' -f1)"
|
||||
[ "$ONLINE" = "$SHA" ] || { echo "线上 DMG 校验不一致:$ONLINE" >&2; exit 1; }
|
||||
echo "✅ $URL"
|
||||
upload() {
|
||||
local name
|
||||
name="$(basename "$1")"
|
||||
scp -i "$KEY" -o BatchMode=yes "$1" "$HOST:$ROOT/download/.${name}.part"
|
||||
"${SSH[@]}" "mv -f $ROOT/download/.${name}.part $ROOT/download/${name} && chmod 644 $ROOT/download/${name}"
|
||||
local online
|
||||
online="$(curl -fsSL --max-time 300 "https://getopenstats.com/download/${name}" | shasum -a 256 | cut -d' ' -f1)"
|
||||
[ "$online" = "$(shasum -a 256 "$1" | cut -d' ' -f1)" ] || { echo "线上 ${name} 校验不一致:$online" >&2; exit 1; }
|
||||
echo "✅ https://getopenstats.com/download/${name}"
|
||||
}
|
||||
upload "$DMG"
|
||||
upload "$ZIP"
|
||||
# 版本清单最后发布:安装包都已就位后,已安装的应用才会看到新版本
|
||||
upload "$APPCAST"
|
||||
|
||||
# Homebrew tap
|
||||
WORK="$(mktemp -d)"
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# 发布一个可分发的版本:Developer ID 签名 → 公证 → 装订 → DMG(签名、公证、装订)→ SHA-256 与 Homebrew cask。
|
||||
# 发布一个可分发的版本:Developer ID 签名 → 公证 → 装订 → DMG(签名、公证、装订)→ 在线升级包与版本清单 → Homebrew cask。
|
||||
#
|
||||
# ./Scripts/release.sh
|
||||
#
|
||||
@@ -19,8 +19,10 @@ NOTARY_PROFILE="${NOTARY_PROFILE:-QuotaBar}"
|
||||
SIGN_ID="${SIGN_ID:-$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| grep 'Developer ID Application' | head -1 | sed -E 's/.*"(.*)".*/\1/' || true)}"
|
||||
VERSION="$(sed -nE 's/^ *MARKETING_VERSION: *"?([0-9.]+)"?.*/\1/p' project.yml | head -1)"
|
||||
BUILD="$(sed -nE 's/^ *CURRENT_PROJECT_VERSION: *"?([0-9]+)"?.*/\1/p' project.yml | head -1)"
|
||||
APP="build/DerivedData/Build/Products/Release/OpenStats.app"
|
||||
DMG_NAME="OpenStats-${VERSION}.dmg"
|
||||
ZIP_NAME="OpenStats-${VERSION}.zip"
|
||||
|
||||
if [ -z "$SIGN_ID" ]; then
|
||||
echo "error: 钥匙串里没有 Developer ID Application 证书,无法发布。" >&2
|
||||
@@ -85,6 +87,12 @@ fi
|
||||
|
||||
SHA256="$(shasum -a 256 "$DIST/$DMG_NAME" | cut -d' ' -f1)"
|
||||
|
||||
# ---- 在线升级 ------------------------------------------------------------------
|
||||
|
||||
# 应用内升级下载已装订票据的 .app 压缩包,版本清单里带 sha256 与更新摘要
|
||||
ditto -c -k --keepParent "$APP" "$DIST/$ZIP_NAME"
|
||||
python3 Scripts/appcast.py "$VERSION" "$BUILD" "$DIST/$ZIP_NAME" "$DIST/$DMG_NAME" "$DOWNLOAD_BASE" > "$DIST/appcast.json"
|
||||
|
||||
# ---- Homebrew cask ------------------------------------------------------------
|
||||
|
||||
cat > "$DIST/openstats.rb" <<CASK
|
||||
@@ -97,6 +105,8 @@ cask "openstats" do
|
||||
desc "Menu bar system monitor with fan control, keep-awake and cleanup"
|
||||
homepage "https://getopenstats.com"
|
||||
|
||||
# 应用内置在线升级,brew upgrade 默认不再重复升级
|
||||
auto_updates true
|
||||
depends_on macos: :sonoma
|
||||
|
||||
app "OpenStats.app"
|
||||
@@ -111,6 +121,7 @@ CASK
|
||||
echo
|
||||
echo "✅ ${DIST}/${DMG_NAME}"
|
||||
echo " SHA-256 ${SHA256}"
|
||||
echo " 在线升级:${DIST}/${ZIP_NAME} · ${DIST}/appcast.json"
|
||||
echo " Homebrew cask:${DIST}/openstats.rb"
|
||||
if [ "${SKIP_NOTARIZE:-0}" = "1" ]; then
|
||||
echo "⚠️ 未公证,仅供本机测试。"
|
||||
|
||||
Reference in new issue
Block a user