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