Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ JSON 报告;不会在 UI 内重写字节转换、备份、manifest、模拟器
macOS 窗口会正常出现于 Dock 和 Cmd-Tab;默认跟随系统语言,也可在设置中切换简体中文或
English。

两个原生工作台都提供“**关于与更新**”。每个本地自然日首次启动最多向
`MHToolkit/mh-save-sync` 官方 latest-release 接口发起一次非阻塞请求,也可手动检查;
GitHub 不可访问时绝不会阻塞转换。发现新版时会显示版本、发布日期、Release 说明和官方
链接。该请求不会上传任何存档字节或用户所选路径。

工作台提供四个**有引导、但不强制**的阶段:输入与检查、可选共享数据、Dry Run、写入或
回滚。完成一个阶段后会显示推荐的下一步操作(例如检查完成后配置可选数据),但玩家仍可
返回前一阶段,或跳过可选组件。该引导不会额外执行任何转换。
Expand Down
17 changes: 17 additions & 0 deletions apps/mh3g-save-converter-macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ Normal conversion uses a single-file manifest; compatibility repair uses
`.mh3g-compatibility-repair-<UUID>.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
Expand Down
13 changes: 13 additions & 0 deletions apps/mh3g-save-converter-macos/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ sidecar 并展示 JSON 报告,不在 Swift 中重复实现存档转换。
使用 `.mh3g-compatibility-repair-<UUID>.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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..<count {
let left = index < lhs.components.count ? lhs.components[index] : 0
let right = index < rhs.components.count ? rhs.components[index] : 0
if left != right {
return left < right
}
}
return false
}

public static func == (lhs: Self, rhs: Self) -> 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)<li(?:\s[^>]*)?>"#,
with: "• ",
options: .regularExpression
)
text = text.replacingOccurrences(
of: #"(?i)<br\s*/?>|</li\s*>|</p\s*>|</h[1-6]\s*>|</blockquote\s*>|</pre\s*>"#,
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
}
}
Loading
Loading