在线升级:版本清单、摘要提示、一键安装与自动重启

This commit is contained in:
gentpan committed 2026-09-13 08:03:25 +05:00
1 parent 0538dbc3ca
commit b901bb8b67
19 files changed
+1015 -16

No files matched your search

+8
View File
@@ -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
+4 -1
View File
@@ -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")
}
}
}
+75
View File
@@ -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()
+27 -9
View File
@@ -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
View File
@@ -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 "⚠️ 未公证,仅供本机测试。"