From 24f2ef2fa9d2ba52ea6b1271dbc811926584ea0c Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Mon, 10 Aug 2026 16:17:44 +0800 Subject: [PATCH 1/2] feat(converter): add native release update checks --- README.md | 7 + README.zh-CN.md | 5 + apps/mh3g-save-converter-macos/README.md | 15 ++ .../mh3g-save-converter-macos/README.zh-CN.md | 12 ++ .../ConverterPresentation/Localization.swift | 28 +++ .../UpdateChecking.swift | 121 ++++++++++++ .../ConversionWorkbenchView.swift | 18 +- .../GitHubUpdateChecker.swift | 138 ++++++++++++++ .../Resources/Localizable.xcstrings | 14 ++ .../MH3GSaveConverterMac/SettingsView.swift | 58 ++++++ .../UpdateReleaseView.swift | 94 +++++++++ .../UpdateCheckingTests.swift | 62 ++++++ .../Infrastructure/ConverterCopy.cs | 45 +++++ .../MainWindow.xaml | 8 +- .../MainWindow.xaml.cs | 179 +++++++++++++++++- .../Models/UpdateModels.cs | 32 ++++ apps/mh3g-save-converter-windows/README.md | 18 +- .../README.zh-CN.md | 14 +- .../Services/GitHubUpdateService.cs | 96 ++++++++++ .../Services/UpdateCheckPreferenceStore.cs | 102 ++++++++++ .../package-mh3g-save-converter-windows.ps1 | 11 +- ...rify-mh3g-save-converter-windows-source.py | 38 ++++ 22 files changed, 1104 insertions(+), 11 deletions(-) create mode 100644 apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift create mode 100644 apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift create mode 100644 apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/UpdateReleaseView.swift create mode 100644 apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift create mode 100644 apps/mh3g-save-converter-windows/Models/UpdateModels.cs create mode 100644 apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs create mode 100644 apps/mh3g-save-converter-windows/Services/UpdateCheckPreferenceStore.cs diff --git a/README.md b/README.md index 91cf69c..dc5e316 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,13 @@ rollback themselves. The macOS window appears in the Dock and Cmd-Tab, follows the system language by default, and can switch between Simplified Chinese and English in Settings. +Both native workbenches expose **About & Updates**. The first launch on each +local calendar day makes at most one non-blocking request to the official +`MHToolkit/mh-save-sync` latest-release endpoint; users may also check +manually. An unavailable GitHub connection never blocks conversion. A newer +release dialog shows its version, publication date, release notes, and official +link. No save bytes or selected paths are sent with this request. + The workbenches provide four **guided but non-blocking** stages: input and inspection, optional shared data, Dry Run, then write or rollback. Completing one stage reveals the recommended next action (for example, inspect then diff --git a/README.zh-CN.md b/README.zh-CN.md index 4622ba6..6aec25d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -53,6 +53,11 @@ JSON 报告;不会在 UI 内重写字节转换、备份、manifest、模拟器 macOS 窗口会正常出现于 Dock 和 Cmd-Tab;默认跟随系统语言,也可在设置中切换简体中文或 English。 +两个原生工作台都提供“**关于与更新**”。每个本地自然日首次启动最多向 +`MHToolkit/mh-save-sync` 官方 latest-release 接口发起一次非阻塞请求,也可手动检查; +GitHub 不可访问时绝不会阻塞转换。发现新版时会显示版本、发布日期、Release 说明和官方 +链接。该请求不会上传任何存档字节或用户所选路径。 + 工作台提供四个**有引导、但不强制**的阶段:输入与检查、可选共享数据、Dry Run、写入或 回滚。完成一个阶段后会显示推荐的下一步操作(例如检查完成后配置可选数据),但玩家仍可 返回前一阶段,或跳过可选组件。该引导不会额外执行任何转换。 diff --git a/apps/mh3g-save-converter-macos/README.md b/apps/mh3g-save-converter-macos/README.md index 3b4d2f5..3fde396 100644 --- a/apps/mh3g-save-converter-macos/README.md +++ b/apps/mh3g-save-converter-macos/README.md @@ -31,6 +31,21 @@ Normal conversion uses a single-file manifest; compatibility repair uses `.mh3g-compatibility-repair-.json` and `rollback-repair`. Quit Nemessix, Azahar, and Cemu before any write or rollback. +The optional housekeeper gallery/movie migration never creates or replaces a +shared `system` from the 3DS file alone. Select both the 3DS source `system` and +an existing initialized Cemu `system`; the converter unions only the known +gallery/movie flags and preserves every other Cemu byte, including data shared +by the other character slots. + +## Updates + +The About & Updates section can query the latest stable release from the +official `MHToolkit/mh-save-sync` GitHub Release API. The first launch on each +local calendar day makes at most one silent attempt. A blocked or unavailable +GitHub connection never blocks the window or any local conversion; manual +checks show the error and may be retried. When a newer release exists, the app +shows its title, publication date, release notes, and official release link. + ## Local verification ```bash diff --git a/apps/mh3g-save-converter-macos/README.zh-CN.md b/apps/mh3g-save-converter-macos/README.zh-CN.md index 883491f..7f93f72 100644 --- a/apps/mh3g-save-converter-macos/README.zh-CN.md +++ b/apps/mh3g-save-converter-macos/README.zh-CN.md @@ -23,6 +23,18 @@ sidecar 并展示 JSON 报告,不在 Swift 中重复实现存档转换。 使用 `.mh3g-compatibility-repair-.json` 并通过 `rollback-repair` 总体回滚。 执行写入或回滚前必须退出 Nemessix、Azahar 和 Cemu。 +可选的管家画廊/动画迁移不会只凭 3DS `system` 新建或整文件覆盖共享 +`system`。必须同时选择 3DS 源 `system` 和已存在、已初始化的 Cemu `system`; +转换器只合并已知画廊/动画标记,其他 Cemu 字节(包括其他角色槽位共享数据) +全部保留。 + +## 更新检查 + +“关于与更新”可以查询 `MHToolkit/mh-save-sync` 官方 GitHub Release API 的最新 +正式版。每个本地自然日首次启动最多静默尝试一次;GitHub 无法访问时不会阻塞 +窗口,也不会影响任何本地转换。手动检查会展示错误并允许重试;发现新版本时会 +显示版本名、发布日期、Release 说明和官方发布链接。 + ## 本地验证 ```bash diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift index 87f545b..9f41529 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift @@ -292,6 +292,20 @@ public enum ConverterCopy { "WorkflowState.Failure": "操作失败", "Settings.BundledSidecar": "打包时使用内置 sidecar", "Settings.Development": "开发版本", + "Update.About": "关于与更新", + "Update.CurrentVersion": "当前版本", + "Update.Check": "检查更新", + "Update.Checking": "正在连接 GitHub…", + "Update.UpToDate": "已经是最新版(GitHub:%@)", + "Update.Available": "发现新版本 %@,请在弹窗中查看更新说明。", + "Update.Failed": "无法检查更新", + "Update.NetworkNote": "每天首次启动会静默检查一次 GitHub Release;网络不可用时不会阻塞或影响任何本地存档操作。也可以随时手动重试。", + "Update.AvailableTitle": "发现新版本", + "Update.VersionSummary": "当前 %@ · 最新 %@", + "Update.ReleaseNotes": "更新说明", + "Update.NoReleaseNotes": "该版本未提供更新说明。", + "Update.Later": "稍后", + "Update.OpenRelease": "打开发布页面", ] private static let english: [String: String] = [ @@ -482,6 +496,20 @@ public enum ConverterCopy { "WorkflowState.Failure": "Operation failed", "Settings.BundledSidecar": "Bundled sidecar when packaged", "Settings.Development": "development", + "Update.About": "About & Updates", + "Update.CurrentVersion": "Current version", + "Update.Check": "Check for Updates", + "Update.Checking": "Contacting GitHub…", + "Update.UpToDate": "You are up to date (GitHub: %@)", + "Update.Available": "Version %@ is available. Review its release notes in the update window.", + "Update.Failed": "Unable to check for updates", + "Update.NetworkNote": "The first launch each day silently checks GitHub Releases once. An unavailable network never blocks or changes any local save operation, and you can retry manually at any time.", + "Update.AvailableTitle": "Update Available", + "Update.VersionSummary": "Current %@ · Latest %@", + "Update.ReleaseNotes": "Release notes", + "Update.NoReleaseNotes": "No release notes were provided for this version.", + "Update.Later": "Later", + "Update.OpenRelease": "Open Release Page", ] /// The complete set of copy IDs rendered by the SwiftUI workbench. Tests diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift new file mode 100644 index 0000000..b11e196 --- /dev/null +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift @@ -0,0 +1,121 @@ +import Foundation + +public struct ConverterSemanticVersion: Comparable, Equatable, Sendable, CustomStringConvertible { + public let components: [Int] + + public init?(_ value: String) { + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingPrefix("v") + .split(separator: "+", maxSplits: 1, omittingEmptySubsequences: true)[0] + .split(separator: "-", maxSplits: 1, omittingEmptySubsequences: true)[0] + let parts = normalized.split(separator: ".", omittingEmptySubsequences: false) + guard !parts.isEmpty, + parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }), + parts.allSatisfy({ Int($0) != nil }) + else { + return nil + } + components = parts.map { Int($0)! } + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + let count = max(lhs.components.count, rhs.components.count) + for index in 0.. Bool { + !(lhs < rhs) && !(rhs < lhs) + } + + public var description: String { + components.map(String.init).joined(separator: ".") + } +} + +public struct GitHubConverterRelease: Codable, Equatable, Identifiable, Sendable { + public let tagName: String + public let name: String? + public let body: String? + public let htmlURL: URL + public let publishedAt: String? + public let draft: Bool + public let prerelease: Bool + + public var id: String { tagName } + + public var isOfficialStableRelease: Bool { + !draft + && !prerelease + && htmlURL.scheme == "https" + && htmlURL.host?.lowercased() == "github.com" + && htmlURL.path.hasPrefix("/MHToolkit/mh-save-sync/releases/") + } + + public init( + tagName: String, + name: String?, + body: String?, + htmlURL: URL, + publishedAt: String?, + draft: Bool, + prerelease: Bool + ) { + self.tagName = tagName + self.name = name + self.body = body + self.htmlURL = htmlURL + self.publishedAt = publishedAt + self.draft = draft + self.prerelease = prerelease + } + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case name + case body + case htmlURL = "html_url" + case publishedAt = "published_at" + case draft + case prerelease + } +} + +public enum ConverterUpdateDecision: Equatable, Sendable { + case updateAvailable + case upToDate + case invalidVersion + + public static func decide(current: String, latest: String) -> Self { + guard let currentVersion = ConverterSemanticVersion(current), + let latestVersion = ConverterSemanticVersion(latest) + else { + return .invalidVersion + } + return currentVersion < latestVersion ? .updateAvailable : .upToDate + } +} + +public enum DailyUpdateCheckGate { + public static func shouldCheck( + lastAttempt: Date?, + now: Date, + calendar: Calendar = .current + ) -> Bool { + guard let lastAttempt else { return true } + return !calendar.isDate(lastAttempt, inSameDayAs: now) + } +} + +private extension String { + func trimmingPrefix(_ prefix: Character) -> String { + first == prefix ? String(dropFirst()) : self + } +} diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ConversionWorkbenchView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ConversionWorkbenchView.swift index 63bb595..f9289ae 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ConversionWorkbenchView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ConversionWorkbenchView.swift @@ -5,6 +5,7 @@ struct ConversionWorkbenchView: View { @Binding var localeOverride: String @State private var selectedNavigation: ConverterNavigation? = .input @State private var workflow = ConversionWorkflow(executable: ConverterExecutableLocator.locate()) + @StateObject private var updateChecker = GitHubUpdateChecker() @Environment(\.accessibilityReduceMotion) private var reduceMotion private var language: ConverterLanguage { @@ -50,6 +51,16 @@ struct ConversionWorkbenchView: View { // Navigation is controlled by native split-view selection. The // content changes are brief and do not disable any other control. } + .task { + await updateChecker.checkAutomaticallyIfNeeded() + } + .sheet(item: $updateChecker.availableRelease) { release in + UpdateReleaseView( + release: release, + currentVersion: updateChecker.currentVersion, + language: language + ) + } } @ViewBuilder @@ -68,7 +79,12 @@ struct ConversionWorkbenchView: View { case .experimentalCEC: ExperimentalCECView(workflow: workflow, language: language) case .settings: - SettingsView(localeOverride: $localeOverride, workflow: workflow, language: language) + SettingsView( + localeOverride: $localeOverride, + workflow: workflow, + updateChecker: updateChecker, + language: language + ) } } } diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift new file mode 100644 index 0000000..6590a9e --- /dev/null +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift @@ -0,0 +1,138 @@ +import ConverterPresentation +import Foundation + +enum UpdateCheckStatus: Equatable { + case idle + case checking + case upToDate(String) + case updateAvailable(String) + case failed(String) +} + +@MainActor +final class GitHubUpdateChecker: ObservableObject { + static let releaseEndpoint = URL(string: "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest")! + static let lastAttemptDefaultsKey = "MH3GSaveConverter.LastUpdateCheckAttempt" + + @Published private(set) var status: UpdateCheckStatus = .idle + @Published var availableRelease: GitHubConverterRelease? + + let currentVersion: String + + private let session: URLSession + private let defaults: UserDefaults + + init( + currentVersion: String? = nil, + session: URLSession? = nil, + defaults: UserDefaults = .standard + ) { + self.currentVersion = currentVersion ?? Self.bundleVersion + self.defaults = defaults + if let session { + self.session = session + } else { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 8 + configuration.timeoutIntervalForResource = 12 + self.session = URLSession(configuration: configuration) + } + } + + var isChecking: Bool { + status == .checking + } + + func checkAutomaticallyIfNeeded(now: Date = .now, calendar: Calendar = .current) async { + guard !isChecking, + DailyUpdateCheckGate.shouldCheck( + lastAttempt: defaults.object(forKey: Self.lastAttemptDefaultsKey) as? Date, + now: now, + calendar: calendar + ) + else { + return + } + + // Persist before networking. A blocked or offline GitHub request must + // not be repeated on every launch during the same local calendar day. + defaults.set(now, forKey: Self.lastAttemptDefaultsKey) + await performCheck(manual: false) + } + + func checkManually() async { + guard !isChecking else { return } + await performCheck(manual: true) + } + + private func performCheck(manual: Bool) async { + status = .checking + do { + var request = URLRequest(url: Self.releaseEndpoint) + request.timeoutInterval = 8 + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + request.setValue("MH3GSaveConverter/\(safeUserAgentVersion)", forHTTPHeaderField: "User-Agent") + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw UpdateCheckError.invalidResponse + } + guard data.count <= 2 * 1_024 * 1_024 else { + throw UpdateCheckError.responseTooLarge + } + + let release = try JSONDecoder().decode(GitHubConverterRelease.self, from: data) + try validate(release: release) + switch ConverterUpdateDecision.decide(current: currentVersion, latest: release.tagName) { + case .updateAvailable: + availableRelease = release + status = .updateAvailable(release.tagName) + case .upToDate: + availableRelease = nil + status = .upToDate(release.tagName) + case .invalidVersion: + throw UpdateCheckError.invalidVersion + } + } catch { + availableRelease = nil + // Automatic checks stay silent and never obstruct the local save + // workflow. Manual checks retain a diagnostic for the About pane. + status = manual ? .failed(error.localizedDescription) : .idle + } + } + + private func validate(release: GitHubConverterRelease) throws { + guard release.isOfficialStableRelease else { + throw UpdateCheckError.invalidRelease + } + } + + private var safeUserAgentVersion: String { + ConverterSemanticVersion(currentVersion)?.description ?? "0.0.0" + } + + private static var bundleVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "development" + } +} + +private enum UpdateCheckError: LocalizedError { + case invalidResponse + case responseTooLarge + case invalidRelease + case invalidVersion + + var errorDescription: String? { + switch self { + case .invalidResponse: + "GitHub Release API returned an unexpected response." + case .responseTooLarge: + "GitHub Release API response exceeded the accepted size." + case .invalidRelease: + "GitHub Release metadata did not match the official MHToolkit repository." + case .invalidVersion: + "The current or latest release version could not be compared safely." + } + } +} diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings index 1a06981..f9d4da8 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings @@ -54,6 +54,20 @@ "Write.StagingSetSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Staging set SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "暂存集合 SHA-256" } } } }, "Write.TargetSetSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Target set SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "目标集合 SHA-256" } } } }, "Write.TargetSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Target SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "目标文件 SHA-256" } } } }, + "Update.About" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "About & Updates" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "关于与更新" } } } }, + "Update.CurrentVersion" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current version" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "当前版本" } } } }, + "Update.Check" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Check for Updates" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "检查更新" } } } }, + "Update.Checking" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Contacting GitHub…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在连接 GitHub…" } } } }, + "Update.UpToDate" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "You are up to date (GitHub: %@)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已经是最新版(GitHub:%@)" } } } }, + "Update.Available" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Version %@ is available. Review its release notes in the update window." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "发现新版本 %@,请在弹窗中查看更新说明。" } } } }, + "Update.Failed" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Unable to check for updates" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法检查更新" } } } }, + "Update.NetworkNote" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The first launch each day silently checks GitHub Releases once. An unavailable network never blocks or changes any local save operation, and you can retry manually at any time." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "每天首次启动会静默检查一次 GitHub Release;网络不可用时不会阻塞或影响任何本地存档操作。也可以随时手动重试。" } } } }, + "Update.AvailableTitle" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Update Available" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "发现新版本" } } } }, + "Update.VersionSummary" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current %@ · Latest %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "当前 %@ · 最新 %@" } } } }, + "Update.ReleaseNotes" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Release notes" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "更新说明" } } } }, + "Update.NoReleaseNotes" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No release notes were provided for this version." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "该版本未提供更新说明。" } } } }, + "Update.Later" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Later" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "稍后" } } } }, + "Update.OpenRelease" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Open Release Page" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "打开发布页面" } } } }, "WorkflowState.ComponentSelection" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Inspection complete" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已完成检查" } } } }, "WorkflowState.DryRun" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run complete" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run 已完成" } } } }, "WorkflowState.Failure" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Operation failed" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "操作失败" } } } }, diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/SettingsView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/SettingsView.swift index c98b395..96c145a 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/SettingsView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/SettingsView.swift @@ -4,6 +4,7 @@ import ConverterPresentation struct SettingsView: View { @Binding var localeOverride: String @Bindable var workflow: ConversionWorkflow + @ObservedObject var updateChecker: GitHubUpdateChecker let language: ConverterLanguage var body: some View { @@ -37,8 +38,65 @@ struct SettingsView: View { value: ConverterCopy.text(workflow.state.localizationKey, language: language) ) } + + Section(ConverterCopy.text("Update.About", language: language)) { + LabeledContent(ConverterCopy.text("Update.CurrentVersion", language: language)) { + Text(updateChecker.currentVersion) + .font(.caption.monospaced()) + } + + HStack(spacing: 10) { + Button(ConverterCopy.text("Update.Check", language: language)) { + Task { await updateChecker.checkManually() } + } + .disabled(updateChecker.isChecking) + + if updateChecker.isChecking { + ProgressView() + .controlSize(.small) + Text(ConverterCopy.text("Update.Checking", language: language)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + updateStatus + + Text(ConverterCopy.text("Update.NetworkNote", language: language)) + .font(.caption) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) } } + + @ViewBuilder + private var updateStatus: some View { + switch updateChecker.status { + case .idle, .checking: + EmptyView() + case let .upToDate(latest): + Label( + String(format: ConverterCopy.text("Update.UpToDate", language: language), latest), + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + case let .updateAvailable(latest): + Label( + String(format: ConverterCopy.text("Update.Available", language: language), latest), + systemImage: "arrow.down.circle.fill" + ) + .foregroundStyle(.tint) + case let .failed(detail): + VStack(alignment: .leading, spacing: 4) { + Label(ConverterCopy.text("Update.Failed", language: language), systemImage: "wifi.exclamationmark") + .foregroundStyle(.orange) + Text(detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } } diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/UpdateReleaseView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/UpdateReleaseView.swift new file mode 100644 index 0000000..e65439a --- /dev/null +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/UpdateReleaseView.swift @@ -0,0 +1,94 @@ +import AppKit +import ConverterPresentation +import SwiftUI + +struct UpdateReleaseView: View { + let release: GitHubConverterRelease + let currentVersion: String + let language: ConverterLanguage + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 14) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 34)) + .foregroundStyle(.tint) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(ConverterCopy.text("Update.AvailableTitle", language: language)) + .font(.title2.weight(.semibold)) + Text(releaseTitle) + .font(.headline) + Text( + String( + format: ConverterCopy.text("Update.VersionSummary", language: language), + currentVersion, + release.tagName + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + if let published = formattedPublishedDate { + Text(published) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Divider() + + Text(ConverterCopy.text("Update.ReleaseNotes", language: language)) + .font(.headline) + ScrollView { + Text(releaseNotes) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(minHeight: 150, maxHeight: 320) + + HStack { + Spacer() + Button(ConverterCopy.text("Update.Later", language: language)) { + dismiss() + } + Button(ConverterCopy.text("Update.OpenRelease", language: language)) { + NSWorkspace.shared.open(release.htmlURL) + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(minWidth: 540, idealWidth: 600, minHeight: 390) + } + + private var formattedPublishedDate: String? { + guard let value = release.publishedAt, + let date = ISO8601DateFormatter().date(from: value) + else { + return nil + } + return date.formatted(date: .long, time: .omitted) + } + + private var releaseTitle: String { + guard let name = release.name?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + else { + return release.tagName + } + return name + } + + private var releaseNotes: String { + guard let body = release.body?.trimmingCharacters(in: .whitespacesAndNewlines), + !body.isEmpty + else { + return ConverterCopy.text("Update.NoReleaseNotes", language: language) + } + return body + } +} diff --git a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift new file mode 100644 index 0000000..669506d --- /dev/null +++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift @@ -0,0 +1,62 @@ +import Foundation +import XCTest +@testable import ConverterPresentation + +final class UpdateCheckingTests: XCTestCase { + func testSemanticVersionAcceptsReleaseTagsAndComparesNumerically() throws { + let current = try XCTUnwrap(ConverterSemanticVersion("0.0.16")) + let latest = try XCTUnwrap(ConverterSemanticVersion("v0.0.17")) + let twoDigit = try XCTUnwrap(ConverterSemanticVersion("0.0.100")) + + XCTAssertLessThan(current, latest) + XCTAssertLessThan(latest, twoDigit) + XCTAssertEqual(ConverterSemanticVersion("1.2"), ConverterSemanticVersion("1.2.0")) + } + + func testMalformedVersionsFailClosed() { + XCTAssertNil(ConverterSemanticVersion("release-latest")) + XCTAssertNil(ConverterSemanticVersion("1..2")) + XCTAssertEqual( + ConverterUpdateDecision.decide(current: "development", latest: "v0.0.17"), + .invalidVersion + ) + } + + func testGitHubReleasePayloadDecodesExpectedFields() throws { + let payload = #"{"tag_name":"v0.0.17","name":"MH3G 0.0.17","body":"Fix notes","html_url":"https://github.com/MHToolkit/mh-save-sync/releases/tag/v0.0.17","published_at":"2026-08-10T00:00:00Z","draft":false,"prerelease":false}"#.data(using: .utf8)! + let release = try JSONDecoder().decode(GitHubConverterRelease.self, from: payload) + + XCTAssertEqual(release.tagName, "v0.0.17") + XCTAssertEqual(release.name, "MH3G 0.0.17") + XCTAssertEqual(release.body, "Fix notes") + XCTAssertFalse(release.draft) + XCTAssertFalse(release.prerelease) + XCTAssertTrue(release.isOfficialStableRelease) + } + + func testReleaseLinkMustBelongToTheOfficialRepository() throws { + let spoofed = GitHubConverterRelease( + tagName: "v9.9.9", + name: nil, + body: nil, + htmlURL: try XCTUnwrap(URL(string: "https://github.com.evil.example/MHToolkit/mh-save-sync/releases/tag/v9.9.9")), + publishedAt: nil, + draft: false, + prerelease: false + ) + + XCTAssertFalse(spoofed.isOfficialStableRelease) + } + + func testDailyGateRunsOnlyOncePerLocalCalendarDay() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 8 * 3600)) + let lastAttempt = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 8, day: 10, hour: 1))) + let sameDay = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 8, day: 10, hour: 23))) + let nextDay = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 8, day: 11, hour: 0))) + + XCTAssertFalse(DailyUpdateCheckGate.shouldCheck(lastAttempt: lastAttempt, now: sameDay, calendar: calendar)) + XCTAssertTrue(DailyUpdateCheckGate.shouldCheck(lastAttempt: lastAttempt, now: nextDay, calendar: calendar)) + XCTAssertTrue(DailyUpdateCheckGate.shouldCheck(lastAttempt: nil, now: sameDay, calendar: calendar)) + } +} diff --git a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs index 5b40212..3dd3a32 100644 --- a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs +++ b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs @@ -32,6 +32,21 @@ public void SetLanguage(AppLanguageOverride language) public string LanguageSystem => Text(nameof(LanguageSystem)); public string LanguageChinese => Text(nameof(LanguageChinese)); public string LanguageEnglish => Text(nameof(LanguageEnglish)); + public string AboutUpdates => Text(nameof(AboutUpdates)); + public string AboutDescription => Text(nameof(AboutDescription)); + public string CurrentVersion => Text(nameof(CurrentVersion)); + public string CheckForUpdates => Text(nameof(CheckForUpdates)); + public string CheckingForUpdates => Text(nameof(CheckingForUpdates)); + public string UpdateAvailableTitle => Text(nameof(UpdateAvailableTitle)); + public string UpdateAvailableSummary => Text(nameof(UpdateAvailableSummary)); + public string ReleaseNotes => Text(nameof(ReleaseNotes)); + public string NoReleaseNotes => Text(nameof(NoReleaseNotes)); + public string OpenReleasePage => Text(nameof(OpenReleasePage)); + public string UpToDateTitle => Text(nameof(UpToDateTitle)); + public string UpToDateMessage => Text(nameof(UpToDateMessage)); + public string UpdateCheckFailed => Text(nameof(UpdateCheckFailed)); + public string UpdateNetworkNote => Text(nameof(UpdateNetworkNote)); + public string Close => Text(nameof(Close)); public string StageInput => Text(nameof(StageInput)); public string StageInspect => Text(nameof(StageInspect)); public string StageDryRun => Text(nameof(StageDryRun)); @@ -183,6 +198,21 @@ private string Text(string key) [nameof(LanguageSystem)] = "System default", [nameof(LanguageChinese)] = "Simplified Chinese", [nameof(LanguageEnglish)] = "English", + [nameof(AboutUpdates)] = "About & Updates", + [nameof(AboutDescription)] = "MH3G Save Converter uses the bundled local Rust converter. Update checks read only the official MHToolkit GitHub Release metadata.", + [nameof(CurrentVersion)] = "Current version", + [nameof(CheckForUpdates)] = "Check for Updates", + [nameof(CheckingForUpdates)] = "Contacting GitHub…", + [nameof(UpdateAvailableTitle)] = "Update Available", + [nameof(UpdateAvailableSummary)] = "Current {0} · Latest {1}", + [nameof(ReleaseNotes)] = "Release notes", + [nameof(NoReleaseNotes)] = "No release notes were provided for this version.", + [nameof(OpenReleasePage)] = "Open Release Page", + [nameof(UpToDateTitle)] = "Up to date", + [nameof(UpToDateMessage)] = "Version {0} is the latest published release.", + [nameof(UpdateCheckFailed)] = "Unable to check for updates", + [nameof(UpdateNetworkNote)] = "The first launch each day checks GitHub Releases once. A blocked or unavailable network never blocks or changes local save operations; use this button to retry manually.", + [nameof(Close)] = "Close", [nameof(StageInput)] = "Input", [nameof(StageInspect)] = "Inspect", [nameof(StageDryRun)] = "Dry Run", @@ -336,6 +366,21 @@ private string Text(string key) [nameof(LanguageSystem)] = "跟随系统", [nameof(LanguageChinese)] = "简体中文", [nameof(LanguageEnglish)] = "English", + [nameof(AboutUpdates)] = "关于与更新", + [nameof(AboutDescription)] = "MH3G Save Converter 使用内置的本地 Rust 转换器。更新检查只读取 MHToolkit 官方 GitHub Release 元数据。", + [nameof(CurrentVersion)] = "当前版本", + [nameof(CheckForUpdates)] = "检查更新", + [nameof(CheckingForUpdates)] = "正在连接 GitHub…", + [nameof(UpdateAvailableTitle)] = "发现新版本", + [nameof(UpdateAvailableSummary)] = "当前 {0} · 最新 {1}", + [nameof(ReleaseNotes)] = "更新说明", + [nameof(NoReleaseNotes)] = "该版本未提供更新说明。", + [nameof(OpenReleasePage)] = "打开发布页面", + [nameof(UpToDateTitle)] = "已经是最新版", + [nameof(UpToDateMessage)] = "版本 {0} 是当前最新正式版。", + [nameof(UpdateCheckFailed)] = "无法检查更新", + [nameof(UpdateNetworkNote)] = "每天首次启动会检查一次 GitHub Release;网络受限或不可用时不会阻塞或改动任何本地存档,也可以在这里手动重试。", + [nameof(Close)] = "关闭", [nameof(StageInput)] = "输入", [nameof(StageInspect)] = "检查", [nameof(StageDryRun)] = "Dry Run", diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml b/apps/mh3g-save-converter-windows/MainWindow.xaml index 50ca378..a3ab643 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml @@ -6,7 +6,7 @@ xmlns:converter="using:MHToolkit.MH3GSaveConverter.Windows.Controls" xmlns:models="using:MHToolkit.MH3GSaveConverter.Windows.Models" Title="MH3G Save Converter"> - + @@ -34,6 +34,12 @@ AutomationProperties.LiveSetting="Polite" AutomationProperties.Name="{Binding StatusText}" /> + diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs index 35ecb43..93b1f70 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.UI.Composition.SystemBackdrops; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -5,15 +6,22 @@ using Windows.Graphics; using Windows.Storage.Pickers; using WinRT.Interop; +using MHToolkit.MH3GSaveConverter.Windows.Models; +using MHToolkit.MH3GSaveConverter.Windows.Services; using MHToolkit.MH3GSaveConverter.Windows.ViewModels; namespace MHToolkit.MH3GSaveConverter.Windows; public sealed partial class MainWindow : Window { + private readonly GitHubUpdateService _updateService = new(); + private readonly UpdateCheckPreferenceStore _updateCheckStore = new(); + private readonly SemaphoreSlim _dialogGate = new(1, 1); + private Task? _activeUpdateCheck; private bool _synchronizingLanguage; private bool _synchronizingConversionMode; private bool _synchronizingRepairVersion; + private bool _loadedOnce; public MainWindow() { @@ -28,6 +36,174 @@ public MainWindow() public MainViewModel ViewModel { get; } + private async void RootGrid_Loaded(object sender, RoutedEventArgs e) + { + if (_loadedOnce) + { + return; + } + _loadedOnce = true; + + if (!_updateCheckStore.ShouldCheckToday()) + { + return; + } + + // Record the attempt before networking so a blocked GitHub connection + // is not retried on every launch during the same local calendar day. + _updateCheckStore.MarkAttempt(); + await CheckForUpdatesAsync(manual: false); + } + + private async void About_Click(object sender, RoutedEventArgs e) + { + var content = new StackPanel { Spacing = 10, Width = 520 }; + content.Children.Add(new TextBlock + { + Text = ViewModel.Copy.AboutDescription, + TextWrapping = TextWrapping.Wrap, + }); + content.Children.Add(new TextBlock + { + Text = $"{ViewModel.Copy.CurrentVersion}: {_updateService.CurrentVersion}", + FontFamily = new FontFamily("Cascadia Mono"), + }); + content.Children.Add(new TextBlock + { + Text = ViewModel.Copy.UpdateNetworkNote, + TextWrapping = TextWrapping.Wrap, + Foreground = (Brush)Application.Current.Resources["ConverterSlateBrush"], + }); + + var dialog = new ContentDialog + { + Title = ViewModel.Copy.AboutUpdates, + Content = content, + PrimaryButtonText = ViewModel.Copy.CheckForUpdates, + CloseButtonText = ViewModel.Copy.Close, + DefaultButton = ContentDialogButton.Primary, + }; + if (await ShowDialogAsync(dialog) == ContentDialogResult.Primary) + { + await CheckForUpdatesAsync(manual: true); + } + } + + private async Task CheckForUpdatesAsync(bool manual) + { + try + { + var activeCheck = _activeUpdateCheck; + if (activeCheck is null || activeCheck.IsCompleted) + { + activeCheck = _updateService.CheckAsync(); + _activeUpdateCheck = activeCheck; + } + var result = await activeCheck; + if (result.IsUpdateAvailable) + { + await ShowUpdateAvailableAsync(result); + } + else if (manual) + { + await ShowMessageAsync( + ViewModel.Copy.UpToDateTitle, + string.Format(ViewModel.Copy.UpToDateMessage, result.Release.TagName)); + } + } + catch (Exception exception) + { + // Automatic checks are advisory and silent. They never delay the + // local workflow beyond their own timeout or surface a startup + // blocker when GitHub is unavailable in the user's network. + if (manual) + { + await ShowMessageAsync( + ViewModel.Copy.UpdateCheckFailed, + $"{ViewModel.Copy.UpdateNetworkNote}\n\n{exception.Message}"); + } + } + } + + private async Task ShowUpdateAvailableAsync(UpdateCheckResult result) + { + var release = result.Release; + var content = new StackPanel { Spacing = 10, Width = 560 }; + content.Children.Add(new TextBlock + { + Text = string.Format( + ViewModel.Copy.UpdateAvailableSummary, + result.CurrentVersion, + release.TagName), + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); + if (release.PublishedAt is { } publishedAt) + { + content.Children.Add(new TextBlock + { + Text = publishedAt.ToLocalTime().ToString("D"), + Foreground = (Brush)Application.Current.Resources["ConverterSlateBrush"], + }); + } + content.Children.Add(new TextBlock + { + Text = ViewModel.Copy.ReleaseNotes, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); + content.Children.Add(new ScrollViewer + { + MaxHeight = 320, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = new TextBlock + { + Text = string.IsNullOrWhiteSpace(release.Body) ? ViewModel.Copy.NoReleaseNotes : release.Body, + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }, + }); + + var dialog = new ContentDialog + { + Title = string.IsNullOrWhiteSpace(release.Name) + ? ViewModel.Copy.UpdateAvailableTitle + : release.Name, + Content = content, + PrimaryButtonText = ViewModel.Copy.OpenReleasePage, + CloseButtonText = ViewModel.Copy.Close, + DefaultButton = ContentDialogButton.Primary, + }; + if (await ShowDialogAsync(dialog) == ContentDialogResult.Primary) + { + Process.Start(new ProcessStartInfo(release.HtmlUrl) { UseShellExecute = true }); + } + } + + private async Task ShowMessageAsync(string title, string message) + { + var dialog = new ContentDialog + { + Title = title, + Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, + CloseButtonText = ViewModel.Copy.Close, + DefaultButton = ContentDialogButton.Close, + }; + await ShowDialogAsync(dialog); + } + + private async Task ShowDialogAsync(ContentDialog dialog) + { + await _dialogGate.WaitAsync(); + try + { + dialog.XamlRoot = RootGrid.XamlRoot; + return await dialog.ShowAsync(); + } + finally + { + _dialogGate.Release(); + } + } + private void ConfigureWindowMaterial() { AppWindow.Resize(new SizeInt32(1240, 900)); @@ -468,14 +644,13 @@ private async Task ConfirmAsync(string title, string message) { var dialog = new ContentDialog { - XamlRoot = RootGrid.XamlRoot, Title = title, Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, PrimaryButtonText = ViewModel.Copy.Continue, CloseButtonText = ViewModel.Copy.Cancel, DefaultButton = ContentDialogButton.Close, }; - return await dialog.ShowAsync() == ContentDialogResult.Primary; + return await ShowDialogAsync(dialog) == ContentDialogResult.Primary; } private async Task RunSafelyAsync(Func operation) diff --git a/apps/mh3g-save-converter-windows/Models/UpdateModels.cs b/apps/mh3g-save-converter-windows/Models/UpdateModels.cs new file mode 100644 index 0000000..2e3ac0f --- /dev/null +++ b/apps/mh3g-save-converter-windows/Models/UpdateModels.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace MHToolkit.MH3GSaveConverter.Windows.Models; + +public sealed class GitHubReleaseInfo +{ + [JsonPropertyName("tag_name")] + public string TagName { get; init; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("body")] + public string? Body { get; init; } + + [JsonPropertyName("html_url")] + public string HtmlUrl { get; init; } = string.Empty; + + [JsonPropertyName("published_at")] + public DateTimeOffset? PublishedAt { get; init; } + + [JsonPropertyName("draft")] + public bool Draft { get; init; } + + [JsonPropertyName("prerelease")] + public bool Prerelease { get; init; } +} + +public sealed record UpdateCheckResult( + string CurrentVersion, + GitHubReleaseInfo Release, + bool IsUpdateAvailable); diff --git a/apps/mh3g-save-converter-windows/README.md b/apps/mh3g-save-converter-windows/README.md index e70ed71..5330160 100644 --- a/apps/mh3g-save-converter-windows/README.md +++ b/apps/mh3g-save-converter-windows/README.md @@ -30,7 +30,10 @@ code. Dry Run's aggregate source_record_set_sha256 and target_sha256_before are bound to its write, so a changed mailbox or cache fails closed. It never turns on merely because a player selected the primary slot. -- `system` and normal ExtData staging/install remain explicit transactions. +- Shared `system` and normal ExtData staging/install remain explicit + transactions. Gallery/movie migration requires both the 3DS source `system` + and an existing initialized Cemu `system`; only the known flags are unioned, + while all other target bytes and other-slot shared data are retained. The Windows backend installs complete ExtData groups through `ReplaceFileW`, manifest-bound backups, and a durable recovery journal. The UI never guesses a Cemu MLC directory or silently installs a group. Compatibility repair @@ -42,6 +45,19 @@ Quit Nemessix, Azahar, and Cemu before any write or rollback. See the root [Chinese CLI contract](../../docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md) for the exact source files and transaction scope. +## Updates + +The About & Updates dialog can query the latest stable release from the +official `MHToolkit/mh-save-sync` GitHub Release API. The first launch on each +local calendar day makes at most one silent attempt. A blocked or unavailable +GitHub connection never blocks the window or changes a local save; manual +checks display the failure and can be retried. A newer release dialog includes +the release title, publication date, notes, and official link. + +The package script passes the Rust converter version into both WinUI publish +forms, so the folder, portable EXE, and installer compare the same real version +instead of the .NET default assembly version. + ## Release formats The one-command package build produces three Windows x64 formats from the same diff --git a/apps/mh3g-save-converter-windows/README.zh-CN.md b/apps/mh3g-save-converter-windows/README.zh-CN.md index 7a5a34f..d999f5f 100644 --- a/apps/mh3g-save-converter-windows/README.zh-CN.md +++ b/apps/mh3g-save-converter-windows/README.zh-CN.md @@ -26,7 +26,9 @@ 回滚链路。写入会绑定紧接着的 Dry Run 返回的聚合 source_record_set_sha256 与 target_sha256_before,邮箱或缓存变化时会 失败关闭。选择核心槽位不会自动打开 CEC。 -- `system` 与普通 ExtData 暂存/安装仍是显式事务。Windows 后端使用 +- 共享 `system` 与普通 ExtData 暂存/安装仍是显式事务。画廊/动画迁移必须同时 + 提供 3DS 源 `system` 和已存在、已初始化的 Cemu `system`;只合并已知标记, + 其余目标字节和其他角色槽位共享数据全部保留。Windows 后端使用 `ReplaceFileW`、manifest 绑定备份和持久恢复日志安装完整 ExtData 组件组; 界面不会猜测 Cemu MLC 目录或静默安装。兼容修复模式只字段级更新仍保持旧版 转换结果的 `user#` 与公会名片字段,当前 `quest1` 至 `quest4` 保持原字节。 @@ -36,6 +38,16 @@ [中文 CLI 文件契约](../../docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md) 与 [English CLI contract](../../docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md)。 +## 更新检查 + +“关于与更新”可以查询 `MHToolkit/mh-save-sync` 官方 GitHub Release API 的最新 +正式版。每个本地自然日首次启动最多静默尝试一次;GitHub 无法访问时不会阻塞 +窗口或改动本地存档,手动检查会展示失败信息并允许重试。发现新版本时,弹窗会 +显示版本名、发布日期、更新说明与官方链接。 + +打包脚本会把 Rust 转换器版本同时注入 WinUI 文件夹版和单文件便携版,因此 ZIP、 +便携 EXE 和安装器比较的是同一个真实版本,不会误用 .NET 默认程序集版本。 + ## 发布格式 一键打包会使用同一份原生 WinUI 应用与 Rust sidecar 生成三种 Windows x64 形式: diff --git a/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs b/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs new file mode 100644 index 0000000..2ac2b76 --- /dev/null +++ b/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs @@ -0,0 +1,96 @@ +using System.Net.Http.Headers; +using System.Reflection; +using System.Text.Json; +using MHToolkit.MH3GSaveConverter.Windows.Models; + +namespace MHToolkit.MH3GSaveConverter.Windows.Services; + +public sealed class GitHubUpdateService +{ + private static readonly Uri LatestReleaseEndpoint = new( + "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest"); + + private readonly HttpClient _httpClient; + + public GitHubUpdateService(HttpClient? httpClient = null, string? currentVersion = null) + { + _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(8) }; + CurrentVersion = currentVersion ?? ResolveCurrentVersion(); + } + + public string CurrentVersion { get; } + + public async Task CheckAsync(CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, LatestReleaseEndpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + request.Headers.UserAgent.ParseAdd($"MH3GSaveConverter/{SafeUserAgentVersion(CurrentVersion)}"); + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength is > 2 * 1024 * 1024) + { + throw new InvalidDataException("GitHub Release API response exceeded the accepted size."); + } + + var payload = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + if (payload.Length > 2 * 1024 * 1024) + { + throw new InvalidDataException("GitHub Release API response exceeded the accepted size."); + } + + var release = JsonSerializer.Deserialize(payload) + ?? throw new InvalidDataException("GitHub Release API returned an empty payload."); + ValidateRelease(release); + + if (!TryParseVersion(CurrentVersion, out var current) + || !TryParseVersion(release.TagName, out var latest)) + { + throw new InvalidDataException("The current or latest release version could not be compared safely."); + } + + return new UpdateCheckResult(CurrentVersion, release, latest > current); + } + + internal static bool TryParseVersion(string value, out Version version) + { + var normalized = value.Trim().TrimStart('v', 'V').Split('+', 2)[0].Split('-', 2)[0]; + if (Version.TryParse(normalized, out var parsed)) + { + version = parsed; + return true; + } + version = new Version(0, 0); + return false; + } + + private static void ValidateRelease(GitHubReleaseInfo release) + { + if (release.Draft + || release.Prerelease + || !Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releaseUri) + || !releaseUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !releaseUri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase) + || !releaseUri.AbsolutePath.StartsWith("/MHToolkit/mh-save-sync/releases/", StringComparison.Ordinal)) + { + throw new InvalidDataException("GitHub Release metadata did not match the official MHToolkit repository."); + } + } + + private static string ResolveCurrentVersion() + { + var assembly = Assembly.GetEntryAssembly() ?? typeof(GitHubUpdateService).Assembly; + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informational)) + { + return informational.Split('+', 2)[0]; + } + return assembly.GetName().Version?.ToString(3) ?? "development"; + } + + private static string SafeUserAgentVersion(string version) + { + return TryParseVersion(version, out var parsed) ? parsed.ToString() : "0.0.0"; + } +} diff --git a/apps/mh3g-save-converter-windows/Services/UpdateCheckPreferenceStore.cs b/apps/mh3g-save-converter-windows/Services/UpdateCheckPreferenceStore.cs new file mode 100644 index 0000000..93202b3 --- /dev/null +++ b/apps/mh3g-save-converter-windows/Services/UpdateCheckPreferenceStore.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using System.Text.Json; + +namespace MHToolkit.MH3GSaveConverter.Windows.Services; + +public sealed class UpdateCheckPreferenceStore +{ + private const string StateFileName = "update-check.json"; + private readonly string _statePath; + private bool _attemptedInProcess; + + public UpdateCheckPreferenceStore(string? settingsDirectory = null) + { + var directory = settingsDirectory + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MHToolkit", + "MH3GSaveConverter"); + _statePath = Path.Combine(directory, StateFileName); + } + + public bool ShouldCheckToday(DateTimeOffset? now = null) + { + if (_attemptedInProcess) + { + return false; + } + + var localDate = (now ?? DateTimeOffset.Now).LocalDateTime.Date; + try + { + if (!File.Exists(_statePath)) + { + return true; + } + var stored = JsonSerializer.Deserialize(File.ReadAllText(_statePath)); + return !DateTime.TryParseExact( + stored?.LastAttemptLocalDate, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var lastAttempt) + || lastAttempt.Date != localDate; + } + catch (IOException) + { + return true; + } + catch (UnauthorizedAccessException) + { + return true; + } + catch (JsonException) + { + return true; + } + } + + public void MarkAttempt(DateTimeOffset? now = null) + { + _attemptedInProcess = true; + var directory = Path.GetDirectoryName(_statePath)!; + var temporaryPath = Path.Combine(directory, $".{StateFileName}.{Guid.NewGuid():N}.tmp"); + try + { + Directory.CreateDirectory(directory); + var state = new StoredState + { + LastAttemptLocalDate = (now ?? DateTimeOffset.Now).LocalDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + }; + File.WriteAllBytes(temporaryPath, JsonSerializer.SerializeToUtf8Bytes(state)); + File.Move(temporaryPath, _statePath, overwrite: true); + } + catch (IOException) + { + // The in-process gate still prevents repeated launch checks. A + // read-only profile may retry on the next application launch. + } + catch (UnauthorizedAccessException) + { + // Update checks are advisory and never block the converter. + } + finally + { + try + { + File.Delete(temporaryPath); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + + private sealed class StoredState + { + public string? LastAttemptLocalDate { get; init; } + } +} diff --git a/scripts/package-mh3g-save-converter-windows.ps1 b/scripts/package-mh3g-save-converter-windows.ps1 index 94e0943..af8d1a7 100644 --- a/scripts/package-mh3g-save-converter-windows.ps1 +++ b/scripts/package-mh3g-save-converter-windows.ps1 @@ -882,7 +882,8 @@ function Publish-PortableExecutable { [Parameter(Mandatory = $true)][string]$ProjectPath, [Parameter(Mandatory = $true)][string]$NativeSidecar, [Parameter(Mandatory = $true)][string]$PortableStageDirectory, - [Parameter(Mandatory = $true)][string]$PortableOutput + [Parameter(Mandatory = $true)][string]$PortableOutput, + [Parameter(Mandatory = $true)][string]$Version ) # The UI invokes the Rust converter from AppContext.BaseDirectory\tools. @@ -908,7 +909,7 @@ function Publish-PortableExecutable { Invoke-External -FilePath $Dotnet -Arguments @( "publish", $ProjectPath, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "--no-restore", "-p:Platform=x64", "-p:WindowsAppSDKSelfContained=true", "-p:PublishSingleFile=true", - "-p:IncludeAllContentForSelfExtract=true", "-p:PublishReadyToRun=false", "-o", $PortableStageDirectory + "-p:IncludeAllContentForSelfExtract=true", "-p:PublishReadyToRun=false", "-p:Version=$Version", "-o", $PortableStageDirectory ) -Description "WinUI portable single-file dotnet publish" $publishedPortable = Join-Path $PortableStageDirectory "MH3GSaveConverter.exe" @@ -1173,6 +1174,7 @@ try { } Assert-NativeConverterSidecar -FilePath $sidecar Invoke-External -FilePath $sidecar -Arguments @("--help") -Description "release sidecar smoke" + $converterVersion = Get-ConverterVersion # Recheck directly before recursive deletion in case an existing stage # component was replaced by a junction/symlink after initial preflight. @@ -1180,7 +1182,7 @@ try { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue Invoke-External -FilePath $dotnet -Arguments @( "publish", $project, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "--no-restore", - "-p:Platform=x64", "-p:WindowsAppSDKSelfContained=true", "-o", $stage + "-p:Platform=x64", "-p:WindowsAppSDKSelfContained=true", "-p:Version=$converterVersion", "-o", $stage ) -Description "WinUI dotnet publish" $gui = Join-Path $stage "MH3GSaveConverter.exe" @@ -1212,10 +1214,9 @@ try { # that sidecar as extracted app content; the installer wraps the folder # form so its tools\ path remains intact. Remove-Item -LiteralPath $portableExecutable, $portableChecksum, $installerExecutable, $installerChecksum -Force -ErrorAction SilentlyContinue - Publish-PortableExecutable -Dotnet $dotnet -ProjectPath $project -NativeSidecar $sidecar -PortableStageDirectory $portableStage -PortableOutput $portableExecutable + Publish-PortableExecutable -Dotnet $dotnet -ProjectPath $project -NativeSidecar $sidecar -PortableStageDirectory $portableStage -PortableOutput $portableExecutable -Version $converterVersion Write-Sha256File -FilePath $portableExecutable -OutputPath $portableChecksum -DisplayName "MH3GSaveConverter-Portable-x64.exe" | Out-Null - $converterVersion = Get-ConverterVersion Build-InstallerExecutable -InstallerCompiler $innoSetup -InstallerDefinition $installerScript -SourceDirectory $stage -Version $converterVersion -InstallerOutput $installerExecutable Write-Sha256File -FilePath $installerExecutable -OutputPath $installerChecksum -DisplayName "MH3GSaveConverter-Setup-x64.exe" | Out-Null diff --git a/scripts/verify-mh3g-save-converter-windows-source.py b/scripts/verify-mh3g-save-converter-windows-source.py index f0402d4..06d2b6b 100644 --- a/scripts/verify-mh3g-save-converter-windows-source.py +++ b/scripts/verify-mh3g-save-converter-windows-source.py @@ -147,6 +147,7 @@ def verify_local_packaging_script() -> None: "WindowsAppSDKSelfContained=true", "PublishSingleFile=true", "IncludeAllContentForSelfExtract=true", + "-p:Version=$converterVersion", "Publish-PortableExecutable", "Build-InstallerExecutable", "MH3GSaveConverter-Portable-x64.exe", @@ -228,6 +229,7 @@ def verify_local_packaging_script() -> None: for expected in ( "-p:PublishSingleFile=true", "-p:IncludeAllContentForSelfExtract=true", + "-p:Version=$Version", "mh3g-save-convert.exe", "finally", "Remove-Item -LiteralPath $embeddedSidecar", @@ -414,6 +416,27 @@ def main() -> int: require("startInfo.Arguments" not in bridge, "CLI bridge must not build a command-string argument list") require("cmd.exe" not in bridge and "powershell" not in bridge.lower(), "CLI bridge must not invoke a shell") + update_service = read("Services/GitHubUpdateService.cs") + for expected in ( + "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest", + "application/vnd.github+json", + "X-GitHub-Api-Version", + "MH3GSaveConverter/", + "TimeSpan.FromSeconds(8)", + "MHToolkit/mh-save-sync/releases/", + "AssemblyInformationalVersionAttribute", + ): + require(expected in update_service, f"Windows update service is missing {expected}") + update_store = read("Services/UpdateCheckPreferenceStore.cs") + for expected in ( + "update-check.json", + "ShouldCheckToday", + "MarkAttempt", + '"yyyy-MM-dd"', + "LocalApplicationData", + ): + require(expected in update_store, f"Windows daily update gate is missing {expected}") + launcher = (ROOT / "scripts" / "mh3g-windows-launcher.ps1").read_text(encoding="utf-8") require( 'Join-Path $PSScriptRoot "tools/mh3g-save-convert.exe"' in launcher, @@ -578,6 +601,16 @@ def main() -> int: require("private void GoToOptionalConfiguration_Click" in code_behind, "optional configuration CTA handler is missing") require("OptionalConfigurationAnchor.StartBringIntoView();" in code_behind, "optional configuration CTA must scroll to its controls") require("private void GoToPostWriteDestination_Click" in code_behind, "post-write destination handler is missing") + for expected in ( + "RootGrid_Loaded", + "About_Click", + "CheckForUpdatesAsync(manual: false)", + "_updateCheckStore.MarkAttempt();", + "ShowUpdateAvailableAsync", + "ProcessStartInfo(release.HtmlUrl)", + "UseShellExecute = true", + ): + require(expected in code_behind, f"WinUI update-check flow is missing {expected}") cec_write = workflow.split("public async Task WriteCecAsync()", 1)[1].split( "public async Task RollbackCecAsync()", 1 @@ -792,6 +825,11 @@ def main() -> int: "Optional ExtData", "可选 ExtData", "ExtDataInstallUnavailable", + "About & Updates", + "关于与更新", + "Check for Updates", + "检查更新", + "UpdateNetworkNote", ): require(expected in copy, f"localized copy is missing {expected}") require((APP / "README.zh-CN.md").is_file(), "Windows shell must include Chinese usage guidance") From 7984dafe6bd9b78ccadb097e1897bdde241c166f Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Mon, 10 Aug 2026 16:59:02 +0800 Subject: [PATCH 2/2] fix(converter): avoid GitHub API rate-limit failures --- apps/mh3g-save-converter-macos/README.md | 14 +- .../mh3g-save-converter-macos/README.zh-CN.md | 9 +- .../UpdateChecking.swift | 147 +++++++++++++ .../GitHubUpdateChecker.swift | 105 +++++++-- .../UpdateCheckingTests.swift | 52 +++++ apps/mh3g-save-converter-windows/README.md | 14 +- .../README.zh-CN.md | 9 +- .../Services/GitHubUpdateService.cs | 203 +++++++++++++++++- ...rify-mh3g-save-converter-windows-source.py | 14 ++ 9 files changed, 519 insertions(+), 48 deletions(-) diff --git a/apps/mh3g-save-converter-macos/README.md b/apps/mh3g-save-converter-macos/README.md index 3fde396..7871cd5 100644 --- a/apps/mh3g-save-converter-macos/README.md +++ b/apps/mh3g-save-converter-macos/README.md @@ -39,12 +39,14 @@ by the other character slots. ## Updates -The About & Updates section can query the latest stable release from the -official `MHToolkit/mh-save-sync` GitHub Release API. The first launch on each -local calendar day makes at most one silent attempt. A blocked or unavailable -GitHub connection never blocks the window or any local conversion; manual -checks show the error and may be retried. When a newer release exists, the app -shows its title, publication date, release notes, and official release link. +The About & Updates section resolves the latest stable tag through the official +`MHToolkit/mh-save-sync` GitHub release page and reads its official Atom release +feed. This path does not consume the shared anonymous GitHub API quota; the +Release API remains a fallback. The first launch on each local calendar day +makes at most one silent attempt. A blocked or unavailable GitHub connection +never blocks the window or any local conversion; manual checks show the error +and may be retried. When a newer release exists, the app shows its title, +publication date, release notes, and official release link. ## Local verification diff --git a/apps/mh3g-save-converter-macos/README.zh-CN.md b/apps/mh3g-save-converter-macos/README.zh-CN.md index 7f93f72..2b5da83 100644 --- a/apps/mh3g-save-converter-macos/README.zh-CN.md +++ b/apps/mh3g-save-converter-macos/README.zh-CN.md @@ -30,10 +30,11 @@ sidecar 并展示 JSON 报告,不在 Swift 中重复实现存档转换。 ## 更新检查 -“关于与更新”可以查询 `MHToolkit/mh-save-sync` 官方 GitHub Release API 的最新 -正式版。每个本地自然日首次启动最多静默尝试一次;GitHub 无法访问时不会阻塞 -窗口,也不会影响任何本地转换。手动检查会展示错误并允许重试;发现新版本时会 -显示版本名、发布日期、Release 说明和官方发布链接。 +“关于与更新”会通过 `MHToolkit/mh-save-sync` 官方 GitHub Release 页面确认最新 +正式版 tag,并读取官方 Atom Release feed。该主路径不消耗共享的 GitHub 匿名 +API 配额,Release API 仅作为备用。每个本地自然日首次启动最多静默尝试一次; +GitHub 无法访问时不会阻塞窗口,也不会影响任何本地转换。手动检查会展示错误并 +允许重试;发现新版本时会显示版本名、发布日期、Release 说明和官方发布链接。 ## 本地验证 diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift index b11e196..82ab9db 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift @@ -103,6 +103,148 @@ public enum ConverterUpdateDecision: Equatable, Sendable { } } +public enum GitHubReleaseAtomFeed { + public static func stableRelease(from data: Data, expectedReleaseURL: URL) throws -> GitHubConverterRelease { + guard isOfficialTagURL(expectedReleaseURL), + let tagName = expectedReleaseURL.lastPathComponent.removingPercentEncoding, + ConverterSemanticVersion(tagName) != nil + else { + throw GitHubReleaseAtomFeedError.invalidReleaseURL + } + + let delegate = GitHubReleaseAtomParser() + let parser = XMLParser(data: data) + parser.delegate = delegate + guard parser.parse(), parser.parserError == nil else { + throw GitHubReleaseAtomFeedError.invalidXML + } + guard let entry = delegate.entries.first(where: { + $0.link?.absoluteString == expectedReleaseURL.absoluteString + }) else { + throw GitHubReleaseAtomFeedError.releaseNotFound + } + + let release = GitHubConverterRelease( + tagName: tagName, + name: entry.title.nilIfBlank, + body: plainText(fromHTML: entry.content).nilIfBlank, + htmlURL: expectedReleaseURL, + publishedAt: entry.updated.nilIfBlank, + draft: false, + prerelease: false + ) + guard release.isOfficialStableRelease else { + throw GitHubReleaseAtomFeedError.invalidReleaseURL + } + return release + } + + public static func isOfficialTagURL(_ url: URL) -> Bool { + url.scheme == "https" + && url.host?.lowercased() == "github.com" + && url.query == nil + && url.fragment == nil + && url.path.hasPrefix("/MHToolkit/mh-save-sync/releases/tag/") + && url.pathComponents.count == 6 + } + + private static func plainText(fromHTML html: String) -> String { + var text = html.replacingOccurrences( + of: #"(?i)]*)?>"#, + with: "• ", + options: .regularExpression + ) + text = text.replacingOccurrences( + of: #"(?i)|||||"#, + with: "\n", + options: .regularExpression + ) + text = text.replacingOccurrences(of: #"<[^>]+>"#, with: "", options: .regularExpression) + return text + .split(whereSeparator: \Character.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + .prefix(32_000) + .description + } +} + +private enum GitHubReleaseAtomFeedError: Error { + case invalidReleaseURL + case invalidXML + case releaseNotFound +} + +private final class GitHubReleaseAtomParser: NSObject, XMLParserDelegate { + struct Entry { + var title = "" + var updated = "" + var content = "" + var link: URL? + } + + private(set) var entries: [Entry] = [] + private var currentEntry: Entry? + private var capturedElement: String? + private var characters = "" + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + if elementName == "entry" { + currentEntry = Entry() + return + } + guard currentEntry != nil else { return } + if elementName == "link", + attributeDict["rel"] == "alternate", + let href = attributeDict["href"], + let url = URL(string: href) + { + currentEntry?.link = url + } else if elementName == "title" || elementName == "updated" || elementName == "content" { + capturedElement = elementName + characters = "" + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + guard capturedElement != nil else { return } + characters += string + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + if elementName == capturedElement { + switch elementName { + case "title": + currentEntry?.title = characters.trimmingCharacters(in: .whitespacesAndNewlines) + case "updated": + currentEntry?.updated = characters.trimmingCharacters(in: .whitespacesAndNewlines) + case "content": + currentEntry?.content = characters + default: + break + } + capturedElement = nil + characters = "" + } + if elementName == "entry", let currentEntry { + entries.append(currentEntry) + self.currentEntry = nil + } + } +} + public enum DailyUpdateCheckGate { public static func shouldCheck( lastAttempt: Date?, @@ -115,6 +257,11 @@ public enum DailyUpdateCheckGate { } private extension String { + var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + func trimmingPrefix(_ prefix: Character) -> String { first == prefix ? String(dropFirst()) : self } diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift index 6590a9e..253078f 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift @@ -12,6 +12,8 @@ enum UpdateCheckStatus: Equatable { @MainActor final class GitHubUpdateChecker: ObservableObject { static let releaseEndpoint = URL(string: "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest")! + static let releaseWebEndpoint = URL(string: "https://github.com/MHToolkit/mh-save-sync/releases/latest")! + static let releaseAtomEndpoint = URL(string: "https://github.com/MHToolkit/mh-save-sync/releases.atom")! static let lastAttemptDefaultsKey = "MH3GSaveConverter.LastUpdateCheckAttempt" @Published private(set) var status: UpdateCheckStatus = .idle @@ -68,21 +70,7 @@ final class GitHubUpdateChecker: ObservableObject { private func performCheck(manual: Bool) async { status = .checking do { - var request = URLRequest(url: Self.releaseEndpoint) - request.timeoutInterval = 8 - request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") - request.setValue("MH3GSaveConverter/\(safeUserAgentVersion)", forHTTPHeaderField: "User-Agent") - - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - throw UpdateCheckError.invalidResponse - } - guard data.count <= 2 * 1_024 * 1_024 else { - throw UpdateCheckError.responseTooLarge - } - - let release = try JSONDecoder().decode(GitHubConverterRelease.self, from: data) + let release = try await fetchLatestRelease() try validate(release: release) switch ConverterUpdateDecision.decide(current: currentVersion, latest: release.tagName) { case .updateAvailable: @@ -102,6 +90,78 @@ final class GitHubUpdateChecker: ObservableObject { } } + private func fetchLatestRelease() async throws -> GitHubConverterRelease { + do { + return try await fetchLatestReleaseFromWebFeed() + } catch { + let webFailure = error.localizedDescription + do { + return try await fetchLatestReleaseFromAPI() + } catch { + throw UpdateCheckError.allSourcesUnavailable( + web: webFailure, + api: error.localizedDescription + ) + } + } + } + + private func fetchLatestReleaseFromWebFeed() async throws -> GitHubConverterRelease { + var latestRequest = configuredRequest(url: Self.releaseWebEndpoint, accept: "text/html") + // One byte is enough: URLSession follows GitHub's /releases/latest + // redirect, and the final response URL carries the stable tag. This + // avoids downloading the full release page. + latestRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range") + let (redirectProbe, latestResponse) = try await session.data(for: latestRequest) + guard let latestHTTP = latestResponse as? HTTPURLResponse, + latestHTTP.statusCode == 200 || latestHTTP.statusCode == 206, + redirectProbe.count <= 2 * 1_024 * 1_024, + let stableReleaseURL = latestHTTP.url, + GitHubReleaseAtomFeed.isOfficialTagURL(stableReleaseURL) + else { + throw UpdateCheckError.invalidResponse(source: "GitHub release page") + } + + let atomRequest = configuredRequest(url: Self.releaseAtomEndpoint, accept: "application/atom+xml") + let (atomData, atomResponse) = try await session.data(for: atomRequest) + guard let atomHTTP = atomResponse as? HTTPURLResponse, atomHTTP.statusCode == 200 else { + throw UpdateCheckError.invalidResponse(source: "GitHub release feed") + } + guard atomData.count <= 2 * 1_024 * 1_024 else { + throw UpdateCheckError.responseTooLarge(source: "GitHub release feed") + } + return try GitHubReleaseAtomFeed.stableRelease( + from: atomData, + expectedReleaseURL: stableReleaseURL + ) + } + + private func fetchLatestReleaseFromAPI() async throws -> GitHubConverterRelease { + var request = configuredRequest(url: Self.releaseEndpoint, accept: "application/vnd.github+json") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let status = (response as? HTTPURLResponse)?.statusCode + throw UpdateCheckError.invalidResponse( + source: status.map { "GitHub Release API (HTTP \($0))" } ?? "GitHub Release API" + ) + } + guard data.count <= 2 * 1_024 * 1_024 else { + throw UpdateCheckError.responseTooLarge(source: "GitHub Release API") + } + let release = try JSONDecoder().decode(GitHubConverterRelease.self, from: data) + try validate(release: release) + return release + } + + private func configuredRequest(url: URL, accept: String) -> URLRequest { + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.setValue(accept, forHTTPHeaderField: "Accept") + request.setValue("MH3GSaveConverter/\(safeUserAgentVersion)", forHTTPHeaderField: "User-Agent") + return request + } + private func validate(release: GitHubConverterRelease) throws { guard release.isOfficialStableRelease else { throw UpdateCheckError.invalidRelease @@ -118,21 +178,24 @@ final class GitHubUpdateChecker: ObservableObject { } private enum UpdateCheckError: LocalizedError { - case invalidResponse - case responseTooLarge + case invalidResponse(source: String) + case responseTooLarge(source: String) case invalidRelease case invalidVersion + case allSourcesUnavailable(web: String, api: String) var errorDescription: String? { switch self { - case .invalidResponse: - "GitHub Release API returned an unexpected response." - case .responseTooLarge: - "GitHub Release API response exceeded the accepted size." + case let .invalidResponse(source): + "\(source) returned an unexpected response." + case let .responseTooLarge(source): + "\(source) response exceeded the accepted size." case .invalidRelease: "GitHub Release metadata did not match the official MHToolkit repository." case .invalidVersion: "The current or latest release version could not be compared safely." + case let .allSourcesUnavailable(web, api): + "GitHub release page/feed and API were unavailable. Web: \(web) API: \(api)" } } } diff --git a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift index 669506d..92a7504 100644 --- a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift +++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift @@ -48,6 +48,58 @@ final class UpdateCheckingTests: XCTestCase { XCTAssertFalse(spoofed.isOfficialStableRelease) } + func testAtomFeedConvertsTheExpectedStableReleaseAndPlainTextNotes() throws { + let feed = #""" + + + + 2026-08-10T00:00:00Z + + MH3G 0.0.17 + <h2>Fixes</h2><ul><li>Rate-limit fallback</li></ul> + + + """#.data(using: .utf8)! + let expectedURL = try XCTUnwrap( + URL(string: "https://github.com/MHToolkit/mh-save-sync/releases/tag/v0.0.17") + ) + + let release = try GitHubReleaseAtomFeed.stableRelease( + from: feed, + expectedReleaseURL: expectedURL + ) + + XCTAssertEqual(release.tagName, "v0.0.17") + XCTAssertEqual(release.name, "MH3G 0.0.17") + XCTAssertEqual(release.body, "Fixes\n• Rate-limit fallback") + XCTAssertEqual(release.publishedAt, "2026-08-10T00:00:00Z") + XCTAssertTrue(release.isOfficialStableRelease) + } + + func testAtomFeedRequiresTheExactOfficialTagURLAndMatchingEntry() throws { + let feed = #""" + + + + v0.0.17 + + + """#.data(using: .utf8)! + let spoofed = try XCTUnwrap( + URL(string: "https://github.com.evil.example/MHToolkit/mh-save-sync/releases/tag/v0.0.17") + ) + let missing = try XCTUnwrap( + URL(string: "https://github.com/MHToolkit/mh-save-sync/releases/tag/v0.0.18") + ) + + XCTAssertThrowsError( + try GitHubReleaseAtomFeed.stableRelease(from: feed, expectedReleaseURL: spoofed) + ) + XCTAssertThrowsError( + try GitHubReleaseAtomFeed.stableRelease(from: feed, expectedReleaseURL: missing) + ) + } + func testDailyGateRunsOnlyOncePerLocalCalendarDay() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 8 * 3600)) diff --git a/apps/mh3g-save-converter-windows/README.md b/apps/mh3g-save-converter-windows/README.md index 5330160..ccd89ca 100644 --- a/apps/mh3g-save-converter-windows/README.md +++ b/apps/mh3g-save-converter-windows/README.md @@ -47,12 +47,14 @@ the exact source files and transaction scope. ## Updates -The About & Updates dialog can query the latest stable release from the -official `MHToolkit/mh-save-sync` GitHub Release API. The first launch on each -local calendar day makes at most one silent attempt. A blocked or unavailable -GitHub connection never blocks the window or changes a local save; manual -checks display the failure and can be retried. A newer release dialog includes -the release title, publication date, notes, and official link. +The About & Updates dialog resolves the latest stable tag through the official +`MHToolkit/mh-save-sync` GitHub release page and reads its official Atom release +feed. This path does not consume the shared anonymous GitHub API quota; the +Release API remains a fallback. The first launch on each local calendar day +makes at most one silent attempt. A blocked or unavailable GitHub connection +never blocks the window or changes a local save; manual checks display the +failure and can be retried. A newer release dialog includes the release title, +publication date, notes, and official link. The package script passes the Rust converter version into both WinUI publish forms, so the folder, portable EXE, and installer compare the same real version diff --git a/apps/mh3g-save-converter-windows/README.zh-CN.md b/apps/mh3g-save-converter-windows/README.zh-CN.md index d999f5f..71f08f2 100644 --- a/apps/mh3g-save-converter-windows/README.zh-CN.md +++ b/apps/mh3g-save-converter-windows/README.zh-CN.md @@ -40,10 +40,11 @@ ## 更新检查 -“关于与更新”可以查询 `MHToolkit/mh-save-sync` 官方 GitHub Release API 的最新 -正式版。每个本地自然日首次启动最多静默尝试一次;GitHub 无法访问时不会阻塞 -窗口或改动本地存档,手动检查会展示失败信息并允许重试。发现新版本时,弹窗会 -显示版本名、发布日期、更新说明与官方链接。 +“关于与更新”会通过 `MHToolkit/mh-save-sync` 官方 GitHub Release 页面确认最新 +正式版 tag,并读取官方 Atom Release feed。该主路径不消耗共享的 GitHub 匿名 +API 配额,Release API 仅作为备用。每个本地自然日首次启动最多静默尝试一次; +GitHub 无法访问时不会阻塞窗口或改动本地存档,手动检查会展示失败信息并允许 +重试。发现新版本时,弹窗会显示版本名、发布日期、更新说明与官方链接。 打包脚本会把 Rust 转换器版本同时注入 WinUI 文件夹版和单文件便携版,因此 ZIP、 便携 EXE 和安装器比较的是同一个真实版本,不会误用 .NET 默认程序集版本。 diff --git a/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs b/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs index 2ac2b76..9369b4f 100644 --- a/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs +++ b/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs @@ -1,6 +1,11 @@ +using System.Globalization; +using System.Net; using System.Net.Http.Headers; using System.Reflection; +using System.Text.RegularExpressions; using System.Text.Json; +using System.Xml; +using System.Xml.Linq; using MHToolkit.MH3GSaveConverter.Windows.Models; namespace MHToolkit.MH3GSaveConverter.Windows.Services; @@ -9,6 +14,10 @@ public sealed class GitHubUpdateService { private static readonly Uri LatestReleaseEndpoint = new( "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest"); + private static readonly Uri LatestReleaseWebEndpoint = new( + "https://github.com/MHToolkit/mh-save-sync/releases/latest"); + private static readonly Uri ReleaseAtomEndpoint = new( + "https://github.com/MHToolkit/mh-save-sync/releases.atom"); private readonly HttpClient _httpClient; @@ -22,26 +31,193 @@ public GitHubUpdateService(HttpClient? httpClient = null, string? currentVersion public async Task CheckAsync(CancellationToken cancellationToken = default) { - using var request = new HttpRequestMessage(HttpMethod.Get, LatestReleaseEndpoint); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + GitHubReleaseInfo release; + try + { + release = await FetchFromWebFeedAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception webFailure) when (!cancellationToken.IsCancellationRequested) + { + try + { + release = await FetchFromApiAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception apiFailure) when (!cancellationToken.IsCancellationRequested) + { + throw new InvalidDataException( + $"GitHub release page/feed and Release API were unavailable. Web: {webFailure.Message} API: {apiFailure.Message}", + new AggregateException(webFailure, apiFailure)); + } + } + + return CreateResult(release); + } + + private async Task FetchFromWebFeedAsync(CancellationToken cancellationToken) + { + using var latestRequest = CreateRequest(HttpMethod.Get, LatestReleaseWebEndpoint, "text/html"); + // HttpClient follows GitHub's stable /releases/latest redirect. One + // byte is enough to retain the final tag URL without downloading the + // full HTML release page. + latestRequest.Headers.Range = new RangeHeaderValue(0, 0); + using var latestResponse = await _httpClient.SendAsync( + latestRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + Uri? stableReleaseUri; + if (latestResponse.StatusCode is HttpStatusCode.MovedPermanently + or HttpStatusCode.Found + or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect) + { + stableReleaseUri = latestResponse.Headers.Location is { } location + ? new Uri(LatestReleaseWebEndpoint, location) + : null; + } + else + { + latestResponse.EnsureSuccessStatusCode(); + stableReleaseUri = latestResponse.RequestMessage?.RequestUri; + } + + if (stableReleaseUri is null || !IsOfficialReleaseTagUri(stableReleaseUri)) + { + throw new InvalidDataException("GitHub latest-release redirect did not resolve to the official repository tag."); + } + + using var feedRequest = CreateRequest(HttpMethod.Get, ReleaseAtomEndpoint, "application/atom+xml"); + using var feedResponse = await _httpClient.SendAsync(feedRequest, cancellationToken).ConfigureAwait(false); + feedResponse.EnsureSuccessStatusCode(); + var payload = await ReadBoundedContentAsync( + feedResponse, + "GitHub release feed", + cancellationToken).ConfigureAwait(false); + return ParseAtomRelease(payload, stableReleaseUri); + } + + private async Task FetchFromApiAsync(CancellationToken cancellationToken) + { + using var request = CreateRequest( + HttpMethod.Get, + LatestReleaseEndpoint, + "application/vnd.github+json"); request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); - request.Headers.UserAgent.ParseAdd($"MH3GSaveConverter/{SafeUserAgentVersion(CurrentVersion)}"); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); + var payload = await ReadBoundedContentAsync( + response, + "GitHub Release API", + cancellationToken).ConfigureAwait(false); + + var release = JsonSerializer.Deserialize(payload) + ?? throw new InvalidDataException("GitHub Release API returned an empty payload."); + ValidateRelease(release); + return release; + } + + private HttpRequestMessage CreateRequest(HttpMethod method, Uri endpoint, string accept) + { + var request = new HttpRequestMessage(method, endpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept)); + request.Headers.UserAgent.ParseAdd($"MH3GSaveConverter/{SafeUserAgentVersion(CurrentVersion)}"); + return request; + } + + private static async Task ReadBoundedContentAsync( + HttpResponseMessage response, + string source, + CancellationToken cancellationToken) + { if (response.Content.Headers.ContentLength is > 2 * 1024 * 1024) { - throw new InvalidDataException("GitHub Release API response exceeded the accepted size."); + throw new InvalidDataException($"{source} response exceeded the accepted size."); } var payload = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); if (payload.Length > 2 * 1024 * 1024) { - throw new InvalidDataException("GitHub Release API response exceeded the accepted size."); + throw new InvalidDataException($"{source} response exceeded the accepted size."); } + return payload; + } - var release = JsonSerializer.Deserialize(payload) - ?? throw new InvalidDataException("GitHub Release API returned an empty payload."); + private static GitHubReleaseInfo ParseAtomRelease(byte[] payload, Uri stableReleaseUri) + { + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = 2 * 1024 * 1024, + }; + using var input = new MemoryStream(payload, writable: false); + using var reader = XmlReader.Create(input, settings); + var document = XDocument.Load(reader, LoadOptions.None); + XNamespace atom = "http://www.w3.org/2005/Atom"; + var entry = document.Root? + .Elements(atom + "entry") + .FirstOrDefault(candidate => + { + var href = candidate + .Elements(atom + "link") + .FirstOrDefault(link => string.Equals( + (string?)link.Attribute("rel"), + "alternate", + StringComparison.Ordinal))? + .Attribute("href")? + .Value; + return Uri.TryCreate(href, UriKind.Absolute, out var entryUri) + && string.Equals( + entryUri.AbsoluteUri, + stableReleaseUri.AbsoluteUri, + StringComparison.Ordinal); + }) + ?? throw new InvalidDataException("The stable GitHub release was not present in the official release feed."); + + var tagName = Uri.UnescapeDataString(stableReleaseUri.Segments[^1]); + DateTimeOffset? publishedAt = null; + if (DateTimeOffset.TryParse( + entry.Element(atom + "updated")?.Value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsedPublishedAt)) + { + publishedAt = parsedPublishedAt; + } + + var release = new GitHubReleaseInfo + { + TagName = tagName, + Name = entry.Element(atom + "title")?.Value.Trim(), + Body = PlainTextFromHtml(entry.Element(atom + "content")?.Value ?? string.Empty), + HtmlUrl = stableReleaseUri.AbsoluteUri, + PublishedAt = publishedAt, + Draft = false, + Prerelease = false, + }; + ValidateRelease(release); + return release; + } + + private static string PlainTextFromHtml(string html) + { + var text = Regex.Replace(html, @"(?i)]*)?>", "• "); + text = Regex.Replace( + text, + @"(?i)|||||", + "\n"); + text = Regex.Replace(text, @"<[^>]+>", string.Empty); + text = WebUtility.HtmlDecode(text); + var normalized = string.Join( + Environment.NewLine, + text.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + return normalized.Length <= 32_000 ? normalized : normalized[..32_000]; + } + + private UpdateCheckResult CreateResult(GitHubReleaseInfo release) + { ValidateRelease(release); if (!TryParseVersion(CurrentVersion, out var current) @@ -53,6 +229,19 @@ public async Task CheckAsync(CancellationToken cancellationTo return new UpdateCheckResult(CurrentVersion, release, latest > current); } + private static bool IsOfficialReleaseTagUri(Uri releaseUri) + { + return releaseUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && releaseUri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase) + && string.IsNullOrEmpty(releaseUri.Query) + && string.IsNullOrEmpty(releaseUri.Fragment) + && releaseUri.AbsolutePath.StartsWith( + "/MHToolkit/mh-save-sync/releases/tag/", + StringComparison.Ordinal) + && releaseUri.Segments.Length == 6 + && TryParseVersion(Uri.UnescapeDataString(releaseUri.Segments[^1]), out _); + } + internal static bool TryParseVersion(string value, out Version version) { var normalized = value.Trim().TrimStart('v', 'V').Split('+', 2)[0].Split('-', 2)[0]; diff --git a/scripts/verify-mh3g-save-converter-windows-source.py b/scripts/verify-mh3g-save-converter-windows-source.py index 06d2b6b..7fd0a2c 100644 --- a/scripts/verify-mh3g-save-converter-windows-source.py +++ b/scripts/verify-mh3g-save-converter-windows-source.py @@ -419,14 +419,28 @@ def main() -> int: update_service = read("Services/GitHubUpdateService.cs") for expected in ( "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest", + "https://github.com/MHToolkit/mh-save-sync/releases/latest", + "https://github.com/MHToolkit/mh-save-sync/releases.atom", "application/vnd.github+json", + "application/atom+xml", "X-GitHub-Api-Version", "MH3GSaveConverter/", "TimeSpan.FromSeconds(8)", "MHToolkit/mh-save-sync/releases/", "AssemblyInformationalVersionAttribute", + "FetchFromWebFeedAsync", + "FetchFromApiAsync", + "RangeHeaderValue(0, 0)", + "HttpCompletionOption.ResponseHeadersRead", + "XDocument.Load", + "DtdProcessing.Prohibit", ): require(expected in update_service, f"Windows update service is missing {expected}") + require( + update_service.index("FetchFromWebFeedAsync(cancellationToken)") + < update_service.index("FetchFromApiAsync(cancellationToken)"), + "Windows update checks must avoid anonymous API quotas by preferring the official release page/feed", + ) update_store = read("Services/UpdateCheckPreferenceStore.cs") for expected in ( "update-check.json",