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..7871cd5 100644
--- a/apps/mh3g-save-converter-macos/README.md
+++ b/apps/mh3g-save-converter-macos/README.md
@@ -31,6 +31,23 @@ 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 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
```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..2b5da83 100644
--- a/apps/mh3g-save-converter-macos/README.zh-CN.md
+++ b/apps/mh3g-save-converter-macos/README.zh-CN.md
@@ -23,6 +23,19 @@ 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 页面确认最新
+正式版 tag,并读取官方 Atom Release feed。该主路径不消耗共享的 GitHub 匿名
+API 配额,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..82ab9db
--- /dev/null
+++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift
@@ -0,0 +1,268 @@
+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 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?,
+ now: Date,
+ calendar: Calendar = .current
+ ) -> Bool {
+ guard let lastAttempt else { return true }
+ return !calendar.isDate(lastAttempt, inSameDayAs: now)
+ }
+}
+
+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/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..253078f
--- /dev/null
+++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift
@@ -0,0 +1,201 @@
+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 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
+ @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 {
+ let release = try await fetchLatestRelease()
+ 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 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
+ }
+ }
+
+ 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(source: String)
+ case responseTooLarge(source: String)
+ case invalidRelease
+ case invalidVersion
+ case allSourcesUnavailable(web: String, api: String)
+
+ var errorDescription: String? {
+ switch self {
+ 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/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..92a7504
--- /dev/null
+++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift
@@ -0,0 +1,114 @@
+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 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))
+ 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..ccd89ca 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,21 @@ 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 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
+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..71f08f2 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,17 @@
[中文 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 页面确认最新
+正式版 tag,并读取官方 Atom Release feed。该主路径不消耗共享的 GitHub 匿名
+API 配额,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..9369b4f
--- /dev/null
+++ b/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs
@@ -0,0 +1,285 @@
+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;
+
+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;
+
+ 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)
+ {
+ 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");
+
+ 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($"{source} response exceeded the accepted size.");
+ }
+
+ var payload = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
+ if (payload.Length > 2 * 1024 * 1024)
+ {
+ throw new InvalidDataException($"{source} response exceeded the accepted size.");
+ }
+ return 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)
+ || !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);
+ }
+
+ 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];
+ 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..7fd0a2c 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,41 @@ 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",
+ "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",
+ "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 +615,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 +839,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")