Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/mh3g-save-converter-macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ The core picker accepts an exact `user1`, `user2`, or `user3` file or its
direct parent. It does not recursively scan an SD card or MLC and does not open
ZIP, 7z, or RAR archives. A directory resolves only to the directly contained
same-named selected slot.
The UI keeps the file-or-folder choice visible and provides an expandable
`user2` directory-tree example, so the expected level is clear before opening
the system picker.

Every write is bound to the immediately preceding Dry Run's SHA-256 values.
Normal conversion uses a single-file manifest; compatibility repair uses
Expand Down
2 changes: 2 additions & 0 deletions apps/mh3g-save-converter-macos/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ sidecar 并展示 JSON 报告,不在 Swift 中重复实现存档转换。

核心选择器接受准确 `user1`、`user2`、`user3` 文件或其直接父目录。它不会递归
扫描 SD 卡/MLC,不接受 ZIP、7z、RAR。目录只解析成当前选中槽位的同名直接子文件。
界面会持续显示“文件或目录”选择类型,并提供可展开的 `user2` 目录树示例;无需
先打开系统文件选择器才能知道应当选择哪一层。

每次写入都绑定紧邻 Dry Run 的 SHA-256。普通转换使用单文件 manifest;兼容修复
使用 `.mh3g-compatibility-repair-<UUID>.json` 并通过 `rollback-repair` 总体回滚。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ public enum ConverterCopy {
"Input.FinalOutput": "最终输出",
"Input.NewOutput": "新输出文件;写入时必须保持不存在",
"Input.Select": "选择…",
"Input.SelectFile": "选择文件…",
"Input.SelectDirectory": "选择目录…",
"Input.SelectFileOrDirectory": "选择文件或目录…",
"Input.SourceSelectionHint": "允许选择:单个 user# 文件,或直接包含所选 user# 的父目录。",
"Input.CurrentSelectionHint": "允许选择:单个同槽位 Wii U/Cemu user# 文件,或它的直接父目录;必须已存在。",
"Input.OutputSelectionHint": "允许选择:同名 user# 文件,或一个已有输出目录;选择目录时会解析为 <目录>/<所选 user#>。",
"Input.PathGuideTitle": "支持单文件或直接父目录(查看结构示例)",
"Input.PathGuideSummary": "只解析所选目录的直接同名 user#;不会递归扫描 SD 卡或 MLC,也不会读取 ZIP、7z、RAR。",
"Input.PathGuideExample": "示例(选择 user2)\n\n3DS 存档直接父目录/\n├── system (本控件不读取)\n└── user2 ← 3DS 源\n\n当前 Cemu 存档目录/\n├── user2 ← 当前引用(仅修复,只读)\n├── card1、card2、card3、cardbox (修复名片时读取)\n└── quest1 … quest4 (修复名片时校验并保留)\n\n输出目录/\n├── user2 ← 转换结果(仅处理核心角色时可暂不存在)\n└── card1、card2、card3、cardbox (修复名片并独立输出时必须已存在)",
"Input.Inspect": "检查所选内容",
"Input.NoSelection": "尚未选择文件",
"Components.System": "共享 system",
Expand Down Expand Up @@ -346,6 +355,15 @@ public enum ConverterCopy {
"Input.FinalOutput": "Final output",
"Input.NewOutput": "New output file; it must remain absent before writing",
"Input.Select": "Choose…",
"Input.SelectFile": "Choose File…",
"Input.SelectDirectory": "Choose Folder…",
"Input.SelectFileOrDirectory": "Choose File or Folder…",
"Input.SourceSelectionHint": "Allowed: one user# file, or the direct parent folder containing the selected user#.",
"Input.CurrentSelectionHint": "Allowed: one existing same-slot Wii U/Cemu user# file, or its direct parent folder.",
"Input.OutputSelectionHint": "Allowed: a same-name user# file, or an existing output folder; a folder resolves to <folder>/<selected user#>.",
"Input.PathGuideTitle": "File or direct parent folder supported (show example)",
"Input.PathGuideSummary": "Only the direct same-name user# is resolved. The app never recursively scans an SD card or MLC and never reads ZIP, 7z, or RAR archives.",
"Input.PathGuideExample": "Example (user2 selected)\n\n3DS save parent/\n├── system (not read by this control)\n└── user2 ← 3DS source\n\nCurrent Cemu save folder/\n├── user2 ← current reference (repair only, read-only)\n├── card1, card2, card3, cardbox (read for guild-card repair)\n└── quest1 … quest4 (validated and preserved for repair)\n\nOutput folder/\n├── user2 ← result (may be absent for a core-only operation)\n└── card1, card2, card3, cardbox (must exist for separate guild-card output)",
"Input.Inspect": "Inspect selection",
"Input.NoSelection": "No file selected",
"Components.System": "Shared system",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ struct ComponentSelectionView: View {
SelectedPathRow(
title: ConverterCopy.text("Components.SystemSource", language: language),
value: workflow.components.systemSource,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectFile", language: language)
) {
chooseSystemSource()
}
SelectedPathRow(
title: ConverterCopy.text("Components.SystemTarget", language: language),
value: workflow.components.systemTarget,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectFile", language: language)
) {
chooseSystemTarget()
}
Expand Down Expand Up @@ -77,22 +77,22 @@ struct ComponentSelectionView: View {
SelectedPathRow(
title: ConverterCopy.text("Components.ExtDataSource", language: language),
value: workflow.components.extraSourceDirectory,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectDirectory", language: language)
) {
chooseExtraSource()
}
if workflow.mode == .newConversion {
SelectedPathRow(
title: ConverterCopy.text("Components.Staging", language: language),
value: workflow.components.extraStagingDirectory,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectDirectory", language: language)
) {
chooseExtraStaging()
}
SelectedPathRow(
title: ConverterCopy.text("Components.Target", language: language),
value: workflow.components.extraTargetDirectory,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectDirectory", language: language)
) {
chooseExtraTarget()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ struct ExperimentalCECView: View {
SelectedPathRow(
title: ConverterCopy.text("CEC.Source", language: language),
value: workflow.components.cecSourceDirectory,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectDirectory", language: language)
) {
chooseSource()
}
SelectedPathRow(
title: ConverterCopy.text("CEC.Target", language: language),
value: workflow.components.cecTarget,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectFile", language: language)
) {
chooseTarget()
}
Expand Down Expand Up @@ -73,7 +73,7 @@ struct ExperimentalCECView: View {
SelectedPathRow(
title: ConverterCopy.text("CEC.Manifest", language: language),
value: manifest,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectFile", language: language)
) {
manifest = OpenPanel.selectFile(
title: ConverterCopy.text("CEC.Manifest", language: language),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,21 @@ struct InputInspectionView: View {
.onChange(of: slot) { _, _ in
resolveSelections()
}
CorePathSelectionGuide(language: language)
SelectedPathRow(
title: ConverterCopy.text("Input.Source", language: language),
value: source,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectFileOrDirectory", language: language),
selectionHint: ConverterCopy.text("Input.SourceSelectionHint", language: language)
) {
chooseSource()
}
if workflow.mode == .repairConverted {
SelectedPathRow(
title: ConverterCopy.text("Input.Current", language: language),
value: current ?? workflow.input?.current,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectFileOrDirectory", language: language),
selectionHint: ConverterCopy.text("Input.CurrentSelectionHint", language: language)
) {
chooseCurrent()
}
Expand All @@ -81,7 +84,8 @@ struct InputInspectionView: View {
language: language
),
value: target ?? workflow.input?.target,
chooseTitle: ConverterCopy.text("Input.Select", language: language)
chooseTitle: ConverterCopy.text("Input.SelectFileOrDirectory", language: language),
selectionHint: ConverterCopy.text("Input.OutputSelectionHint", language: language)
) {
chooseTarget()
}
Expand Down Expand Up @@ -328,36 +332,59 @@ struct SelectedPathRow: View {
let title: String
let value: URL?
let chooseTitle: String
let selectionHint: String?
let choose: () -> Void

init(
title: String,
value: URL?,
chooseTitle: String,
selectionHint: String? = nil,
choose: @escaping () -> Void
) {
self.title = title
self.value = value
self.chooseTitle = chooseTitle
self.selectionHint = selectionHint
self.choose = choose
}

var body: some View {
LabeledContent(title) {
HStack(alignment: .center, spacing: 8) {
if let value {
Image(systemName: icon(for: value))
.foregroundStyle(.secondary)
VStack(alignment: .trailing, spacing: 2) {
Text(value.lastPathComponent)
.lineLimit(1)
Text(value.deletingLastPathComponent().path)
.font(.caption.monospaced())
VStack(alignment: .leading, spacing: 6) {
LabeledContent(title) {
HStack(alignment: .center, spacing: 8) {
if let value {
Image(systemName: icon(for: value))
.foregroundStyle(.secondary)
.lineLimit(1)
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .trailing)
.help(value.path)
} else {
Text("—")
.foregroundStyle(.secondary)
VStack(alignment: .trailing, spacing: 2) {
Text(value.lastPathComponent)
.lineLimit(1)
Text(value.deletingLastPathComponent().path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .trailing)
.help(value.path)
} else {
Text("—")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .trailing)
}
Button(action: choose) {
Label(chooseTitle, systemImage: "folder.badge.plus")
}
.buttonStyle(.borderless)
.help(chooseTitle)
.accessibilityLabel(chooseTitle)
}
Button(action: choose) {
Image(systemName: "folder.badge.plus")
}
.buttonStyle(.borderless)
.help(chooseTitle)
.accessibilityLabel(chooseTitle)
}
if let selectionHint {
Label(selectionHint, systemImage: "info.circle")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
Expand All @@ -369,6 +396,31 @@ struct SelectedPathRow: View {
}
}

private struct CorePathSelectionGuide: View {
let language: ConverterLanguage

var body: some View {
DisclosureGroup {
VStack(alignment: .leading, spacing: 10) {
Text(ConverterCopy.text("Input.PathGuideSummary", language: language))
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(ConverterCopy.text("Input.PathGuideExample", language: language))
.font(.caption.monospaced())
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 6)
} label: {
Label(
ConverterCopy.text("Input.PathGuideTitle", language: language),
systemImage: "folder.badge.questionmark"
)
}
}
}

struct InspectionTable: View {
let source: InputInspection
let current: InputInspection?
Expand Down
Loading
Loading