Skip to content

Commit a9a483f

Browse files
niazlvclaude
andauthored
feat: 体积会话缓存 + 弹窗本地化修复(中文→本地化、补全俄语)+ 受保护应用迁移预警 (#55)
* fix: 应用体积改用会话级缓存,修复偶发"计算中"卡死与体积丢失 - 新增会话级 sizeCache(key=AppItem.id),独立于扫描结果,重扫不丢失已算体积 - 缓存条目记录 bundle mtime,应用原地更新时自动失效并后台重算 - 所有扫描路径统一走 applySizes:命中项在赋值前填充(无"计算中"闪烁),仅未命中项后台并行计算 - 移除按 name 查找的 recalculateAppSize(同名应用会永久卡在"计算中")与 calculateSizes 开关,体积匹配改为按 id Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: 修复弹窗中文未本地化问题(xcstrings 换行键 + Stub 启动器对话框) 弹窗标题/按钮已本地化但正文仍显示中文,根因是 Localizable.xcstrings 中部分多行键存储为字面量 "\n"(反斜杠+n),而 Swift 字面量编译为真实 换行符,导致 .localized 查找不匹配并回退到中文源串。 - 规范化 16 个多行键:14 个改为真实换行符以匹配运行时查找,删除 2 个 已有正确孪生键的孤立重复键,并规范化 271 处译文值。中文(源语言) 保持不变,其余语言现可正确本地化。 - Stub Portal 原生启动器(外部存储未连接对话框)改为按系统语言选择 文案(英文兜底),重新编译通用二进制 StubLauncherBinary。 - Bash 兜底启动器对话框改为英文。 仅影响新迁移的应用;已迁移的 Stub Portal 在重新迁移前仍使用旧启动器。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: 补全俄语界面翻译(弹窗/状态/错误描述中混入英文) 结构性换行键修复后,部分用户可见字符串能正确解析了,但其俄语 译文本身是英文(甚至是错误的拉丁转写,如 "Eto prilozhenie..."), 导致俄语界面里弹窗正文/状态提示/错误信息显示为英文。 为 57 条用户可见字符串补全规范的俄语翻译,覆盖: - App Store 外部安装、自更新应用迁移、数据目录迁移/还原/规范化等 确认弹窗正文 - DataDirError / AppMoverError 错误描述 - 状态徽章悬浮说明(Sparkle/Electron/已锁定/未锁定/外部安装) - 数据目录类型与优先级标签(重要/推荐/可选/自定义等)、按钮与空状态 仅修改 ru 译文值;格式化占位符(%@/%lld/%1$@)与源串完全一致, 弹窗内引用的按钮名与实际俄语按钮一致。日志字符串维持英文。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: 迁移受保护应用前增加预警弹窗 App Store 应用与 root 拥有的应用,其本地副本通常无法被直接删除或替换, 自动迁移可能以「权限不足」失败。迁移前检测此类应用并弹出预警,说明可改用 访达手动拖动 + AppPorts 创建链接,用户可选择「仍然迁移」或「取消」。 迁移收尾逻辑抽出为 proceedWithMigration 复用。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n: 本地化「受保护应用」预警的新增字符串 新功能「迁移受保护应用前预警」引入了 4 条经 .localized 的中文字符串, 但未加入字符串目录,非中文界面会回退显示中文(与本次修复的问题同类)。 为以下字符串补充英文与俄语翻译(zh-Hans 源 + en + ru,多行键使用真实换行符): - 受保护的应用(标题) - 仍然迁移(按钮) - 预警正文(App Store / root 受保护说明) - 迁移失败的权限不足友好提示 其余语言暂随源回退,待后续补全。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: 文件夹套件改用 Folder Mirror 入口,并合入 transparentHybrid 原型 Folder Mirror(本次主改动):迁移/链接 appSuiteFolder 与 singleAppContainer 时,本地不再对整个文件夹做符号链接,而是创建真实文件夹逐项镜像外部套件—— 内部每个 .app 为 Stub Portal(真实 .app 壳,可被 Launch Services / Spotlight 索引),其余文件/子目录为指向外部的符号链接,并写入隐藏标记 .appports-folder-portal.plist 记录外部真实路径。 - createFolderMirrorPortal:逐项创建 Stub/符号链接,单项 Stub 失败回退为符号链接 - refreshFolderMirror:rescan 时同步外部套件的新增/删除条目与内部 Stub 版本, 递归 lsregister 刷新索引;旧版整体符号链接文件夹安全跳过 - localPortalKind / 扫描器据标记识别已链接、孤立链接,体积按外部真实文件夹计算 - 解链删除本地镜像(外部保留),还原复制外部套件回本地为真实文件夹 - 旧版整体符号链接文件夹保持兼容,仅新迁移使用 Folder Mirror transparentHybrid(原型,feature flag transparentHybridEnabled,默认关闭): 本地为真实 .app 壳 + Stub 启动器,并把 Contents 下未注入的文件/子目录符号链接 镜像到外部真实 app,使硬编码到 /Applications/X.app/Contents/... 的内部路径仍可 解析(需外置磁盘在线)。 测试:AppMigrationServiceTests 15/15、AppScannerTests 11/11;Release 构建通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 414d3f2 commit a9a483f

7 files changed

Lines changed: 1168 additions & 714 deletions

File tree

AppPorts/ContentView.swift

Lines changed: 196 additions & 134 deletions
Large diffs are not rendered by default.

AppPorts/Localizable.xcstrings

Lines changed: 384 additions & 566 deletions
Large diffs are not rendered by default.

AppPorts/Services/AppMigrationService.swift

Lines changed: 367 additions & 4 deletions
Large diffs are not rendered by default.

AppPorts/StubLauncherBinary

63.5 KB
Binary file not shown.

AppPorts/Utils/AppScanner.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,12 @@ actor AppScanner {
380380
}
381381

382382
private func detectLocalFolderStatus(at folderURL: URL) -> String {
383+
// Folder Mirror:真实文件夹 + 标记文件,按外部真实文件夹是否在线判定
384+
if let externalURL = AppMigrationService.folderMirrorExternalURL(at: folderURL) {
385+
return FileManager.default.fileExists(atPath: externalURL.path) ? AppStatus.linked : AppStatus.orphanedLink
386+
}
387+
388+
// 旧版整体符号链接文件夹
383389
if let linkDest = resolveSymlinkDestination(of: folderURL) {
384390
return FileManager.default.fileExists(atPath: linkDest.path) ? AppStatus.linked : AppStatus.orphanedLink
385391
}
@@ -393,6 +399,11 @@ actor AppScanner {
393399
/// - `/Applications/Foo.app -> /somewhere/Foo.app`
394400
/// - 本地空壳 `.app`,其 `Contents`/`MacOS`/`Resources` 指向外部真实 bundle
395401
private func resolveSizeCalculationURL(for url: URL) -> URL {
402+
// Folder Mirror:本地仅存 Stub/符号链接,体积按外部真实套件文件夹计算
403+
if let externalURL = AppMigrationService.folderMirrorExternalURL(at: url) {
404+
return externalURL
405+
}
406+
396407
if let rootTarget = resolveSymlinkDestination(of: url) {
397408
return enclosingAppBundleURL(for: rootTarget)
398409
}
@@ -489,6 +500,13 @@ actor AppScanner {
489500
}
490501

491502
private func isLocalFolder(_ localFolderURL: URL, linkedTo externalFolderURL: URL) -> Bool {
503+
// Folder Mirror:真实文件夹 + 标记文件,标记记录的外部路径匹配即视为已链接
504+
if let mirrorExternal = AppMigrationService.folderMirrorExternalURL(at: localFolderURL),
505+
mirrorExternal == externalFolderURL.standardizedFileURL {
506+
return true
507+
}
508+
509+
// 旧版整体符号链接文件夹
492510
guard let linkDestination = resolveSymlinkDestination(of: localFolderURL) else {
493511
return false
494512
}

AppPortsTests/AppMigrationServiceTests.swift

Lines changed: 139 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ final class AppMigrationServiceTests: XCTestCase {
8787
}
8888
}
8989

90-
func testFolderMoveAndRestoreUsesSingleFolderSymlink() async throws {
90+
func testFolderMoveAndRestoreUsesFolderMirror() async throws {
9191
let workspace = try makeWorkspace()
9292
defer { cleanupWorkspace(workspace.rootURL) }
9393

@@ -96,6 +96,10 @@ final class AppMigrationServiceTests: XCTestCase {
9696
try fileManager.createDirectory(at: localSuiteURL, withIntermediateDirectories: true)
9797
try createAppBundle(at: localSuiteURL.appendingPathComponent("Word.app"))
9898
try createAppBundle(at: localSuiteURL.appendingPathComponent("Excel.app"))
99+
// 非 app 条目:单文件 + 子目录
100+
try "manual".write(to: localSuiteURL.appendingPathComponent("Manual.pdf"), atomically: true, encoding: .utf8)
101+
try fileManager.createDirectory(at: localSuiteURL.appendingPathComponent("Documents"), withIntermediateDirectories: true)
102+
try "license".write(to: localSuiteURL.appendingPathComponent("Documents/License.txt"), atomically: true, encoding: .utf8)
99103

100104
let service = AppMigrationService()
101105
let suiteItem = AppItem(
@@ -113,9 +117,16 @@ final class AppMigrationServiceTests: XCTestCase {
113117
progressHandler: nil
114118
)
115119

116-
try assertWholeAppSymlink(localSuiteURL, pointsTo: externalSuiteURL)
120+
// 本地是真实文件夹(非 symlink),内部 app 为 Stub,非 app 为符号链接,含标记文件
121+
try assertFolderMirror(localSuiteURL, stubAppNames: ["Word.app", "Excel.app"], externalURL: externalSuiteURL)
122+
try assertSymlink(localSuiteURL.appendingPathComponent("Manual.pdf"), pointsTo: externalSuiteURL.appendingPathComponent("Manual.pdf"))
123+
try assertSymlink(localSuiteURL.appendingPathComponent("Documents"), pointsTo: externalSuiteURL.appendingPathComponent("Documents"))
124+
// 内部 app 未被展开到 /Applications 顶层
117125
XCTAssertFalse(fileManager.fileExists(atPath: workspace.localAppsURL.appendingPathComponent("Word.app").path))
118126
XCTAssertFalse(fileManager.fileExists(atPath: workspace.localAppsURL.appendingPathComponent("Excel.app").path))
127+
// 外部为真实套件副本,且不含本地标记文件
128+
try assertRealAppBundle(externalSuiteURL.appendingPathComponent("Word.app"))
129+
XCTAssertFalse(fileManager.fileExists(atPath: externalSuiteURL.appendingPathComponent(AppMigrationService.folderPortalMarkerName).path))
119130

120131
try await service.moveBack(
121132
app: AppItem(
@@ -129,13 +140,90 @@ final class AppMigrationServiceTests: XCTestCase {
129140
progressHandler: nil
130141
)
131142

132-
XCTAssertFalse(fileManager.fileExists(atPath: workspace.localAppsURL.appendingPathComponent("Word.app").path))
133-
XCTAssertFalse(fileManager.fileExists(atPath: workspace.localAppsURL.appendingPathComponent("Excel.app").path))
143+
// 还原后本地为真实套件,标记消失,外部已删除
134144
try assertRealAppBundle(localSuiteURL.appendingPathComponent("Word.app"))
135145
try assertRealAppBundle(localSuiteURL.appendingPathComponent("Excel.app"))
146+
XCTAssertFalse(fileManager.fileExists(atPath: localSuiteURL.appendingPathComponent(AppMigrationService.folderPortalMarkerName).path))
147+
XCTAssertEqual(try String(contentsOf: localSuiteURL.appendingPathComponent("Manual.pdf"), encoding: .utf8), "manual")
136148
XCTAssertFalse(fileManager.fileExists(atPath: externalSuiteURL.path))
137149
}
138150

151+
func testFolderMirrorDeleteLinkRemovesMirrorKeepsExternal() async throws {
152+
let workspace = try makeWorkspace()
153+
defer { cleanupWorkspace(workspace.rootURL) }
154+
155+
let localSuiteURL = workspace.localAppsURL.appendingPathComponent("Office")
156+
let externalSuiteURL = workspace.externalRootURL.appendingPathComponent("Office")
157+
try fileManager.createDirectory(at: localSuiteURL, withIntermediateDirectories: true)
158+
try createAppBundle(at: localSuiteURL.appendingPathComponent("Word.app"))
159+
try createAppBundle(at: localSuiteURL.appendingPathComponent("Excel.app"))
160+
161+
let service = AppMigrationService()
162+
try await service.moveAndLink(
163+
appToMove: AppItem(name: "Office", path: localSuiteURL, status: "本地", isFolder: true, appCount: 2),
164+
destinationURL: externalSuiteURL,
165+
isRunning: false,
166+
progressHandler: nil
167+
)
168+
try assertFolderMirror(localSuiteURL, stubAppNames: ["Word.app", "Excel.app"], externalURL: externalSuiteURL)
169+
170+
// 解链:删除本地镜像,外部真实套件保持完好
171+
try service.deleteLink(app: AppItem(name: "Office", path: localSuiteURL, status: "已链接", isFolder: true, appCount: 2))
172+
XCTAssertFalse(fileManager.fileExists(atPath: localSuiteURL.path))
173+
try assertRealAppBundle(externalSuiteURL.appendingPathComponent("Word.app"))
174+
try assertRealAppBundle(externalSuiteURL.appendingPathComponent("Excel.app"))
175+
}
176+
177+
func testRefreshFolderMirrorSyncsAddedAndRemovedEntries() async throws {
178+
let workspace = try makeWorkspace()
179+
defer { cleanupWorkspace(workspace.rootURL) }
180+
181+
let localSuiteURL = workspace.localAppsURL.appendingPathComponent("Office")
182+
let externalSuiteURL = workspace.externalRootURL.appendingPathComponent("Office")
183+
try fileManager.createDirectory(at: localSuiteURL, withIntermediateDirectories: true)
184+
try createAppBundle(at: localSuiteURL.appendingPathComponent("Word.app"))
185+
try createAppBundle(at: localSuiteURL.appendingPathComponent("Excel.app"))
186+
187+
let service = AppMigrationService()
188+
try await service.moveAndLink(
189+
appToMove: AppItem(name: "Office", path: localSuiteURL, status: "本地", isFolder: true, appCount: 2),
190+
destinationURL: externalSuiteURL,
191+
isRunning: false,
192+
progressHandler: nil
193+
)
194+
try assertFolderMirror(localSuiteURL, stubAppNames: ["Word.app", "Excel.app"], externalURL: externalSuiteURL)
195+
196+
// 模拟外部套件被更新:新增 PowerPoint.app 与 ReadMe.txt,删除 Excel.app
197+
try createAppBundle(at: externalSuiteURL.appendingPathComponent("PowerPoint.app"))
198+
try "read me".write(to: externalSuiteURL.appendingPathComponent("ReadMe.txt"), atomically: true, encoding: .utf8)
199+
try fileManager.removeItem(at: externalSuiteURL.appendingPathComponent("Excel.app"))
200+
201+
service.refreshFolderMirror(at: localSuiteURL, from: externalSuiteURL)
202+
203+
// 新增项被镜像,删除项被清理,保留项不变,标记仍在
204+
try assertStubPortal(localSuiteURL.appendingPathComponent("Word.app"), pointsTo: externalSuiteURL.appendingPathComponent("Word.app"))
205+
try assertStubPortal(localSuiteURL.appendingPathComponent("PowerPoint.app"), pointsTo: externalSuiteURL.appendingPathComponent("PowerPoint.app"))
206+
try assertSymlink(localSuiteURL.appendingPathComponent("ReadMe.txt"), pointsTo: externalSuiteURL.appendingPathComponent("ReadMe.txt"))
207+
XCTAssertFalse(fileManager.fileExists(atPath: localSuiteURL.appendingPathComponent("Excel.app").path))
208+
XCTAssertTrue(fileManager.fileExists(atPath: localSuiteURL.appendingPathComponent(AppMigrationService.folderPortalMarkerName).path))
209+
}
210+
211+
func testRefreshFolderMirrorIgnoresLegacySymlinkFolder() throws {
212+
let workspace = try makeWorkspace()
213+
defer { cleanupWorkspace(workspace.rootURL) }
214+
215+
// 旧版整体符号链接文件夹(无标记文件)
216+
let externalSuiteURL = workspace.externalRootURL.appendingPathComponent("Office")
217+
try fileManager.createDirectory(at: externalSuiteURL, withIntermediateDirectories: true)
218+
try createAppBundle(at: externalSuiteURL.appendingPathComponent("Word.app"))
219+
let localSuiteURL = workspace.localAppsURL.appendingPathComponent("Office")
220+
try fileManager.createSymbolicLink(at: localSuiteURL, withDestinationURL: externalSuiteURL)
221+
222+
// 应安全跳过:不抛错,不改动符号链接
223+
AppMigrationService().refreshFolderMirror(at: localSuiteURL, from: externalSuiteURL)
224+
try assertWholeAppSymlink(localSuiteURL, pointsTo: externalSuiteURL)
225+
}
226+
139227
func testIOSRelinkUsesStubPortal() throws {
140228
let workspace = try makeWorkspace()
141229
defer { cleanupWorkspace(workspace.rootURL) }
@@ -425,14 +513,22 @@ final class AppMigrationServiceTests: XCTestCase {
425513
let launcherURL = localURL.appendingPathComponent("Contents/MacOS/launcher")
426514
XCTAssertTrue(fileManager.fileExists(atPath: launcherURL.path), "launcher script should exist", file: file, line: line)
427515

516+
// 解析符号链接后比较,规避测试临时目录位于 /var -> /private/var 软链导致的路径差异
517+
let expectedResolved = externalURL.resolvingSymlinksInPath().path
428518
let pathFileURL = localURL.appendingPathComponent("Contents/Resources/real_app_path.txt")
429519
if fileManager.fileExists(atPath: pathFileURL.path) {
430520
let path = try String(contentsOf: pathFileURL, encoding: .utf8)
431521
.trimmingCharacters(in: .whitespacesAndNewlines)
432-
XCTAssertEqual(path, externalURL.path, "launcher path file should reference external app", file: file, line: line)
522+
let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
523+
XCTAssertEqual(resolved, expectedResolved, "launcher path file should reference external app", file: file, line: line)
433524
} else {
434525
let script = try String(contentsOf: launcherURL, encoding: .utf8)
435-
XCTAssertTrue(script.contains(externalURL.path), "launcher should reference external app", file: file, line: line)
526+
XCTAssertTrue(
527+
script.contains(externalURL.path) || script.contains(expectedResolved),
528+
"launcher should reference external app",
529+
file: file,
530+
line: line
531+
)
436532
}
437533
}
438534

@@ -442,6 +538,43 @@ final class AppMigrationServiceTests: XCTestCase {
442538
XCTAssertEqual(resolvedDestination, externalURL.standardizedFileURL, file: file, line: line)
443539
}
444540

541+
private func assertSymlink(_ localURL: URL, pointsTo target: URL, file: StaticString = #filePath, line: UInt = #line) throws {
542+
let destination = try fileManager.destinationOfSymbolicLink(atPath: localURL.path)
543+
let resolvedDestination = URL(fileURLWithPath: destination, relativeTo: localURL.deletingLastPathComponent()).standardizedFileURL
544+
XCTAssertEqual(resolvedDestination, target.standardizedFileURL, file: file, line: line)
545+
}
546+
547+
private func assertFolderMirror(
548+
_ localURL: URL,
549+
stubAppNames: [String],
550+
externalURL: URL,
551+
file: StaticString = #filePath,
552+
line: UInt = #line
553+
) throws {
554+
let values = try localURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
555+
XCTAssertEqual(values.isDirectory, true, "mirror should be a real directory", file: file, line: line)
556+
XCTAssertNotEqual(values.isSymbolicLink, true, "mirror should not be a symlink", file: file, line: line)
557+
558+
let markerURL = localURL.appendingPathComponent(AppMigrationService.folderPortalMarkerName)
559+
XCTAssertTrue(fileManager.fileExists(atPath: markerURL.path), "mirror marker should exist", file: file, line: line)
560+
XCTAssertEqual(
561+
AppMigrationService.folderMirrorExternalURL(at: localURL),
562+
externalURL.standardizedFileURL,
563+
"marker should record external folder path",
564+
file: file,
565+
line: line
566+
)
567+
568+
for name in stubAppNames {
569+
try assertStubPortal(
570+
localURL.appendingPathComponent(name),
571+
pointsTo: externalURL.appendingPathComponent(name),
572+
file: file,
573+
line: line
574+
)
575+
}
576+
}
577+
445578
private func assertRealAppBundle(_ appURL: URL, file: StaticString = #filePath, line: UInt = #line) throws {
446579
let appValues = try appURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
447580
XCTAssertEqual(appValues.isDirectory, true, file: file, line: line)

Tools/StubLauncher/StubLauncherSource.swift

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,58 @@ import Cocoa
99
/// Companion file at Contents/Resources/real_app_path.txt contains
1010
/// the path to the real .app bundle.
1111

12+
/// Localization for the "external storage missing" system dialog.
13+
///
14+
/// This launcher runs as its own process, separate from the AppPorts app,
15+
/// so it cannot use the app's `.localized` helper. Instead it picks the
16+
/// message in the Mac's system language and falls back to English.
17+
///
18+
/// The translations mirror the `外部存储未连接,请连接后重试。` and `好`
19+
/// entries in `Localizable.xcstrings` — keep both in sync.
20+
enum DiskMissingStrings {
21+
/// (message, OK button title) keyed by xcstrings language code.
22+
static let table: [String: (message: String, button: String)] = [
23+
"en": ("External storage is not connected. Connect it and try again.", "OK"),
24+
"ar": ("وحدة التخزين الخارجية غير متصلة. صِلها ثم حاول مرة أخرى.", "موافق"),
25+
"de": ("Externer Speicher ist nicht verbunden. Verbinden Sie ihn und versuchen Sie es erneut.", "OK"),
26+
"eo": ("Ekstera konservejo ne estas konektita. Konektu ĝin kaj reprovu.", "Bone"),
27+
"es": ("El almacenamiento externo no está conectado. Conéctalo e inténtalo de nuevo.", "Aceptar"),
28+
"fr": ("Le stockage externe n'est pas connecté. Connectez-le, puis réessayez.", "OK"),
29+
"hi": ("बाहरी स्टोरेज कनेक्ट नहीं है। उसे कनेक्ट करके फिर कोशिश करें।", "ठीक है"),
30+
"id": ("Penyimpanan eksternal belum tersambung. Sambungkan lalu coba lagi.", "Oke"),
31+
"it": ("L'archiviazione esterna non è collegata. Collegala e riprova.", "OK"),
32+
"ja": ("外部ストレージが接続されていません。接続してからもう一度お試しください。", "OK"),
33+
"ko": ("외부 저장소가 연결되어 있지 않습니다. 연결한 뒤 다시 시도하세요.", "확인"),
34+
"nl": ("Externe opslag is niet aangesloten. Sluit deze aan en probeer het opnieuw.", "OK"),
35+
"pl": ("Pamięć zewnętrzna nie jest podłączona. Podłącz ją i spróbuj ponownie.", "OK"),
36+
"pt": ("O armazenamento externo não está conectado. Conecte-o e tente novamente.", "OK"),
37+
"ru": ("Внешний накопитель не подключен. Подключите его и повторите попытку.", "ОК"),
38+
"th": ("ยังไม่ได้เชื่อมต่อที่จัดเก็บข้อมูลภายนอก โปรดเชื่อมต่อแล้วลองอีกครั้ง", "ตกลง"),
39+
"tr": ("Harici depolama bağlı değil. Bağlayıp yeniden deneyin.", "Tamam"),
40+
"vi": ("Bộ nhớ ngoài chưa được kết nối. Hãy kết nối rồi thử lại.", "OK"),
41+
"zh-Hans": ("外部存储未连接,请连接后重试。", ""),
42+
"zh-Hant": ("外部儲存裝置未連接,請連接後再試。", ""),
43+
]
44+
45+
/// Picks the best entry for the user's preferred languages, defaulting to English.
46+
static var current: (message: String, button: String) {
47+
for preferred in Locale.preferredLanguages {
48+
// Try progressively shorter prefixes, e.g.
49+
// "zh-Hant-TW" → "zh-Hant" → "zh", "en-US" → "en".
50+
let components = preferred.split(separator: "-").map(String.init)
51+
var count = components.count
52+
while count > 0 {
53+
let candidate = components[0..<count].joined(separator: "-")
54+
if let entry = table[candidate] { return entry }
55+
count -= 1
56+
}
57+
// Generic Chinese with no script subtag → Simplified.
58+
if components.first == "zh" { return table["zh-Hans"]! }
59+
}
60+
return table["en"]!
61+
}
62+
}
63+
1264
final class AppDelegate: NSObject, NSApplicationDelegate {
1365
var realAppPath: String?
1466

@@ -62,13 +114,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
62114
try? proc.run()
63115
}
64116

117+
/// Wraps a string as an AppleScript double-quoted literal, escaping `\` and `"`.
118+
private func appleScriptLiteral(_ value: String) -> String {
119+
let escaped = value
120+
.replacingOccurrences(of: "\\", with: "\\\\")
121+
.replacingOccurrences(of: "\"", with: "\\\"")
122+
return "\"\(escaped)\""
123+
}
124+
65125
private func showExternalDiskMissing() {
126+
let strings = DiskMissingStrings.current
127+
let script = "display dialog \(appleScriptLiteral(strings.message)) "
128+
+ "buttons {\(appleScriptLiteral(strings.button))} default button 1 with icon caution"
66129
let proc = Process()
67130
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
68-
proc.arguments = [
69-
"-e",
70-
"display dialog \"外部存储未连接,请连接后重试。\" buttons {\"\"} default button 1 with icon caution"
71-
]
131+
proc.arguments = ["-e", script]
72132
try? proc.run()
73133
proc.waitUntilExit()
74134
}

0 commit comments

Comments
 (0)