diff --git a/README.md b/README.md
index 9de81e4..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
@@ -160,7 +167,7 @@ child. CEC is a system NAND mailbox, not SD-card ExtData.
| Data group | Give this exact input to the CLI | Do **not** give it | Required? | Purpose / affected files |
| --- | --- | --- | --- | --- |
| Core slot | One explicit `user1`, `user2`, or `user3` file under `title/00040000/00048100/data/00000001/` | The title directory, all slots, ExtData, a ZIP | Yes: choose one slot | Character, story/progress, farm, fleet, local offline-hunter data; writes only the named same-number Cemu `user#` target |
-| Shared system | One explicit `system` file in the same title savedata directory | The whole title directory, a ZIP | Optional | Shared system data; writes only the named Cemu `system` target |
+| Shared system | One explicit 3DS `system` file in the same title savedata directory **and** one existing initialized Cemu `system` target | The whole title directory, a ZIP, or a missing/new Cemu target | Optional | Unions the housekeeper gallery/movie unlock flags into the existing Cemu `system`; all other Wii U shared bytes are preserved |
| Shared ExtData | The complete `extdata/00000000/00000481/user/` directory containing `card1`, `card2`, `card3`, `cardbox`, `quest1`, `quest2`, `quest3`, `quest4` directly inside | The `00000481` parent, `boss/`, a partial set, a ZIP | Optional | Converts all eight files into a new staging directory; a separate guarded `install-extras` transaction can install complete `guild-cards`, `quests`, or both into an initialized Cemu target |
| StreetPass / Hunter Search CEC | The exact `CEC/00048100/` directory containing `InBox___` | SD-card ExtData, the `InBox___` child alone, a ZIP | Optional and experimental | Reads received raw StreetPass records and can write only Cemu `cec` |
@@ -192,6 +199,25 @@ through `quest4`, pass that folder only to `convert-extras`. If it contains an
extra wrapper directory, enter it first; the expected filenames must be
immediate children of the CLI path or the narrow GUI selection described above.
+If the 3DS `system` file is omitted, a core `user#` conversion cannot migrate
+the housekeeper's gallery/movie unlock history. `system` is shared across all
+three character slots and also contains settings unrelated to the selected
+slot. Therefore `convert-system` requires both the 3DS source and an existing,
+initialized Cemu target. It bitwise-unions only the verified gallery/movie
+flag range (Cemu file offsets `0x68..0x77`) and preserves every other target
+byte. It refuses a new/missing target instead of replacing all shared data.
+
+### Legacy Wii U save-editor caveat
+
+Talisman skill points are signed one-byte values in the equipment record. Some
+older Wii U editors read them as unsigned bytes, so a legitimate `-5` appears
+as `251`. The converter preserves the raw signed byte and converts only the
+record fields whose byte order actually differs. Some legacy armor writers
+also rebuild an armor header without preserving its RGB bytes; editing armor
+with such a tool can turn its pigment black even when the converted talisman is
+valid. These editor behaviors must not be "fixed" by clamping or rewriting
+otherwise valid converted equipment records.
+
### Before you write: paths, inspection, and dry-run
The examples below run from this repository after Rust is installed. Define a
@@ -234,10 +260,12 @@ an arbitrary renamed file. With no `--write`, conversion remains a dry-run;
pass `--dry-run` explicitly in scripts to make that intention visible. `--write`
and `--dry-run` conflict.
-For GUI and automation clients, `convert` and `convert-system` expose guarded
-write preconditions: `--expected-source-sha256` plus exactly one target
-condition, either `--expected-target-sha256` or `--expected-target-absent`.
-They are accepted only together with `--write`. Take hash values only from
+For GUI and automation clients, `convert` exposes guarded write preconditions:
+`--expected-source-sha256` plus exactly one target condition, either
+`--expected-target-sha256` or `--expected-target-absent`. `convert-system`
+always requires an existing Cemu baseline and therefore requires both
+`--expected-source-sha256` and `--expected-target-sha256` when writing. These
+arguments are accepted only together with `--write`. Take hash values only from
`hashes.source` and `hashes.target_before` in the JSON emitted by the **same**
immediately preceding Dry Run for the same source and output paths. This makes
the write fail closed if an existing source or target changed; the target hash
@@ -375,7 +403,7 @@ mh3g-save-convert repair-converted --current ] [--expected-target-sha256 | --expected-target-absent]] --output
|||"#,
+ 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/ComponentSelectionView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift
index 3dfd008..d9dee84 100644
--- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift
+++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift
@@ -32,6 +32,12 @@ struct ComponentSelectionView: View {
) {
chooseSystemTarget()
}
+ Label(
+ ConverterCopy.text("Components.SystemWarning", language: language),
+ systemImage: "exclamationmark.shield"
+ )
+ .font(.caption)
+ .foregroundStyle(.orange)
}
} footer: {
Text(ConverterCopy.text("Components.SystemFooter", language: language))
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 bec353b..f9d4da8 100644
--- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings
+++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings
@@ -13,6 +13,7 @@
"Repair.Version.Hint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Use automatic detection if the version is unknown. If several historical versions match, Dry Run will require an explicit selection before it can authorize a write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "不知道版本时先自动检测;若多个历史版本都匹配,Dry Run 会要求你明确选择后再运行。" } } } },
"Repair.Version.Required" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Several versions match this save. Select the converter version originally used, then run Dry Run again." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "检测到多个可能版本。请选择当时使用的转换器版本,然后重新运行 Dry Run。" } } } },
"Repair.PreviewSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Repair preview SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "修复预览 SHA-256" } } } },
+ "Components.SystemWarning" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "System is shared by all three character slots. Supply both the 3DS source and an initialized Cemu target; only housekeeper gallery/movie flags are merged while every other Cemu setting and shared-slot byte is retained." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "system 由三个角色槽位共享。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只合并管家画廊/动画标记,其他 Cemu 设置与跨槽位数据保持不变。" } } } },
"Guide.ComponentsReady" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Optional data is set. Next, run a read-only Dry Run to review what this conversion will write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "可选数据已确认。接下来运行只读 Dry Run,确认本次转换会写入什么。" } } } },
"Guide.CoreDryRunCompleteWithOptionals" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The core-slot Dry Run passed. Continue to the write page; selected system or ExtData data still needs its own Dry Run and write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "核心角色 Dry Run 已通过。接下来进入写入页;已选 system 或 ExtData 仍须分别 Dry Run 和写入。" } } } },
"Guide.DryRunComplete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run passed. No save has been modified; continue when you are ready to confirm the write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run 已通过。确认写入前不会修改任何存档。" } } } },
@@ -53,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 1e33762..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));
@@ -141,6 +156,7 @@ public void SetLanguage(AppLanguageOverride language)
public string SharedSystem => Text(nameof(SharedSystem));
public string SystemSource => Text(nameof(SystemSource));
public string SystemTarget => Text(nameof(SystemTarget));
+ public string SystemMergeWarning => Text(nameof(SystemMergeWarning));
public string RunSystemDryRun => Text(nameof(RunSystemDryRun));
public string WriteSystem => Text(nameof(WriteSystem));
public string SystemManifest => Text(nameof(SystemManifest));
@@ -182,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",
@@ -248,7 +279,7 @@ private string Text(string key)
[nameof(ManifestPath)] = "Transaction manifest",
[nameof(RollbackFromManifest)] = "Rollback from manifest",
[nameof(OptionalSectionTitle)] = "Optional data",
- [nameof(OptionalSectionDescription)] = "Shared system and ExtData are separate, explicit transactions. CEC stays isolated and disabled until you enable it.",
+ [nameof(OptionalSectionDescription)] = "Shared system carries housekeeper gallery/movie unlocks; ExtData carries cards and quests. They are separate, explicit transactions. CEC stays isolated and disabled until you enable it.",
[nameof(ExperimentalCec)] = "Experimental CEC",
[nameof(ExperimentalCecHint)] = "CEC is a separate StreetPass / Hunter Search cache. Guild-card and offline-partner migration do not require it.",
[nameof(CecSourceDirectory)] = "3DS CEC mailbox directory",
@@ -288,15 +319,16 @@ private string Text(string key)
[nameof(CecAcknowledgementRequired)] = "Acknowledge the experimental CEC warning before its write command is available.",
[nameof(CecPathsRequired)] = "Choose the exact CEC mailbox directory and Cemu cec target path.",
[nameof(CecFingerprintMissing)] = "CEC Dry Run did not provide the aggregate record-set and target JSON fingerprints.",
- [nameof(SystemPathsRequired)] = "Choose exact 3DS and Cemu system files before running this separate transaction.",
+ [nameof(SystemPathsRequired)] = "Choose the exact 3DS source system and an existing initialized Cemu system before running this separate merge.",
[nameof(ExtrasPathsRequired)] = "Choose at least one ExtData group plus explicit source and staging directories.",
[nameof(ExtrasFingerprintMissing)] = "The ExtData Dry Run did not provide the expected component or set fingerprints.",
[nameof(OptionalDataNeedsConfiguration)] = "Finish configuring the selected system or ExtData paths before the core Dry Run.",
[nameof(SharedSystem)] = "Shared system",
[nameof(SystemSource)] = "3DS system file",
- [nameof(SystemTarget)] = "Cemu system file",
+ [nameof(SystemTarget)] = "Existing initialized Cemu system file",
+ [nameof(SystemMergeWarning)] = "System is shared by all three character slots and may contain settings or data for the other slots. Supply both the 3DS source and the existing Cemu target. The converter merges only housekeeper gallery/movie flags and preserves every other Cemu byte.",
[nameof(RunSystemDryRun)] = "Run system Dry Run",
- [nameof(WriteSystem)] = "Write system",
+ [nameof(WriteSystem)] = "Merge gallery/movie flags",
[nameof(SystemManifest)] = "System manifest",
[nameof(OptionalExtData)] = "Optional ExtData",
[nameof(GuildCards)] = "Guild cards & offline partners",
@@ -334,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",
@@ -400,7 +447,7 @@ private string Text(string key)
[nameof(ManifestPath)] = "事务 manifest",
[nameof(RollbackFromManifest)] = "从 manifest 回滚",
[nameof(OptionalSectionTitle)] = "可选数据",
- [nameof(OptionalSectionDescription)] = "共享 system 和 ExtData 是独立、明确的事务。CEC 完全隔离,默认关闭。",
+ [nameof(OptionalSectionDescription)] = "共享 system 包含管家画廊/动画解锁记录,ExtData 包含名片与任务;两者都是独立、明确的事务。CEC 完全隔离,默认关闭。",
[nameof(ExperimentalCec)] = "实验性 CEC",
[nameof(ExperimentalCecHint)] = "CEC 是独立的擦身通信 / 猎人搜索缓存。公会名片和离线伙伴迁移不依赖它。",
[nameof(CecSourceDirectory)] = "3DS CEC 邮箱目录",
@@ -440,15 +487,16 @@ private string Text(string key)
[nameof(CecAcknowledgementRequired)] = "使用 CEC 写入命令前请确认实验性警告。",
[nameof(CecPathsRequired)] = "请选择准确的 CEC 邮箱目录和 Cemu cec 目标路径。",
[nameof(CecFingerprintMissing)] = "CEC Dry Run 没有提供聚合记录集和目标 JSON 指纹。",
- [nameof(SystemPathsRequired)] = "运行这个独立事务前,请选择准确的 3DS 和 Cemu system 文件。",
+ [nameof(SystemPathsRequired)] = "运行这个独立合并前,请选择准确的 3DS 源 system 和已存在、已初始化的 Cemu system。",
[nameof(ExtrasPathsRequired)] = "请至少选择一个 ExtData 组,以及准确的源目录和临时目录。",
[nameof(ExtrasFingerprintMissing)] = "ExtData Dry Run 没有提供预期的组件或集合指纹。",
[nameof(OptionalDataNeedsConfiguration)] = "请先完成已选择的 system 或 ExtData 路径配置,再运行核心 Dry Run。",
[nameof(SharedSystem)] = "共享 system",
[nameof(SystemSource)] = "3DS system 文件",
- [nameof(SystemTarget)] = "Cemu system 文件",
+ [nameof(SystemTarget)] = "已存在且已初始化的 Cemu system 文件",
+ [nameof(SystemMergeWarning)] = "system 由三个角色槽位共享,可能同时保存其他槽位数据或设置。必须同时提供 3DS 源 system 和现有 Cemu 目标 system;转换器只合并管家画廊/动画标记,其他 Cemu 字节全部保持不变。",
[nameof(RunSystemDryRun)] = "运行 system Dry Run",
- [nameof(WriteSystem)] = "写入 system",
+ [nameof(WriteSystem)] = "合并画廊/动画标记",
[nameof(SystemManifest)] = "system manifest",
[nameof(OptionalExtData)] = "可选 ExtData",
[nameof(GuildCards)] = "公会名片与离线伙伴",
diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml b/apps/mh3g-save-converter-windows/MainWindow.xaml
index bad9a1e..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}" />
+
@@ -311,9 +317,11 @@
-
-
-
+
+
+
+
+
@@ -326,8 +334,8 @@
-
-
+
+
@@ -337,8 +345,9 @@
-
-
+
+
+
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)