diff --git a/App/Sources/AppDelegate.swift b/App/Sources/AppDelegate.swift
index f7228bc06..8060bca0c 100644
--- a/App/Sources/AppDelegate.swift
+++ b/App/Sources/AppDelegate.swift
@@ -33,7 +33,21 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
noteBackupRootURL: root.appendingPathComponent(NoteBackupRing.subdirectoryName),
startsAtBlankEntries: true)
}()
- static let updates = WeiBeiUpdateService()
+ static let updates: WeiBeiUpdateService = {
+ let service = WeiBeiUpdateService()
+ service.prepareForInstallation = {
+ let store = AppDelegate.workspace
+ store.commitCurrentReaderLocation()
+ guard await store.freshActiveNoteEditorSnapshot() else { return false }
+ store.flushPendingNotePersistence(flushWorkspace: false)
+ guard await store.flushPendingWorkspaceSaveAsync() else { return false }
+ // A file conflict/write failure must not be mistaken for a saved note.
+ return !store.noteEditingSession.dirty
+ && store.noteEditingSession.saveStatus != .failed
+ && store.noteEditingSession.saveStatus != .externallyModified
+ }
+ return service
+ }()
private var lifecycleObservers: [NSObjectProtocol] = []
private var saveTask: Task?
private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
@@ -42,6 +56,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
WeiBeiTypography.registerBundledFonts()
guard !Self.usesFixture else { return true }
_ = Self.workspace
+ let defaults = UserDefaults.standard
+ if let pendingBuild = defaults.string(forKey: WeiBeiUpdateService.pendingInstallationBuildKey),
+ pendingBuild == Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String,
+ let version = defaults.string(forKey: WeiBeiUpdateService.pendingInstallationVersionKey) {
+ Self.workspace.showTransientNoteStatus(Self.workspace.ui("已更新到 \(version)", "Updated to \(version)"))
+ defaults.removeObject(forKey: WeiBeiUpdateService.pendingInstallationBuildKey)
+ defaults.removeObject(forKey: WeiBeiUpdateService.pendingInstallationVersionKey)
+ }
lifecycleObservers.append(NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.saveWorkspace() }
})
diff --git a/App/WeiBei.xcodeproj/project.pbxproj b/App/WeiBei.xcodeproj/project.pbxproj
index 4ed8a2b86..011f4cd22 100644
--- a/App/WeiBei.xcodeproj/project.pbxproj
+++ b/App/WeiBei.xcodeproj/project.pbxproj
@@ -212,6 +212,7 @@
E3C57A914FAF086CBC2EE87E /* NativeToolSchemaValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804330B9F87807BF475EB80 /* NativeToolSchemaValidation.swift */; };
E498931F372E542246CCF1E8 /* lanting-observe-kinds.png in Resources */ = {isa = PBXBuildFile; fileRef = A19EDFF7CF12F407B045AAFC /* lanting-observe-kinds.png */; };
E56FDBD0C5978CC30B934430 /* WorkspacePersistStatusDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E51498E496D5A61D998523 /* WorkspacePersistStatusDot.swift */; };
+ EAA3DEB378944BAE947BD215 /* WeiBeiUpdateControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA12C4B125CC4E2394E84785 /* WeiBeiUpdateControl.swift */; };
E60BC6DE707366A0549B937C /* NativeSessionTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433E5657BF2C1DCD29347D42 /* NativeSessionTitle.swift */; };
E7939285E4E0C9937364F5C3 /* AgentReplyMemoryUpdateTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 963BCB482D54E37E6DE3C842 /* AgentReplyMemoryUpdateTag.swift */; };
E818370843CF10A7F41BD707 /* AgentReplyProfileUpdateTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 672C78FE92DA58E70D7B494E /* AgentReplyProfileUpdateTag.swift */; };
@@ -472,6 +473,7 @@
CD62172095286419347BAD3C /* ReaderWebRemarkMarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderWebRemarkMarks.swift; sourceTree = ""; };
CFA8BD37F526A567FC7AF59A /* ConversationLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLayout.swift; sourceTree = ""; };
D1E51498E496D5A61D998523 /* WorkspacePersistStatusDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePersistStatusDot.swift; sourceTree = ""; };
+ FA12C4B125CC4E2394E84785 /* WeiBeiUpdateControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeiBeiUpdateControl.swift; sourceTree = ""; };
D4DC97F117C4C9EF8A872EAA /* SelectionAnchorContentPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionAnchorContentPoint.swift; sourceTree = ""; };
D9660747C198993E15D3D640 /* NativeLLMAdapterFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLLMAdapterFactory.swift; sourceTree = ""; };
DBE910F7A08873EDEB2E1769 /* DiagramView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagramView.swift; sourceTree = ""; };
@@ -789,6 +791,7 @@
B215538091FA28517FF16935 /* StableDocumentWorkspace.swift */,
6B078F9A88BC6C5982B3722B /* WeiBeiSegmentedControl.swift */,
D1E51498E496D5A61D998523 /* WorkspacePersistStatusDot.swift */,
+ FA12C4B125CC4E2394E84785 /* WeiBeiUpdateControl.swift */,
A41AD505070D316B0402B46D /* Settings */,
);
path = Views;
@@ -1354,6 +1357,7 @@
2CD678E8BE84257E5938AAAD /* WorkspaceInteractionState.swift in Sources */,
E94B46A70080CB4FDC671F71 /* WorkspacePaneState.swift in Sources */,
E56FDBD0C5978CC30B934430 /* WorkspacePersistStatusDot.swift in Sources */,
+ EAA3DEB378944BAE947BD215 /* WeiBeiUpdateControl.swift in Sources */,
0D8AC2AC2806968EF9F4C86A /* WorkspaceStore+AppearancePreference.swift in Sources */,
BA6C858DA3558EC483AC6E6C /* WorkspaceStore+CourseContent.swift in Sources */,
C21F9B2F6453AD3F0A0A8FD7 /* WorkspaceStore+CourseLibrary.swift in Sources */,
diff --git a/App/WindowBridge/NativeWindowBridge.swift b/App/WindowBridge/NativeWindowBridge.swift
index 6c4b23de3..89651f08b 100644
--- a/App/WindowBridge/NativeWindowBridge.swift
+++ b/App/WindowBridge/NativeWindowBridge.swift
@@ -327,15 +327,32 @@ final class NativeWindowBridge: NSObject, CatalystWindowBridge {
code: code
))
}
- @MainActor func observeUpdates(_ observer: @escaping (String, String?, [String], Bool, URL?) -> Void) {
- updateObservation = updateService.$status.combineLatest(updateService.$availableUpdate)
- .sink { status, update in
- observer(status.rawValue, update?.version, update?.releaseNotesLines ?? [],
- update?.informationOnly ?? false, update?.informationURL)
+ @MainActor func observeUpdates(_ observer: @escaping (NSDictionary) -> Void) {
+ updateObservation = updateService.$status.combineLatest(
+ updateService.$availableUpdate, updateService.$downloadProgress, updateService.$errorDescription
+ ).sink { status, update, progress, error in
+ var snapshot: [String: Any] = ["status": status.rawValue]
+ if let update {
+ snapshot["version"] = update.version
+ snapshot["notes"] = update.releaseNotesLines
+ snapshot["informationOnly"] = update.informationOnly
+ snapshot["informationURL"] = update.informationURL
+ snapshot["date"] = update.publishedDate
}
+ snapshot["progress"] = progress
+ snapshot["error"] = error
+ observer(snapshot as NSDictionary)
+ }
}
@MainActor func checkForUpdates() { updateService.checkForUpdates() }
- @MainActor func installAvailableUpdate() { updateService.installAvailableUpdate() }
+ @MainActor func installAvailableUpdate(_ save: @escaping (@escaping (Bool) -> Void) -> Void) {
+ updateService.prepareForInstallation = {
+ await withCheckedContinuation { continuation in
+ save { continuation.resume(returning: $0) }
+ }
+ }
+ updateService.installAvailableUpdate()
+ }
deinit { observers.forEach(NotificationCenter.default.removeObserver) }
}
diff --git a/App/WindowBridge/WindowBridgeContract.swift b/App/WindowBridge/WindowBridgeContract.swift
index b422a88ab..8b221dc4a 100644
--- a/App/WindowBridge/WindowBridgeContract.swift
+++ b/App/WindowBridge/WindowBridgeContract.swift
@@ -34,7 +34,7 @@ import Foundation
presentationToolbar: NSObject?,
completion: @MainActor @escaping ([URL], NSError?) -> Void
)
- @MainActor func observeUpdates(_ observer: @escaping (String, String?, [String], Bool, URL?) -> Void)
+ @MainActor func observeUpdates(_ observer: @escaping (NSDictionary) -> Void)
@MainActor func checkForUpdates()
- @MainActor func installAvailableUpdate()
+ @MainActor func installAvailableUpdate(_ save: @escaping (@escaping (Bool) -> Void) -> Void)
}
diff --git a/Docs/update-experience.md b/Docs/update-experience.md
new file mode 100644
index 000000000..f58979152
--- /dev/null
+++ b/Docs/update-experience.md
@@ -0,0 +1,45 @@
+# 魏碑更新体验设计建议(按用户批注修订)
+
+2026-10-10。状态:实现中,真实应用交互尚待验收。本版取代之前的点击打开面板、面板底部操作和继续使用按钮方案。
+
+## 核心交互
+
+悬停阅读说明,点击执行操作,过程和阶段在更新按钮上体现。说明面板不承担更新操作。
+
+| 状态 | 更新按钮 | 悬停面板 | 点击行为 |
+|---|---|---|---|
+| 有更新 | 下载图标;默认不展示“有更新”常驻文字 | 版本、日期与本次实际更新说明 | 立即下载 |
+| 下载中 | 图标区域的环形真实进度,旁边百分比 | 保留本次更新说明 | 不重复触发下载 |
+| 解压、准备 | 同一位置显示阶段,如“准备中” | 保留本次更新说明 | 等待准备完成 |
+| 就绪 | “安装并重启” | 保留本次更新说明 | 先保存最新编辑内容与工作区,成功后安装重启 |
+| 失败 | 重试入口 | 简短具体原因,再保留更新说明 | 重试失败的操作 |
+
+鼠标悬停按钮才打开说明面板;移入面板仍可阅读、滚动;离开按钮和面板后收起。点击按钮不会把面板锁定。后台状态变化不主动弹出面板。
+
+入口保留在窗口左上角独立区:阅读工作区紧随前进/后退之后、保持清晰分组间距,向右自然展开而不挤占导航按键;资料库与课程页位于返回工作台按钮旁。采用现代 Apple Pro / Linear 精工双层微岛架构:常态为 24pt 高清纯色徽标(电力珊瑚色底与白色精准符号 arrow.down.to.line),微光描边与柔和环境阴影,悬停时微放大,点击带有 0.95 机械触感按压微反馈。下载中平滑过渡为紧凑胶囊,内嵌白色环形进度与等宽百分比;准备就绪时平滑展开为翡翠绿微标与「安装并重启」胶囊,点击直接保存现场并安全重启。悬停时平滑呼出更新说明面板(220ms 跨指针防抖),面板顶部摒弃重复图标与多余胶囊、以纯正排版呈现版本与日期,更新条目支持行内 Markdown 与彩色分组导轨,长条目配备视口底部渐隐滚动遮罩,彻底杜绝所有伪古风陈旧概念与多余新手引导文案。
+
+下载完成后保留“安装并重启”,等待用户点击。删除“继续使用”按钮;删除退出时安装的说明文案。更新完成后恢复原资料、阅读位置、笔记与工作区,并给出轻量完成反馈。错误不清空工作现场。
+
+## 说明面板内容
+
+版本和发布日期放在头部,下面直接列本次真实变化。新增、改进、修复在本次内容需要时分组。只有一项修复,就写这一项。必要的使用变化、系统要求或已知问题按实际发布内容说明。
+
+采用魏碑既有主题、字体和细分隔线,靠文字排版和留白保证阅读。不添加宣传标题、收益导语、装饰插画、额外操作底栏,也不规定每次条目数量。长说明自然滚动,鼠标进入面板可以继续读。
+
+## 更贴近本版的成熟参考
+
+- [待办工具 Things 的 Mac 更新说明](https://culturedcode.com/things/support/articles/1100684/):版本、日期、适用系统和直接的变更条目。其 3.22.12 只有一项简短内部调整。参考其信息量按实际变化决定的写法,不照搬系统要求栏,也不据此声称它用悬停更新面板。
+- [Mac 编辑器 Nova 的版本记录](https://nova.app/releases/):日常修复版本按具体问题逐条列出。参考正文的具体性,不照搬大版本的功能宣传结构。
+- [开发工具 IntelliJ IDEA 的悬停文档](https://www.jetbrains.com/help/idea/documentation-tool-window.html?keymap=primary_default_for_windows):支持鼠标悬停查看说明。仅作为悬停读信息的交互参考,不是其更新界面的证据。
+
+没有找到足以证明某款成熟应用完整采用“悬停更新说明、点击直接下载、同一按钮进度和重启”的官方资料。本版是按用户批注组合后的魏碑设计决策,不冒充竞品复刻。
+
+## 必须同步实现的行为
+
+- 下载和解压使用更新组件提供的实际数据。下载总量不可用时显示真实阶段,不制造百分比。
+- 下载完成不得直接批准重启,应等待用户点击安装。没有操作时正常使用魏碑。
+- 安装前等待最新笔记与工作区保存成功;失败停在错误状态,允许重试。正常退出路径也要保证保存完成。
+- 重启恢复实际阅读位置和编辑现场,需要真实应用验证,设计预览不等于已经实现。
+- 按实际安装阶段接通延期和重新进入安装的行为,不通过取消更新伪造就绪,也不依靠额外文案补救错误流程。
+
+实现采用一个工具栏更新组件,共用原有更新服务。构建与检查结果记录于合并请求;未操控用户正在使用的应用。
diff --git a/Sources/WeiBei/Services/WeiBeiUpdateService.swift b/Sources/WeiBei/Services/WeiBeiUpdateService.swift
index 84b27dc17..c77a43fbc 100644
--- a/Sources/WeiBei/Services/WeiBeiUpdateService.swift
+++ b/Sources/WeiBei/Services/WeiBeiUpdateService.swift
@@ -13,25 +13,23 @@ struct WeiBeiAvailableUpdate: Equatable {
let releaseNotesLines: [String]
let informationOnly: Bool
let informationURL: URL?
+ var publishedDate: Date? = nil
- var summaryLines: [String] {
- Self.summaryLines(from: releaseNotesLines)
+ static func isHeading(_ line: String) -> Bool {
+ line.hasPrefix("#") || [
+ "新增", "改进", "修复", "本次更新", "破坏性变化", "破坏性变更", "迁移", "迁移说明", "已知问题",
+ "New", "Improvements", "Fixed", "Fixes", "Breaking Changes", "Migration", "Known Issues",
+ ].contains(line.trimmingCharacters(in: CharacterSet(charactersIn: "::")))
}
- var helpText: String {
- (["魏碑 \(version)"] + summaryLines).joined(separator: "\n")
- }
-
- static func summaryLines(from rawNotes: String?) -> [String] {
- summaryLines(from: releaseNotesLines(from: rawNotes))
+ static func displayText(_ line: String) -> String {
+ line.trimmingCharacters(in: CharacterSet(charactersIn: "# "))
}
static func releaseNotesLines(from rawNotes: String?) -> [String] {
- guard var text = rawNotes?.trimmingCharacters(in: .whitespacesAndNewlines),
- !text.isEmpty else {
- return []
- }
- for marker in ["", "
", "", "", "", "
", "
", "
"] {
+ guard var text = rawNotes?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return [] }
+ text = text.replacingOccurrences(of: "]*>", with: "\n## ", options: [.regularExpression, .caseInsensitive])
+ for marker in ["", "", "", "", "", "", "", "", "
", "
", "
"] {
text = text.replacingOccurrences(of: marker, with: "\n", options: .caseInsensitive)
}
text = text
@@ -41,124 +39,102 @@ struct WeiBeiAvailableUpdate: Equatable {
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: """, with: "\"")
.replacingOccurrences(of: "'", with: "'")
-
- let lines = text
- .components(separatedBy: .newlines)
- .map {
- $0.trimmingCharacters(in: .whitespacesAndNewlines)
- .trimmingCharacters(in: CharacterSet(charactersIn: "-*• "))
- }
+ .replacingOccurrences(of: " ", with: " ")
+ return text.components(separatedBy: .newlines)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "-*• ")) }
.filter { !$0.isEmpty }
- return lines
- }
-
- private static func summaryLines(from lines: [String]) -> [String] {
- let highlights = priorityHighlights(in: lines)
- let excludedIndexes = Set(highlights.flatMap { [$0.headingIndex, $0.detailIndex] })
- let fillers = lines.enumerated()
- .filter { !excludedIndexes.contains($0.offset) }
- .prefix(max(0, 5 - highlights.count))
- .map { (index: $0.offset, text: $0.element) }
- return (fillers + highlights.map { (index: $0.detailIndex, text: $0.text) })
- .sorted { $0.index < $1.index }
- .map { $0.text }
- }
-
- private static func priorityHighlights(
- in lines: [String]
- ) -> [(headingIndex: Int, detailIndex: Int, text: String)] {
- let titles: Set = [
- "破坏性变化", "破坏性变更", "迁移", "迁移说明", "已知问题",
- "Breaking Changes", "Migration", "Known Issues",
- ]
- let headings = lines.indices.compactMap { index -> (index: Int, title: String)? in
- let title = lines[index]
- .trimmingCharacters(in: CharacterSet(charactersIn: "# "))
- .trimmingCharacters(in: CharacterSet(charactersIn: "::"))
- return titles.contains(title) ? (index, title) : nil
- }
- return headings.enumerated().flatMap { offset, heading in
- let endIndex = offset + 1 < headings.count ? headings[offset + 1].index : lines.endIndex
- return ((heading.index + 1).. Bool)?
var isBusy: Bool {
- switch status {
- case .checking, .downloading, .extracting, .installing:
- true
- default:
- false
- }
+ [.checking, .downloading, .extracting, .saving, .installing].contains(status)
}
var showsToolbarControl: Bool {
- guard availableUpdate != nil else { return false }
+ availableUpdate != nil && ![.idle, .checking, .upToDate].contains(status)
+ }
+
+ func actionLabel(english: Bool) -> String {
switch status {
- case .available, .downloading, .extracting, .installing, .failed:
- return true
- case .idle, .checking, .upToDate:
- return false
+ case .ready: return english ? "Install and Relaunch" : "安装并重启"
+ case .downloading: return english ? "Downloading" : "下载中"
+ case .extracting: return english ? "Preparing" : "准备中"
+ case .saving: return english ? "Saving" : "正在保存"
+ case .installing: return english ? "Installing" : "安装中"
+ case .failed: return english ? "Retry" : "重试"
+ default:
+ if availableUpdate?.informationOnly == true { return english ? "View Update" : "查看更新" }
+ return english ? "Download Update" : "下载更新"
}
}
#if targetEnvironment(macCatalyst)
override init() {
super.init()
- CatalystDesktopWindow.shared.observeUpdates { [weak self] status, version, notes, informationOnly, informationURL in
- self?.availableUpdate = version.map {
- WeiBeiAvailableUpdate(version: $0, releaseNotesLines: notes,
- informationOnly: informationOnly, informationURL: informationURL)
+ CatalystDesktopWindow.shared.observeUpdates { [weak self] snapshot in
+ guard let self else { return }
+ self.availableUpdate = (snapshot["version"] as? String).map {
+ WeiBeiAvailableUpdate(version: $0,
+ releaseNotesLines: snapshot["notes"] as? [String] ?? [],
+ informationOnly: snapshot["informationOnly"] as? Bool ?? false,
+ informationURL: snapshot["informationURL"] as? URL,
+ publishedDate: snapshot["date"] as? Date)
}
- self?.status = Status(rawValue: status) ?? .failed
+ self.downloadProgress = snapshot["progress"] as? Double
+ self.errorDescription = snapshot["error"] as? String
+ self.status = Status(rawValue: snapshot["status"] as? String ?? "") ?? .failed
}
}
func checkForUpdates() { CatalystDesktopWindow.shared.checkForUpdates() }
- func installAvailableUpdate() { CatalystDesktopWindow.shared.installAvailableUpdate() }
+ func installAvailableUpdate() {
+ guard !isBusy else { return }
+ CatalystDesktopWindow.shared.installAvailableUpdate { [weak self] completion in
+ Task { @MainActor in
+ completion(await self?.prepareForInstallation?() ?? false)
+ }
+ }
+ }
#else
- private lazy var updater = SPUUpdater(
- hostBundle: .main,
- applicationBundle: .main,
- userDriver: self,
- delegate: nil
- )
+ private lazy var updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: self, delegate: nil)
private var updateChoiceReply: ((SPUUserUpdateChoice) -> Void)?
+ private var readyToInstall = false
private var retryInstallWhenFound = false
+ private var targetBuild: String?
+ private var receivedBytes: UInt64 = 0
+ private var expectedBytes: UInt64 = 0
- override init() {
+ override convenience init() { self.init(startsUpdater: true) }
+
+ init(startsUpdater: Bool, availableUpdate: WeiBeiAvailableUpdate? = nil) {
super.init()
- do {
- try updater.start()
- } catch {
- WeiBeiLog.workspace.error(
- "code=updater_start_failed underlying=\(WeiBeiLog.code(error), privacy: .public)"
- )
- status = .failed
- }
+ self.availableUpdate = availableUpdate
+ guard startsUpdater else { return }
+ do { try updater.start() }
+ catch { recordFailure(error) }
}
func checkForUpdates() {
guard !isBusy else { return }
+ // A second check must not discard an already prepared installation.
+ guard !readyToInstall else { return }
+ errorDescription = nil
status = .checking
updater.checkForUpdates()
}
@@ -166,63 +142,96 @@ final class WeiBeiUpdateService: NSObject, ObservableObject {
func installAvailableUpdate() {
guard let update = availableUpdate, !isBusy else { return }
if update.informationOnly {
- if let informationURL = update.informationURL {
- NSWorkspace.shared.open(informationURL)
+ if let url = update.informationURL { NSWorkspace.shared.open(url) }
+ return
+ }
+ errorDescription = nil
+ if readyToInstall {
+ status = .saving
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ guard await self.prepareForInstallation?() == true else {
+ self.errorDescription = "笔记与工作区尚未安全保存,请重试。"
+ self.status = .failed
+ return
+ }
+ guard self.readyToInstall, let reply = self.updateChoiceReply else { return }
+ self.readyToInstall = false
+ self.updateChoiceReply = nil
+ self.status = .installing
+ if let targetBuild = self.targetBuild {
+ UserDefaults.standard.set(targetBuild, forKey: Self.pendingInstallationBuildKey)
+ UserDefaults.standard.set(update.version, forKey: Self.pendingInstallationVersionKey)
+ }
+ reply(.install)
}
return
}
-
+ resetProgress()
status = .downloading
- if let updateChoiceReply {
- self.updateChoiceReply = nil
- updateChoiceReply(.install)
+ if let reply = updateChoiceReply {
+ updateChoiceReply = nil
+ reply(.install)
} else {
retryInstallWhenFound = true
updater.checkForUpdates()
}
}
+
+ private func resetProgress() {
+ receivedBytes = 0
+ expectedBytes = 0
+ downloadProgress = nil
+ }
+
+ private func refreshProgress() {
+ // Content length can be missing or inaccurate. Never invent a percentage.
+ downloadProgress = expectedBytes > 0 && receivedBytes <= expectedBytes
+ ? Double(receivedBytes) / Double(expectedBytes) : nil
+ }
+
+ private func recordFailure(_ error: Error) {
+ retryInstallWhenFound = false
+ if readyToInstall { updateChoiceReply?(.dismiss) }
+ updateChoiceReply = nil
+ readyToInstall = false
+ errorDescription = error.localizedDescription
+ WeiBeiLog.workspace.error("code=updater_failed underlying=\(WeiBeiLog.code(error), privacy: .public)")
+ status = .failed
+ }
#endif
}
#if !targetEnvironment(macCatalyst)
extension WeiBeiUpdateService: SPUUserDriver {
func show(_ request: SPUUpdatePermissionRequest) async -> SUUpdatePermissionResponse {
- SUUpdatePermissionResponse(
- automaticUpdateChecks: true,
- automaticUpdateDownloading: false,
- sendSystemProfile: false
- )
+ SUUpdatePermissionResponse(automaticUpdateChecks: true, automaticUpdateDownloading: false, sendSystemProfile: false)
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
- if availableUpdate == nil {
- status = .checking
- }
+ if availableUpdate == nil { status = .checking }
}
- func showUpdateFound(
- with appcastItem: SUAppcastItem,
- state: SPUUserUpdateState,
- reply: @escaping (SPUUserUpdateChoice) -> Void
- ) {
- let update = WeiBeiAvailableUpdate(
- version: appcastItem.displayVersionString,
- releaseNotesLines: Self.releaseNotesLines(for: appcastItem),
- informationOnly: appcastItem.isInformationOnlyUpdate,
- informationURL: appcastItem.infoURL
- )
- availableUpdate = update
-
- if update.informationOnly {
+ func showUpdateFound(with item: SUAppcastItem, state: SPUUserUpdateState,
+ reply: @escaping (SPUUserUpdateChoice) -> Void) {
+ targetBuild = item.versionString
+ availableUpdate = WeiBeiAvailableUpdate(version: item.displayVersionString,
+ releaseNotesLines: Self.releaseNotesLines(for: item), informationOnly: item.isInformationOnlyUpdate,
+ informationURL: item.infoURL, publishedDate: item.date)
+ errorDescription = nil
+ if item.isInformationOnlyUpdate {
retryInstallWhenFound = false
status = .available
reply(.dismiss)
- return
- }
-
- if retryInstallWhenFound || state.stage == .installing {
+ } else if state.stage == .installing {
+ // A resumed installation also needs an explicit click and a save receipt.
+ retryInstallWhenFound = false
+ readyToInstall = true
+ updateChoiceReply = reply
+ status = .ready
+ } else if retryInstallWhenFound {
retryInstallWhenFound = false
- status = state.stage == .installing ? .installing : .downloading
+ status = .downloading
reply(.install)
} else {
updateChoiceReply = reply
@@ -231,63 +240,60 @@ extension WeiBeiUpdateService: SPUUserDriver {
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
- guard let text = String(data: downloadData.data, encoding: .utf8),
- let update = availableUpdate else { return }
+ guard let text = String(data: downloadData.data, encoding: .utf8), var update = availableUpdate else { return }
let lines = WeiBeiAvailableUpdate.releaseNotesLines(from: text)
guard !lines.isEmpty else { return }
- availableUpdate = WeiBeiAvailableUpdate(
- version: update.version,
- releaseNotesLines: lines,
- informationOnly: update.informationOnly,
- informationURL: update.informationURL
- )
+ update = WeiBeiAvailableUpdate(version: update.version, releaseNotesLines: lines,
+ informationOnly: update.informationOnly, informationURL: update.informationURL, publishedDate: update.publishedDate)
+ availableUpdate = update
}
- func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {}
+ func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {
+ errorDescription = error.localizedDescription
+ }
func showUpdateNotFoundWithError(_ error: Error) async {
+ let error = error as NSError
+ guard let reason = (error.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.int32Value,
+ error.domain == SUSparkleErrorDomain, error.code == SUError.noUpdateError.rawValue,
+ reason == SPUNoUpdateFoundReason.onLatestVersion.rawValue
+ || reason == SPUNoUpdateFoundReason.onNewerThanLatestVersion.rawValue else {
+ recordFailure(error)
+ return
+ }
availableUpdate = nil
retryInstallWhenFound = false
updateChoiceReply = nil
status = .upToDate
}
- func showUpdaterError(_ error: Error) async {
- retryInstallWhenFound = false
- updateChoiceReply = nil
- WeiBeiLog.workspace.error(
- "code=updater_failed underlying=\(WeiBeiLog.code(error), privacy: .public)"
- )
- status = .failed
- }
+ func showUpdaterError(_ error: Error) async { recordFailure(error) }
func showDownloadInitiated(cancellation: @escaping () -> Void) {
+ resetProgress()
status = .downloading
}
-
- func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {}
-
- func showDownloadDidReceiveData(ofLength length: UInt64) {}
-
- func showDownloadDidStartExtractingUpdate() {
- status = .extracting
+ func showDownloadDidReceiveExpectedContentLength(_ length: UInt64) {
+ expectedBytes = length
+ refreshProgress()
}
-
- func showExtractionReceivedProgress(_ progress: Double) {
- status = .extracting
+ func showDownloadDidReceiveData(ofLength length: UInt64) {
+ receivedBytes += length
+ refreshProgress()
}
+ func showDownloadDidStartExtractingUpdate() { status = .extracting }
+ func showExtractionReceivedProgress(_ progress: Double) { status = .extracting }
func showReadyToInstallAndRelaunch() async -> SPUUserUpdateChoice {
- status = .installing
- return .install
+ await withCheckedContinuation { continuation in
+ readyToInstall = true
+ updateChoiceReply = { continuation.resume(returning: $0) }
+ status = .ready
+ }
}
- func showInstallingUpdate(
- withApplicationTerminated applicationTerminated: Bool,
- retryTerminatingApplication: @escaping () -> Void
- ) {
- status = .installing
- }
+ func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool,
+ retryTerminatingApplication: @escaping () -> Void) { status = .installing }
func showUpdateInstalledAndRelaunched(_ relaunched: Bool) async {
availableUpdate = nil
@@ -295,23 +301,15 @@ extension WeiBeiUpdateService: SPUUserDriver {
}
func dismissUpdateInstallation() {
+ if readyToInstall { updateChoiceReply?(.dismiss) }
updateChoiceReply = nil
+ readyToInstall = false
retryInstallWhenFound = false
- if case .failed = status {
- return
- }
- status = availableUpdate == nil ? .idle : .available
+ if status != .failed { status = availableUpdate == nil ? .idle : .available }
}
private static func releaseNotesLines(for item: SUAppcastItem) -> [String] {
- let lines = WeiBeiAvailableUpdate.releaseNotesLines(from: item.itemDescription)
- if !lines.isEmpty { return lines }
- if let title = item.title?.trimmingCharacters(in: .whitespacesAndNewlines),
- !title.isEmpty,
- title != item.displayVersionString {
- return [title]
- }
- return []
+ WeiBeiAvailableUpdate.releaseNotesLines(from: item.itemDescription)
}
}
#endif
diff --git a/Sources/WeiBei/Views/ContentView.swift b/Sources/WeiBei/Views/ContentView.swift
index 87b6557ea..b0e099db3 100644
--- a/Sources/WeiBei/Views/ContentView.swift
+++ b/Sources/WeiBei/Views/ContentView.swift
@@ -1352,7 +1352,7 @@ private struct UnifiedTopBarView: View {
#if targetEnvironment(macCatalyst)
private var toolbarOverflowMenus: [UIMenu] {
- var navigation = [
+ let navigation = [
toolbarAction(store.ui("课程栏", "Course sidebar"), selected: libraryDrawer.isOpen, action: store.toggleLibrary),
toolbarAction(store.ui("后退", "Back"), enabled: store.canNavigateBack) {
withAnimation(WeiBeiMotion.layout) { store.navigateBackInWorkspace() }
@@ -1361,16 +1361,16 @@ private struct UnifiedTopBarView: View {
withAnimation(WeiBeiMotion.layout) { store.navigateForwardInWorkspace() }
}
]
- if updateService.showsToolbarControl, updateService.availableUpdate != nil {
- navigation.append(toolbarAction(store.ui("下载并安装魏碑更新", "Download and install the WeiBei update"),
- enabled: !updateService.isBusy, action: updateService.installAvailableUpdate))
- }
let panes = [
toolbarAction(store.ui("文稿", "Document"), selected: store.isPaneToggleActive(.reader), action: store.toggleReader),
toolbarAction(store.ui("对话", "Chat"), selected: store.isPaneToggleActive(.agent), action: store.toggleAgent),
toolbarAction(store.ui("笔记", "Notes"), selected: store.isPaneToggleActive(.notes), action: store.toggleNotes)
]
var actions: [UIAction] = []
+ if updateService.showsToolbarControl {
+ actions.append(toolbarAction(updateService.actionLabel(english: store.interfaceLanguage == .english),
+ enabled: !updateService.isBusy, action: updateService.installAvailableUpdate))
+ }
if shouldShowSearchAction {
actions.append(toolbarAction(searchPrompt,
selected: paneState.showDocumentSearch, action: toggleReaderSearch))
@@ -1782,10 +1782,17 @@ private struct UnifiedTopBarView: View {
@ViewBuilder
private var leftPrimaryControls: some View {
- HStack(spacing: 5) {
+ HStack(spacing: 6) {
libraryButton
navigationButtons
+
+ if updateService.showsToolbarControl {
+ WeiBeiUpdateControl()
+ .environmentObject(updateService)
+ .environmentObject(store)
+ .transition(.opacity.combined(with: .scale(scale: 0.94)))
+ }
}
}
@@ -1807,41 +1814,7 @@ private struct UnifiedTopBarView: View {
}
.weiBeiKeyboardShortcut(store.executableChord(for: .navigateForward))
.disabled(!store.canNavigateForward)
-
- if updateService.showsToolbarControl, let update = updateService.availableUpdate {
- Button {
- updateService.installAvailableUpdate()
- } label: {
- if updateService.isBusy {
- ProgressView()
- .controlSize(.mini)
- } else {
- Image(systemName: "arrow.down")
- .contentShape(Rectangle())
- }
- }
- .buttonStyle(WeiBeiIconButtonStyle(active: true, size: 24))
- .disabled(updateService.isBusy)
- .accessibilityLabel(Text(store.ui("下载并安装魏碑更新", "Download and install the WeiBei update")))
- .help(updateHelpText(update))
- .transition(.opacity.combined(with: .scale(scale: 0.92)))
- }
- }
- .animation(WeiBeiMotion.panel, value: updateService.showsToolbarControl)
- }
-
- private func updateHelpText(_ update: WeiBeiAvailableUpdate) -> String {
- var text = update.helpText
- if update.releaseNotesLines.count > update.summaryLines.count {
- text += "\n" + store.ui(
- "完整更新说明可在“设置 > 关于”中展开。",
- "Expand the full release notes in Settings > About."
- )
- }
- if case .failed = updateService.status {
- text += "\n" + store.ui("更新失败,点击重试。", "Update failed. Click to retry.")
}
- return text
}
private var paneToggleCluster: some View {
diff --git a/Sources/WeiBei/Views/CourseWorkspaceView.swift b/Sources/WeiBei/Views/CourseWorkspaceView.swift
index afca37a2c..1e7b0fd6a 100644
--- a/Sources/WeiBei/Views/CourseWorkspaceView.swift
+++ b/Sources/WeiBei/Views/CourseWorkspaceView.swift
@@ -411,6 +411,8 @@ struct CourseWorkspaceHeader: View {
.buttonStyle(.plain)
.accessibilityLabel(Text(store.ui("关闭课程空间并返回工作台", "Close course space")))
+ WeiBeiUpdateControl()
+
HStack(spacing: 8) {
hubTitleBlock
@@ -467,6 +469,7 @@ struct CourseWorkspaceHeader: View {
}
.weibeiInputSurface(active: searchFocused.wrappedValue, height: 30)
.frame(width: isCompact ? 160 : 220)
+
}
.padding(.horizontal, 16)
.frame(height: 52)
diff --git a/Sources/WeiBei/Views/Settings/SettingsView.swift b/Sources/WeiBei/Views/Settings/SettingsView.swift
index f4c2e8ed6..8b0d81966 100644
--- a/Sources/WeiBei/Views/Settings/SettingsView.swift
+++ b/Sources/WeiBei/Views/Settings/SettingsView.swift
@@ -116,7 +116,6 @@ struct SettingsView: View {
@State private var feedbackBody = ""
@State private var feedbackBusy = false
@State private var feedbackStatus: String?
- @State private var showsFullUpdateNotes = false
// 资料库位置迁移(计划 §4.1)。
@State private var pendingMigrationDestination: URL?
@State private var migrationErrorText: String?
@@ -848,20 +847,7 @@ struct SettingsView: View {
title: store.ui("新版本 \(availableUpdate.version)", "New Version \(availableUpdate.version)"),
detail: userFacingUpdateDetail(availableUpdate),
showsBottomDivider: false
- ) {
- if availableUpdate.releaseNotesLines.count > availableUpdate.summaryLines.count {
- Button {
- withAnimation(WeiBeiMotion.panel) {
- showsFullUpdateNotes.toggle()
- }
- } label: {
- Text(showsFullUpdateNotes
- ? store.ui("收起", "Collapse")
- : store.ui("展开全文", "Show All"))
- }
- .buttonStyle(WeiBeiTextActionButtonStyle(active: true))
- }
- }
+ ) {}
}
}
@@ -881,7 +867,7 @@ struct SettingsView: View {
}
if case .failed = updateService.status {
- Text(store.ui("更新失败,请重试。", "Update failed. Please try again."))
+ Text(updateService.errorDescription ?? store.ui("更新失败,请重试。", "Update failed. Please try again."))
.font(SettingsType.detail)
.foregroundStyle(WeiBeiTheme.tertiaryInk)
.padding(.horizontal, 4)
@@ -891,10 +877,7 @@ struct SettingsView: View {
private var updateActionLabel: String {
if updateService.availableUpdate != nil {
- if case .failed = updateService.status {
- return store.ui("重试安装", "Retry Install")
- }
- return store.ui("下载并安装", "Download and Install")
+ return updateService.actionLabel(english: store.interfaceLanguage == .english)
}
switch updateService.status {
case .upToDate:
@@ -1047,9 +1030,9 @@ struct SettingsView: View {
}
private func userFacingUpdateDetail(_ update: WeiBeiAvailableUpdate) -> String {
- let lines = showsFullUpdateNotes ? update.releaseNotesLines : update.summaryLines
+ let lines = update.releaseNotesLines.map(WeiBeiAvailableUpdate.displayText)
return lines.isEmpty
- ? store.ui("包含最新改进和修复。", "Includes the latest improvements and fixes.")
+ ? store.ui("暂无更新说明。", "Release notes are unavailable.")
: lines.joined(separator: "\n")
}
diff --git a/Sources/WeiBei/Views/WeiBeiUpdateControl.swift b/Sources/WeiBei/Views/WeiBeiUpdateControl.swift
new file mode 100644
index 000000000..3ef40e859
--- /dev/null
+++ b/Sources/WeiBei/Views/WeiBeiUpdateControl.swift
@@ -0,0 +1,351 @@
+import SwiftUI
+#if targetEnvironment(macCatalyst)
+import UIKit
+#endif
+
+/// One action in the toolbar; the attached hover surface is only for reading.
+struct WeiBeiUpdateControl: View {
+ @EnvironmentObject private var store: WorkspaceStore
+ @EnvironmentObject private var updates: WeiBeiUpdateService
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var showingNotes = false
+ @State private var isHoveringButton = false
+ @State private var dismissTask: Task?
+
+ private var isDark: Bool { colorScheme == .dark }
+
+ var body: some View {
+ if updates.showsToolbarControl, let update = updates.availableUpdate {
+ Button {
+ showingNotes = false
+ updates.installAvailableUpdate()
+ } label: {
+ contentPill
+ }
+ .buttonStyle(WeiBeiUpdateButtonStyle())
+ // Keep hover readable during progress; the service rejects duplicate actions.
+ .accessibilityLabel(updates.actionLabel(english: store.interfaceLanguage == .english))
+ .accessibilityValue(updates.downloadProgress.map { "\(Int($0 * 100))%" } ?? "")
+ .onHover { hovering in
+ isHoveringButton = hovering
+ hoverChanged(hovering)
+ }
+ .popover(isPresented: $showingNotes, attachmentAnchor: .rect(.bounds), arrowEdge: .top) {
+ WeiBeiUpdateNotesPanel(update: update,
+ error: updates.errorDescription,
+ english: store.interfaceLanguage == .english)
+ .onHover(perform: hoverChanged)
+ .presentationBackground(WeiBeiTheme.paperRaised)
+#if targetEnvironment(macCatalyst)
+ .presentationCompactAdaptation(.popover)
+ .background(UpdatePopoverPassthrough())
+#endif
+ }
+ .onDisappear {
+ dismissTask?.cancel()
+ showingNotes = false
+ }
+ }
+ }
+
+ private var isExpanded: Bool {
+ updates.status == .downloading || updates.status == .ready || updates.status == .failed || updates.status == .extracting || updates.status == .saving || updates.status == .installing
+ }
+
+ private var badgeColor: Color {
+ switch updates.status {
+ case .ready:
+ return Color(red: 0.16, green: 0.74, blue: 0.46)
+ case .failed:
+ return Color(red: 0.90, green: 0.28, blue: 0.24)
+ default:
+ return Color(red: 0.92, green: 0.35, blue: 0.24)
+ }
+ }
+
+ private var contentPill: some View {
+ HStack(spacing: 5.5) {
+ statusBadge
+
+ if isExpanded {
+ textContent
+ .transition(.opacity.combined(with: .move(edge: .leading)))
+ }
+ }
+ .padding(.leading, isExpanded ? 3.5 : 0)
+ .padding(.trailing, isExpanded ? 8.5 : 0)
+ .frame(height: 24)
+ .background {
+ if isExpanded {
+ Capsule()
+ .fill(
+ isDark
+ ? Color(white: 0.18).opacity(isHoveringButton || showingNotes ? 0.95 : 0.82)
+ : Color(white: 0.93).opacity(isHoveringButton || showingNotes ? 1.0 : 0.88)
+ )
+ .overlay {
+ Capsule()
+ .stroke(
+ isHoveringButton || showingNotes ? badgeColor.opacity(0.40) : (isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)),
+ lineWidth: 0.6
+ )
+ }
+ .shadow(color: Color.black.opacity(isDark ? 0.25 : 0.06), radius: 2, y: 1)
+ }
+ }
+ .scaleEffect(isHoveringButton ? 1.03 : 1.0)
+ .animation(WeiBeiMotion.hover, value: isHoveringButton)
+ .animation(WeiBeiMotion.layout, value: isExpanded)
+ .contentShape(Rectangle())
+ }
+
+ private var statusBadge: some View {
+ ZStack {
+ Circle()
+ .fill(badgeColor)
+ .frame(width: isExpanded ? 18 : 24, height: isExpanded ? 18 : 24)
+ .shadow(
+ color: badgeColor.opacity(isHoveringButton || showingNotes ? 0.45 : 0.22),
+ radius: isHoveringButton ? 3 : 1.5,
+ y: 1
+ )
+
+ if updates.status == .downloading, let progress = updates.downloadProgress {
+ ZStack {
+ Circle()
+ .stroke(Color.white.opacity(0.35), lineWidth: 1.6)
+ Circle()
+ .trim(from: 0, to: CGFloat(min(max(progress, 0.05), 1.0)))
+ .stroke(Color.white, style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ }
+ .frame(width: 12, height: 12)
+ } else if updates.isBusy {
+ ProgressView()
+ .controlSize(.mini)
+ .tint(.white)
+ .frame(width: 12, height: 12)
+ } else if updates.status == .ready {
+ Image(systemName: "arrow.clockwise")
+ .font(.system(size: 9.5, weight: .bold))
+ .foregroundStyle(Color.white)
+ } else if updates.status == .failed {
+ Image(systemName: "arrow.trianglehead.clockwise")
+ .font(.system(size: 9.5, weight: .bold))
+ .foregroundStyle(Color.white)
+ } else {
+ Image(systemName: "arrow.down.to.line")
+ .font(.system(size: isExpanded ? 9.5 : 11, weight: .semibold))
+ .foregroundStyle(Color.white)
+ }
+ }
+ .frame(width: isExpanded ? 18 : 24, height: isExpanded ? 18 : 24)
+ }
+
+ @ViewBuilder
+ private var textContent: some View {
+ if updates.status == .downloading, let progress = updates.downloadProgress {
+ Text(progress, format: .percent.precision(.fractionLength(0)))
+ .monospacedDigit()
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(isDark ? Color.white.opacity(0.92) : Color.black.opacity(0.85))
+ } else {
+ Text(updates.actionLabel(english: store.interfaceLanguage == .english))
+ .font(.system(size: 11.5, weight: .medium))
+ .foregroundStyle(isDark ? Color.white.opacity(0.92) : Color.black.opacity(0.85))
+ }
+ }
+
+ private func hoverChanged(_ hovering: Bool) {
+ dismissTask?.cancel()
+ if hovering {
+ showingNotes = true
+ } else {
+ // Let the pointer cross the small native popover gap without losing it.
+ dismissTask = Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(220))
+ guard !Task.isCancelled else { return }
+ showingNotes = false
+ }
+ }
+ }
+}
+
+/// Tactile button press feedback for the toolbar update button
+private struct WeiBeiUpdateButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
+ .animation(WeiBeiMotion.press, value: configuration.isPressed)
+ }
+}
+
+struct WeiBeiUpdateNotesPanel: View {
+ let update: WeiBeiAvailableUpdate
+ var error: String?
+ var english = false
+ @State private var contentHeight: CGFloat = 0
+ @Environment(\.colorScheme) private var colorScheme
+
+ private var isDark: Bool { colorScheme == .dark }
+ private let viewportLimit: CGFloat = 310
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 11) {
+ headerView
+
+ Rectangle()
+ .fill(WeiBeiTheme.hairline)
+ .frame(height: 0.5)
+
+ if let error {
+ errorBanner(error)
+ }
+
+ ScrollView {
+ notes
+ .padding(.vertical, contentHeight > viewportLimit ? 4 : 0)
+ .background(GeometryReader { proxy in
+ Color.clear
+ .onAppear { contentHeight = proxy.size.height }
+ .onChange(of: proxy.size.height) { _, height in contentHeight = height }
+ })
+ }
+ .scrollBounceBehavior(.basedOnSize)
+ .frame(height: min(contentHeight, viewportLimit))
+ .mask {
+ VStack(spacing: 0) {
+ Rectangle().fill(Color.black)
+
+ if contentHeight > viewportLimit {
+ LinearGradient(
+ colors: [.black, .clear],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .frame(height: 12)
+ }
+ }
+ }
+ }
+ .padding(15)
+ .frame(width: 348)
+ .background(WeiBeiTheme.paperRaised)
+ .environment(\.locale, Locale(identifier: english ? "en" : "zh_CN"))
+ }
+
+ private var headerView: some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text("魏碑 \(update.version)")
+ .font(.system(size: 13.5, weight: .semibold))
+ .foregroundStyle(WeiBeiTheme.ink)
+
+ Spacer(minLength: 12)
+
+ if let date = update.publishedDate {
+ Text(date, format: .dateTime.year().month().day())
+ .font(.system(size: 11))
+ .foregroundStyle(WeiBeiTheme.tertiaryInk)
+ }
+ }
+ }
+
+ private func errorBanner(_ message: String) -> some View {
+ HStack(alignment: .top, spacing: 7) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 11))
+ .foregroundStyle(WeiBeiTheme.cinnabar)
+ Text(message)
+ .font(.system(size: 11.5))
+ .foregroundStyle(WeiBeiTheme.cinnabar)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(9)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(WeiBeiTheme.cinnabar.opacity(0.08))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+
+ private var notes: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if update.releaseNotesLines.isEmpty {
+ Text(english ? "Fetching release notes…" : "正在获取更新说明…")
+ .font(.system(size: 12))
+ .foregroundStyle(WeiBeiTheme.secondaryInk)
+ }
+ ForEach(Array(update.releaseNotesLines.enumerated()), id: \.offset) { index, line in
+ if WeiBeiAvailableUpdate.isHeading(line) {
+ headingRow(line, isFirst: index == 0)
+ } else {
+ bulletRow(line)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private func headingRow(_ line: String, isFirst: Bool) -> some View {
+ let title = WeiBeiAvailableUpdate.displayText(line)
+ let color = headingColor(title)
+ return HStack(spacing: 5) {
+ Capsule()
+ .fill(color)
+ .frame(width: 3, height: 11)
+ Text(LocalizedStringKey(title))
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(WeiBeiTheme.ink)
+ }
+ .padding(.top, isFirst ? 0 : 7)
+ }
+
+ private func headingColor(_ title: String) -> Color {
+ if title.contains("新增") || title.contains("New") {
+ return Color(red: 0.16, green: 0.74, blue: 0.46)
+ } else if title.contains("改进") || title.contains("优化") || title.contains("Improvements") {
+ return Color(red: 0.24, green: 0.60, blue: 0.98)
+ } else if title.contains("修复") || title.contains("Fixed") || title.contains("Fixes") {
+ return Color(red: 0.94, green: 0.52, blue: 0.18)
+ }
+ return WeiBeiTheme.tertiaryInk
+ }
+
+ private func bulletRow(_ text: String) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 7) {
+ Circle()
+ .fill(WeiBeiTheme.tertiaryInk.opacity(0.7))
+ .frame(width: 3.5, height: 3.5)
+ .offset(y: -1.5)
+ Text(LocalizedStringKey(text))
+ .font(.system(size: 12))
+ .lineSpacing(3)
+ .foregroundStyle(WeiBeiTheme.secondaryInk)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+}
+
+#if targetEnvironment(macCatalyst)
+/// UIKit otherwise consumes the first click outside a presented popover.
+/// Passing through its source view keeps the hovered update button actionable.
+private struct UpdatePopoverPassthrough: UIViewRepresentable {
+ func makeUIView(context: Context) -> Probe { Probe() }
+ func updateUIView(_ view: Probe, context: Context) { view.configure() }
+ final class Probe: UIView {
+ override func didMoveToWindow() { super.didMoveToWindow(); configure() }
+ func configure() {
+ DispatchQueue.main.async { [weak self] in
+ var responder: UIResponder? = self
+ while let current = responder {
+ if let controller = current as? UIViewController,
+ let popover = controller.popoverPresentationController,
+ let source = popover.sourceView {
+ popover.passthroughViews = [source]
+ return
+ }
+ responder = current.next
+ }
+ }
+ }
+ }
+}
+#endif
diff --git a/Tests/WeiBeiSafetyTests/WeiBeiUpdateServiceTests.swift b/Tests/WeiBeiSafetyTests/WeiBeiUpdateServiceTests.swift
index fc9f8bc60..23be625f6 100644
--- a/Tests/WeiBeiSafetyTests/WeiBeiUpdateServiceTests.swift
+++ b/Tests/WeiBeiSafetyTests/WeiBeiUpdateServiceTests.swift
@@ -1,32 +1,69 @@
import XCTest
+import Combine
+import Sparkle
@testable import WeiBei
final class WeiBeiUpdateServiceTests: XCTestCase {
- func testReleaseNotesSummaryKeepsLateCriticalSectionsAndFullText() {
- let notes = """
- 本次更新
- 顶部栏显示下载入口
- 设置页共用更新状态
- 下载完成后自动安装
- 安装前保存工作区
- 失败后可以重试
- 破坏性变化
- 迁移
- 已知问题
- """
+ func testNotesKeepHeadingsAndEveryLateChange() {
+ let details = (1...12).map { "改动\($0)" }.joined()
+ let notes = WeiBeiAvailableUpdate.releaseNotesLines(from:
+ "改进
已知问题
需要重新打开资料
")
+ XCTAssertEqual(notes.count, 15)
+ XCTAssertTrue(WeiBeiAvailableUpdate.isHeading(notes[0]))
+ XCTAssertTrue(WeiBeiAvailableUpdate.isHeading(notes[13]))
+ XCTAssertEqual(notes.last, "需要重新打开资料")
+ }
+
+ @MainActor func testProgressUsesBytesAndHandlesMissingOrChangedLength() {
+ let service = WeiBeiUpdateService(startsUpdater: false)
+ service.showDownloadDidReceiveData(ofLength: 20)
+ XCTAssertNil(service.downloadProgress)
+ service.showDownloadDidReceiveExpectedContentLength(100)
+ XCTAssertEqual(service.downloadProgress, 0.2)
+ service.showDownloadDidReceiveData(ofLength: 30)
+ XCTAssertEqual(service.downloadProgress, 0.5)
+ service.showDownloadDidReceiveExpectedContentLength(200)
+ XCTAssertEqual(service.downloadProgress, 0.25)
+ service.showDownloadDidReceiveExpectedContentLength(10)
+ XCTAssertNil(service.downloadProgress)
+ }
+
+ @MainActor func testReadyWaitsForClickAndSuccessfulSaveBeforeRelaunch() async {
+ let update = WeiBeiAvailableUpdate(version: "1.2.3", releaseNotesLines: [],
+ informationOnly: false, informationURL: nil)
+ let service = WeiBeiUpdateService(startsUpdater: false, availableUpdate: update)
+ var saveCalls = 0
+ var canSave = false
+ service.prepareForInstallation = { saveCalls += 1; return canSave }
+ let ready = expectation(description: "Update prepared without restarting")
+ let observation = service.$status.sink { if $0 == .ready { ready.fulfill() } }
+ let installation = Task { await service.showReadyToInstallAndRelaunch() }
+ await fulfillment(of: [ready], timeout: 2)
+ XCTAssertEqual(saveCalls, 0)
+ XCTAssertFalse(service.isBusy)
+ observation.cancel()
+
+ let failed = expectation(description: "Save failure blocks restarting")
+ let failureObservation = service.$status.sink { if $0 == .failed { failed.fulfill() } }
+ service.installAvailableUpdate()
+ await fulfillment(of: [failed], timeout: 2)
+ XCTAssertEqual(saveCalls, 1)
+ XCTAssertNotNil(service.errorDescription)
+ XCTAssertEqual(service.availableUpdate, update)
+ failureObservation.cancel()
+
+ canSave = true
+ service.installAvailableUpdate()
+ let choice = await installation.value
+ XCTAssertEqual(choice, .install)
+ XCTAssertEqual(saveCalls, 2)
+ XCTAssertEqual(service.status, .installing)
+ }
- XCTAssertEqual(
- WeiBeiAvailableUpdate.summaryLines(from: notes),
- [
- "破坏性变化:旧版快捷键不再占用编辑器",
- "破坏性变化:旧工具名停止注册",
- "迁移:人工会话标题自动保留",
- "迁移:旧快捷键配置会显示冲突",
- "已知问题:首次展开可能稍慢",
- "已知问题:第二项已知问题仍会显示",
- ]
- )
- let update = WeiBeiAvailableUpdate(
- version: "1.2.3",
- releaseNotesLines: WeiBeiAvailableUpdate.releaseNotesLines(from: notes),
- informationOnly: false,
- informationURL: nil
- )
- XCTAssertTrue(update.helpText.contains("已知问题:第二项已知问题仍会显示"))
+ @MainActor func testCheckFailureIsNotReportedAsLatestVersion() async {
+ let service = WeiBeiUpdateService(startsUpdater: false)
+ await service.showUpdateNotFoundWithError(URLError(.notConnectedToInternet))
+ XCTAssertEqual(service.status, .failed)
+ XCTAssertNotNil(service.errorDescription)
}
}