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: + "

    改进

      \(details)

    已知问题

    需要重新打开资料

    ") + 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) } }