From b901bb8b678b2c16893e051af135278939cab84b Mon Sep 17 00:00:00 2001 From: gentpan Date: Sun, 13 Sep 2026 08:03:25 +0500 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E5=8D=87=E7=BA=A7=EF=BC=9A?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=B8=85=E5=8D=95=E3=80=81=E6=91=98=E8=A6=81?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E3=80=81=E4=B8=80=E9=94=AE=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E4=B8=8E=E8=87=AA=E5=8A=A8=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 + Packages/OpenStatsKit/Package.swift | 5 +- .../OpenStatsUI/AppKit/AppController.swift | 27 ++- .../Sources/OpenStatsUI/AppKit/MainMenu.swift | 6 +- .../AppKit/UpdateWindowController.swift | 40 ++++ .../Sources/OpenStatsUI/Design/Tokens.swift | 3 + .../OpenStatsUI/Settings/SettingsPages.swift | 2 + .../OpenStatsUI/Settings/UpdateViews.swift | 203 ++++++++++++++++ .../Snapshot/SnapshotRenderer.swift | 9 + .../Sources/OpenStatsUI/State/AppModel.swift | 2 + .../OpenStatsUI/State/AppSettings.swift | 6 + .../State/MaintenanceController.swift | 2 +- .../OpenStatsUI/State/UpdateController.swift | 212 +++++++++++++++++ .../Sources/Updates/UpdateFeed.swift | 64 +++++ .../Sources/Updates/UpdateInstaller.swift | 224 ++++++++++++++++++ .../Tests/UpdatesTests/UpdateFeedTests.swift | 94 ++++++++ Scripts/appcast.py | 75 ++++++ Scripts/publish_release.sh | 36 ++- Scripts/release.sh | 13 +- 19 files changed, 1015 insertions(+), 16 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/UpdateWindowController.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/UpdateViews.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/State/UpdateController.swift create mode 100644 Packages/OpenStatsKit/Sources/Updates/UpdateFeed.swift create mode 100644 Packages/OpenStatsKit/Sources/Updates/UpdateInstaller.swift create mode 100644 Packages/OpenStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift create mode 100755 Scripts/appcast.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 224f8ea..935cdeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Packages/OpenStatsKit/Package.swift b/Packages/OpenStatsKit/Package.swift index 5d49946..8391b4c 100644 --- a/Packages/OpenStatsKit/Package.swift +++ b/Packages/OpenStatsKit/Package.swift @@ -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] ) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index ddbbda4..c81f26c 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainMenu.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainMenu.swift index 533e624..2b35bae 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainMenu.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainMenu.swift @@ -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()) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/UpdateWindowController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/UpdateWindowController.swift new file mode 100644 index 0000000..3415c42 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/UpdateWindowController.swift @@ -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) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift index 03ed919..33f9a2c 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -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 } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index 7bbc34d..e7a6dfd 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -597,6 +597,8 @@ struct AboutSettings: View { } } + UpdateSettings() + SettingsGroup(caption: "致谢") { GroupRow(showsDivider: false) { SettingRow(title: "exelban/stats", subtitle: "SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License") { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/UpdateViews.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/UpdateViews.swift new file mode 100644 index 0000000..58635b4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/UpdateViews.swift @@ -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)" + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index dff2d37..5725516 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -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")) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 273efc9..f734126 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -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) } /// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 9ab1330..daf3ba4 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -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" } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift index f1c4ed1..46a4978 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift @@ -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 { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UpdateController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UpdateController.swift new file mode 100644 index 0000000..a3cae7b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UpdateController.swift @@ -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? + + 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 { + 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" + } +} diff --git a/Packages/OpenStatsKit/Sources/Updates/UpdateFeed.swift b/Packages/OpenStatsKit/Sources/Updates/UpdateFeed.swift new file mode 100644 index 0000000..0e7e06f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Updates/UpdateFeed.swift @@ -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.. 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 } + } +} diff --git a/Packages/OpenStatsKit/Sources/Updates/UpdateInstaller.swift b/Packages/OpenStatsKit/Sources/Updates/UpdateInstaller.swift new file mode 100644 index 0000000..22ad05e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Updates/UpdateInstaller.swift @@ -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? + 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) 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? 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() + } + } +} diff --git a/Packages/OpenStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift b/Packages/OpenStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift new file mode 100644 index 0000000..d62a724 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift @@ -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") + } + } +} diff --git a/Scripts/appcast.py b/Scripts/appcast.py new file mode 100755 index 0000000..1bc0c14 --- /dev/null +++ b/Scripts/appcast.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""生成应用内在线升级读取的版本清单 appcast.json。 + + Scripts/appcast.py <版本> <下载地址前缀> > 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() diff --git a/Scripts/publish_release.sh b/Scripts/publish_release.sh index 32d7aad..d85ed78 100755 --- a/Scripts/publish_release.sh +++ b/Scripts/publish_release.sh @@ -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)" diff --git a/Scripts/release.sh b/Scripts/release.sh index 6695723..bdd9aa3 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -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" <