From a5b269ee204140cf33b8351475609cc14620ea21 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 14 Oct 2026 17:14:32 +0800 Subject: [PATCH] feat(i18n): complete localized screenshots and language ordering Localize German, French, and Korean demo content and capture workflows. Preserve real software keyboards and invariant numeric parsing during captures. Sort language choices by code and update the published documentation and marketing asset submodules. --- AgentKit | 2 +- .../Agent/ConversationModel.swift | 28 ++++-- .../TermindApplication/AppLanguage.swift | 2 +- Scripts/Tests/test_screenshots.rb | 17 ++++ Scripts/run-screenshot-demo.sh | 14 ++- .../Screenshots/MobileScreenshotTests.swift | 42 ++++++--- TFM/App/ScreenshotDemo.swift | 36 ++++++-- .../FeatureModels/HostLibraryModel+Demo.swift | 91 +++++++++++++------ TFMTests/AppLanguageTests.swift | 23 +++++ .../Screenshots/ScreenshotDemoTests.swift | 4 +- com.akinokaede.termind | 2 +- fastlane/lib/screenshots.rb | 9 +- 12 files changed, 210 insertions(+), 60 deletions(-) diff --git a/AgentKit b/AgentKit index 849062bd..ce6179f0 160000 --- a/AgentKit +++ b/AgentKit @@ -1 +1 @@ -Subproject commit 849062bd4c246f2f62fddcfa660bd90536139c6a +Subproject commit ce6179f044faf1c9c2927e32d98f69d7d7caec0e diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index f76ccf3c..32b392f6 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -3426,12 +3426,17 @@ extension WeakAgentUserInteraction: AgentUserInteractionHandling {} extension ConversationModel { public static func demo(language: AppLanguage = .english) -> ConversationModel { - func text(_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String) -> String { + func text( + _ en: String, _ zhHans: String, _ zhHant: String, _ ja: String, _ ko: String, _ de: String, _ fr: String + ) -> String { switch language { case .simplifiedChinese: zhHans case .traditionalChinese: zhHant case .japanese: ja - case .system, .english, .german, .french, .korean: en + case .korean: ko + case .german: de + case .french: fr + case .system, .english: en } } let now = Date(timeIntervalSinceReferenceDate: 809_020_800) @@ -3440,7 +3445,10 @@ extension ConversationModel { text: text( "Find the cause of the 502 responses. Inspect only—do not change configuration.", "查明 502 响应的原因。仅检查,不要更改配置。", - "找出 502 回應的原因。僅進行檢查,不要變更設定。", "502応答の原因を調べてください。調査のみを行い、設定は変更しないでください。"), + "找出 502 回應的原因。僅進行檢查,不要變更設定。", "502応答の原因を調べてください。調査のみを行い、設定は変更しないでください。", + "502 응답의 원인을 찾아 주세요. 점검만 하고 설정은 변경하지 마세요.", + "Finde die Ursache der 502-Antworten. Nur prüfen, keine Konfiguration ändern.", + "Trouvez la cause des réponses 502. Analysez uniquement, sans modifier la configuration."), createdAt: now.addingTimeInterval(-18) ) let answer = ChatMessage( @@ -3449,14 +3457,20 @@ extension ConversationModel { "The upstream service is not listening on `127.0.0.1:8080`. Nginx itself is healthy, and no configuration was changed. Start the upstream service, then verify `/health` before considering a reload.", "上游服务没有监听 `127.0.0.1:8080`。Nginx 本身运行正常,且没有更改任何配置。请先启动上游服务并验证 `/health`,再考虑重新加载。", "上游服務未監聽 `127.0.0.1:8080`。Nginx 本身運作正常,且未變更任何設定。請先啟動上游服務並驗證 `/health`,再考慮重新載入。", - "上流サービスが`127.0.0.1:8080`で待ち受けていません。Nginx自体は正常で、設定は変更していません。まず上流サービスを起動して`/health`を確認し、その後で再読み込みを検討してください。" + "上流サービスが`127.0.0.1:8080`で待ち受けていません。Nginx自体は正常で、設定は変更していません。まず上流サービスを起動して`/health`を確認し、その後で再読み込みを検討してください。", + "업스트림 서비스가 `127.0.0.1:8080`에서 수신 대기하고 있지 않아요. Nginx는 정상이며 설정은 변경하지 않았어요. 먼저 업스트림 서비스를 시작하고 `/health`를 확인한 뒤 다시 로드를 검토하세요.", + "Der Upstream-Dienst lauscht nicht auf `127.0.0.1:8080`. Nginx läuft ordnungsgemäß; es wurde keine Konfiguration geändert. Starte den Upstream-Dienst und prüfe `/health`, bevor du ein Neuladen erwägst.", + "Le service en amont n’écoute pas sur `127.0.0.1:8080`. Nginx fonctionne normalement et aucune configuration n’a été modifiée. Démarrez le service en amont, puis vérifiez `/health` avant d’envisager un rechargement." ), createdAt: now ) let runID = UUID() let descriptor = AgentToolDescriptor( name: "ssh_exec", - summary: text("Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。", "リモートホストでコマンドを実行します。"), + summary: text( + "Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。", "リモートホストでコマンドを実行します。", + "원격 호스트에서 명령을 실행해요.", "Einen Befehl auf einem Remote-Host ausführen.", + "Exécuter une commande sur un hôte distant."), inputSchema: .object([:]), target: .host, approvalPolicy: .approve, concurrency: .parallel, presentation: AgentToolDescriptor.Presentation( @@ -3493,7 +3507,9 @@ extension ConversationModel { ) ] ) - conversation.customTitle = text("Diagnose 502 responses", "诊断 502 响应", "診斷 502 回應", "502応答の診断") + conversation.customTitle = text( + "Diagnose 502 responses", "诊断 502 响应", "診斷 502 回應", "502応答の診断", "502 응답 진단", "502-Antworten untersuchen", + "Diagnostiquer les réponses 502") conversation.messages = [ user, answer, ] diff --git a/Frameworks/TermindApplication/AppLanguage.swift b/Frameworks/TermindApplication/AppLanguage.swift index 94050b6b..c200b918 100644 --- a/Frameworks/TermindApplication/AppLanguage.swift +++ b/Frameworks/TermindApplication/AppLanguage.swift @@ -43,8 +43,8 @@ public nonisolated enum AppLocalization { public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendable { case system - case english = "en" case german = "de" + case english = "en" case french = "fr" case japanese = "ja" case korean = "ko" diff --git a/Scripts/Tests/test_screenshots.rb b/Scripts/Tests/test_screenshots.rb index b5d3b722..d72bd0b4 100644 --- a/Scripts/Tests/test_screenshots.rb +++ b/Scripts/Tests/test_screenshots.rb @@ -103,6 +103,23 @@ class ScreenshotsTests < Minitest::Test assert ScreenshotFrames.new({ locales: "ja" }, root: @root).plan.all? { |job| job[:output].include?("/ja/") } end + def test_additional_marketing_locales_configure_native_capture_and_frame_paths + { "ko" => ["KR", "ko_KR"], "de" => ["DE", "de_DE"], "fr" => ["FR", "fr_FR"] }.each do |language, (region, locale)| + %i[mac ios].each do |platform| + capture = runner(platform, locales: language) + target = {} + key = platform == :mac ? "TFMTests" : "TFITests" + capture.configure_xctestrun({ key => target }, language, {}, []) + assert_equal language, target["TestLanguage"] + assert_equal region, target["TestRegion"] + assert_equal "C", target["EnvironmentVariables"]["LC_NUMERIC"] + assert_equal ["-AppleLanguages", "(#{language})", "-AppleLocale", locale], target["CommandLineArguments"] + end + jobs = ScreenshotFrames.new({ locales: language }, root: @root).plan + assert jobs.all? { |job| job[:input].include?("/#{language}/") && job[:output].include?("/#{language}/") } + end + end + def test_test_plan_format_updates_only_the_capture_target capture = runner(:mac) target = { "BlueprintName" => "TFMTests" } diff --git a/Scripts/run-screenshot-demo.sh b/Scripts/run-screenshot-demo.sh index bd34b57e..c865f0c8 100755 --- a/Scripts/run-screenshot-demo.sh +++ b/Scripts/run-screenshot-demo.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { - print -u2 "Usage: Scripts/run-screenshot-demo.sh " + print -u2 "Usage: Scripts/run-screenshot-demo.sh " } if (( $# != 2 )); then @@ -32,6 +32,18 @@ case "$language" in apple_language="ja" apple_locale="ja_JP" ;; + ko) + apple_language="ko" + apple_locale="ko_KR" + ;; + de) + apple_language="de" + apple_locale="de_DE" + ;; + fr) + apple_language="fr" + apple_locale="fr_FR" + ;; zh-Hans) apple_language="zh-Hans" apple_locale="zh-Hans" diff --git a/TFITests/Screenshots/MobileScreenshotTests.swift b/TFITests/Screenshots/MobileScreenshotTests.swift index b2e45a66..7bc8a795 100644 --- a/TFITests/Screenshots/MobileScreenshotTests.swift +++ b/TFITests/Screenshots/MobileScreenshotTests.swift @@ -25,7 +25,7 @@ struct MobileScreenshotTests { if let language = ProcessInfo.processInfo.environment["TERMIND_SCREENSHOT_LOCALE"] { return [language] } - return ["en", "zh-Hans", "zh-Hant", "ja"] + return ["en", "zh-Hans", "zh-Hant", "ja", "ko", "de", "fr"] } @Test func renderScenes() async throws { @@ -48,7 +48,7 @@ struct MobileScreenshotTests { } if includes("ssh") { - try await withEnglishKeyboard { + try await withTerminalKeyboard { try await captureWorkspace(.ssh, language: language) } } @@ -83,8 +83,10 @@ struct MobileScreenshotTests { language: String, connectionServices: MobileWorkspaceConnectionServices = .live ) throws -> MobileAppModel { let app = try MobileAppModel(inMemory: true, workspaceConnectionServices: connectionServices) - let production = HostGroup(name: text(language, "Production", "生产环境", "正式環境", "本番環境")) - let edge = HostGroup(name: text(language, "Edge", "边缘节点", "邊緣節點", "エッジ")) + let production = HostGroup( + name: text(language, "Production", "生产环境", "正式環境", "本番環境", "운영 환경", "Produktion", "Production")) + let edge = HostGroup( + name: text(language, "Edge", "边缘节点", "邊緣節點", "エッジ", "에지", "Edge", "Périphérie")) app.library.upsert(production) app.library.upsert(edge) let hosts = [ @@ -134,7 +136,10 @@ struct MobileScreenshotTests { if kind == .sftp { #expect( app.testWorkspaceSession.renameWorkspace( - id, to: text(language, "Production files", "生产文件", "正式環境檔案", "本番環境のファイル"))) + id, + to: text( + language, "Production files", "生产文件", "正式環境檔案", "本番環境のファイル", + "운영 파일", "Produktionsdateien", "Fichiers de production"))) try #require(workspace.sftpChannel.files.count == 6) } let scene = MobileSceneModel(app: app, sessionID: "test") @@ -145,8 +150,12 @@ struct MobileScreenshotTests { } } - private func withEnglishKeyboard(_ capture: () async throws -> Void) async throws { - let mode = try #require(UITextInputMode.activeInputModes.first { $0.primaryLanguage?.hasPrefix("en") == true }) + private func withTerminalKeyboard(_ capture: () async throws -> Void) async throws { + let modes = UITextInputMode.activeInputModes + let mode = try #require( + modes.first { $0.primaryLanguage?.hasPrefix("en") == true } + ?? modes.first { $0.primaryLanguage != nil && $0.primaryLanguage != "emoji" }, + "The capture simulator needs a text keyboard") let selector = #selector(getter: UIResponder.textInputMode) let method = try #require(class_getInstanceMethod(MobileTerminalView.self, selector)) let original = method_getImplementation(method) @@ -160,7 +169,8 @@ struct MobileScreenshotTests { let typeGetter: @convention(block) (AnyObject) -> Int = { _ in UIKeyboardType.asciiCapable.rawValue } let typeReplacement = imp_implementationWithBlock(typeGetter) // Scope the public input-mode getter override to this opt-in capture. - // UIKit still renders its real keyboard, independently of the app locale. + // XCTest may install only the test language's keyboard. Prefer English + // when available, otherwise keep the native localized text keyboard. // ASCII mode also excludes the bilingual keyboard's Pinyin/Zhuyin toggle. class_replaceMethod(MobileTerminalView.self, selector, replacement, encoding) class_replaceMethod(MobileTerminalView.self, typeSelector, typeReplacement, typeEncoding) @@ -272,7 +282,9 @@ struct MobileScreenshotTests { try await Task.sleep(for: .milliseconds(50)) } try #require(terminal.isFirstResponder) - try #require(terminal.textInputMode?.primaryLanguage?.hasPrefix("en") == true) + let keyboardLanguage = try #require(terminal.textInputMode?.primaryLanguage) + try #require(keyboardLanguage != "emoji") + try #require(terminal.keyboardType == .asciiCapable) try #require(accessory.window != nil) // An accessory-only hardware keyboard bar ends at the bottom safe area. // A software keyboard has its key rows below the accessory instead. @@ -345,18 +357,26 @@ struct MobileScreenshotTests { private func screenshotBackButton(language: String) -> some ToolbarContent { ToolbarItem(placement: .navigationBarLeading) { Button(action: {}) { - Label(text(language, "Workspaces", "工作区", "工作區", "ワークスペース"), systemImage: "chevron.left") + Label( + text( + language, "Workspaces", "工作区", "工作區", "ワークスペース", + "작업 공간", "Arbeitsbereiche", "Espaces de travail"), + systemImage: "chevron.left") } } } private func text( - _ language: String, _ english: String, _ simplified: String, _ traditional: String, _ japanese: String + _ language: String, _ english: String, _ simplified: String, _ traditional: String, _ japanese: String, + _ korean: String, _ german: String, _ french: String ) -> String { switch language { case "zh-Hans": simplified case "zh-Hant": traditional case "ja": japanese + case "ko": korean + case "de": german + case "fr": french default: english } } diff --git a/TFM/App/ScreenshotDemo.swift b/TFM/App/ScreenshotDemo.swift index bc57e5f6..aed0d63a 100644 --- a/TFM/App/ScreenshotDemo.swift +++ b/TFM/App/ScreenshotDemo.swift @@ -29,12 +29,12 @@ import TermindApplication return "Pass --termind-demo only once." case .invalidFormat(let value): return "Invalid Demo argument \(value). Expected " - + "--termind-demo=:." + + "--termind-demo=:." case .unknownScene(let value): return "Unknown Demo scene \(value). Expected hosts, workspace, agent, command, metrics, metrics-detail, or app-settings." case .unknownLanguage(let value): - return "Unknown Demo language \(value). Expected en, ja, zh-Hans, or zh-Hant." + return "Unknown Demo language \(value). Expected en, ja, ko, de, fr, zh-Hans, or zh-Hant." } } } @@ -151,10 +151,13 @@ import TermindApplication let session = SSHSessionModel.demo(host: host) let pane = Pane(.sshSession(session)) - pane.customTitle = text(en: "nginx diagnostics", zhHans: "Nginx 诊断", zhHant: "Nginx 診斷", ja: "nginxの診断") + pane.customTitle = text( + en: "nginx diagnostics", zhHans: "Nginx 诊断", zhHant: "Nginx 診斷", ja: "nginxの診断", ko: "nginx 진단", + de: "nginx-Diagnose", fr: "Diagnostic nginx") let workspace = Workspace(pane: pane) workspace.customTitle = text( - en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断") + en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断", ko: "운영 환경 진단", + de: "Produktionsdiagnose", fr: "Diagnostic de production") workspace.focusedPaneID = pane.id let conversation = ConversationModel.demo(language: language) @@ -196,12 +199,16 @@ import TermindApplication let first = Pane(.sshSession(firstSession)) first.customTitle = showsCommandResult - ? text(en: "nginx logs", zhHans: "Nginx 日志", zhHant: "Nginx 日誌", ja: "nginxのログ") + ? text( + en: "nginx logs", zhHans: "Nginx 日志", zhHant: "Nginx 日誌", ja: "nginxのログ", ko: "nginx 로그", + de: "nginx-Protokolle", fr: "Journaux nginx") : "api-01" let sftpChannel = SFTPChannelModel.demo(hostID: saved[0].id) sftpChannel.selection = ["/etc/nginx/nginx.conf"] let sftp = Pane(.sftpChannel(sftpChannel)) - sftp.customTitle = text(en: "nginx config", zhHans: "Nginx 配置", zhHant: "Nginx 設定", ja: "nginxの設定") + sftp.customTitle = text( + en: "nginx config", zhHans: "Nginx 配置", zhHant: "Nginx 設定", ja: "nginxの設定", ko: "nginx 설정", + de: "nginx-Konfiguration", fr: "Configuration nginx") let secondSession = showsCommandResult ? SSHSessionModel.demo(host: saved[1]) @@ -235,8 +242,12 @@ import TermindApplication workspace.focusedPaneID = first.id workspace.customTitle = showsCommandResult - ? text(en: "Log investigation", zhHans: "日志调查", zhHant: "日誌調查", ja: "ログの調査") - : text(en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断") + ? text( + en: "Log investigation", zhHans: "日志调查", zhHant: "日誌調查", ja: "ログの調査", ko: "로그 분석", + de: "Protokollanalyse", fr: "Analyse des journaux") + : text( + en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断", ko: "운영 환경 진단", + de: "Produktionsdiagnose", fr: "Diagnostic de production") workspace.broadcastsInput = !showsCommandResult if showsCommandResult { workspace.commandGeneratorState = .inserted(targetCount: 1) @@ -281,12 +292,17 @@ import TermindApplication ]).joined(separator: "\r\n") } - private func text(en: String, zhHans: String, zhHant: String, ja: String) -> String { + private func text(en: String, zhHans: String, zhHant: String, ja: String, ko: String, de: String, fr: String) + -> String + { switch language { case .simplifiedChinese: zhHans case .traditionalChinese: zhHant case .japanese: ja - case .system, .english, .german, .french, .korean: en + case .korean: ko + case .german: de + case .french: fr + case .system, .english: en } } } diff --git a/TFM/Application/FeatureModels/HostLibraryModel+Demo.swift b/TFM/Application/FeatureModels/HostLibraryModel+Demo.swift index 49e5f12b..1336cc5c 100644 --- a/TFM/Application/FeatureModels/HostLibraryModel+Demo.swift +++ b/TFM/Application/FeatureModels/HostLibraryModel+Demo.swift @@ -10,12 +10,17 @@ extension HostLibraryModel { /// Seed data for P0, so the sidebar and workspace list can be exercised before any /// persistence or networking exists. static func demo(language: AppLanguage = .english, includesConnectionActivity: Bool = true) -> HostLibraryModel { - func text(_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String) -> String { + func text( + _ en: String, _ zhHans: String, _ zhHant: String, _ ja: String, _ ko: String, _ de: String, _ fr: String + ) -> String { switch language { case .simplifiedChinese: zhHans case .traditionalChinese: zhHant case .japanese: ja - case .system, .english, .german, .french, .korean: en + case .korean: ko + case .german: de + case .french: fr + case .system, .english: en } } func distribution(_ id: String) -> DetectedDistribution { @@ -25,22 +30,27 @@ extension HostLibraryModel { return distribution } - let production = HostGroup(name: text("Production", "生产环境", "正式環境", "本番環境"), sortIndex: 0) - let staging = HostGroup(name: text("Staging", "预发布环境", "測試環境", "ステージング"), sortIndex: 1) - let homelab = HostGroup(name: text("Homelab", "家庭实验室", "家庭實驗室", "ホームラボ"), sortIndex: 2) + let production = HostGroup( + name: text("Production", "生产环境", "正式環境", "本番環境", "운영 환경", "Produktion", "Production"), sortIndex: 0) + let staging = HostGroup( + name: text("Staging", "预发布环境", "測試環境", "ステージング", "스테이징", "Staging", "Préproduction"), sortIndex: 1) + let homelab = HostGroup( + name: text("Homelab", "家庭实验室", "家庭實驗室", "ホームラボ", "홈랩", "Heimlabor", "Labo personnel"), sortIndex: 2) let edge = HostGroup( - name: text("Edge", "边缘节点", "邊緣節點", "エッジ"), parentID: production.id, sortIndex: 0 + name: text("Edge", "边缘节点", "邊緣節點", "エッジ", "에지", "Edge", "Périphérie"), parentID: production.id, sortIndex: 0 ) let now = Date(timeIntervalSinceReferenceDate: 776_000_000) let personalKey = SSHKeyRef( - label: text("Personal", "个人", "個人", "個人用"), algorithm: .ed25519, + label: text("Personal", "个人", "個人", "個人用", "개인용", "Privat", "Personnel"), algorithm: .ed25519, fingerprint: "SHA256:9pKQ2vC0nJ7hR4mXbL1sT8yWfE3dA6uZgN5qOiVxHkM", credentialRef: "termind.key.personal", colorTag: .blue ) let workKey = SSHKeyRef( - label: text("Work (no sync)", "工作(不同步)", "工作(不同步)", "仕事用(同期なし)"), algorithm: .ecdsa256, + label: text( + "Work (no sync)", "工作(不同步)", "工作(不同步)", "仕事用(同期なし)", "업무용 (동기화 안 함)", "Arbeit (ohne Sync)", + "Travail (sans synchro)"), algorithm: .ecdsa256, fingerprint: "SHA256:3tBn8xLpQ2wZ7yKvR1mHcE9dS4fJ6uA0gT5iNqOxVbY", credentialRef: "termind.key.work", storage: .thisDevice, colorTag: .purple @@ -50,25 +60,39 @@ extension HostLibraryModel { // One of each shape, so all four combinations of the two secrets are on // screen at P0: key only, key *and* password, password only, neither. let deploy = Identity( - name: text("Deploy", "部署账号", "部署帳號", "デプロイ"), username: "deploy", keyID: personalKey.id, - notes: text("Production web tier", "生产环境 Web 层", "正式環境 Web 層", "本番環境のWeb層"), + name: text("Deploy", "部署账号", "部署帳號", "デプロイ", "배포", "Deployment", "Déploiement"), username: "deploy", + keyID: personalKey.id, + notes: text( + "Production web tier", "生产环境 Web 层", "正式環境 Web 層", "本番環境のWeb層", "운영 웹 계층", "Produktive Webserver", + "Serveurs web de production"), colorTag: .green ) let postgres = Identity( - name: text("Postgres admin", "Postgres 管理员", "Postgres 管理員", "Postgres管理者"), username: "postgres", + name: text( + "Postgres admin", "Postgres 管理员", "Postgres 管理員", "Postgres管理者", "Postgres 관리자", "Postgres-Admin", + "Admin Postgres"), username: "postgres", credentialRef: "termind.password.postgres", keyID: workKey.id, notes: text( - "Key to get in, password for sudo", "使用密钥登录,密码用于 sudo", "使用金鑰登入,密碼用於 sudo", "ログインには鍵、sudoにはパスワード"), + "Key to get in, password for sudo", "使用密钥登录,密码用于 sudo", "使用金鑰登入,密碼用於 sudo", "ログインには鍵、sudoにはパスワード", + "키로 로그인, sudo에는 암호 사용", "Schlüssel zum Anmelden, Passwort für sudo", + "Clé pour la connexion, mot de passe pour sudo"), colorTag: .orange ) let consoleRoot = Identity( - name: text("Console root", "控制台 root", "主控台 root", "コンソールのroot"), username: "root", + name: text( + "Console root", "控制台 root", "主控台 root", "コンソールのroot", "콘솔 root", "Konsolen-root", "Root console"), + username: "root", credentialRef: "termind.password.console-root", - notes: text("Out-of-band only", "仅限带外管理", "僅限頻外管理", "帯域外管理専用"), colorTag: .red + notes: text( + "Out-of-band only", "仅限带外管理", "僅限頻外管理", "帯域外管理専用", "대역 외 관리 전용", "Nur Out-of-Band", + "Gestion hors bande uniquement"), colorTag: .red ) let unfinished = Identity( - name: text("New admin", "新管理员", "新增管理員", "新しい管理者"), username: "admin", - notes: text("No secret chosen yet", "尚未选择凭据", "尚未選擇認證資訊", "シークレットは未選択") + name: text("New admin", "新管理员", "新增管理員", "新しい管理者", "새 관리자", "Neuer Admin", "Nouvel admin"), + username: "admin", + notes: text( + "No secret chosen yet", "尚未选择凭据", "尚未選擇認證資訊", "シークレットは未選択", "아직 자격 증명을 선택하지 않았어요", + "Noch keine Zugangsdaten gewählt", "Aucun secret sélectionné") ) // Half the fleet is left without an identity on purpose, so the "not set @@ -127,35 +151,43 @@ extension HostLibraryModel { ] let diagnostics = SnippetPackage( - name: text("Diagnostics", "诊断", "診斷", "診断"), sortIndex: 0 + name: text("Diagnostics", "诊断", "診斷", "診断", "진단", "Diagnose", "Diagnostic"), sortIndex: 0 ) let webOperations = SnippetPackage( - name: text("Web Operations", "Web 运维", "Web 維運", "Web運用"), + name: text("Web Operations", "Web 运维", "Web 維運", "Web運用", "웹 운영", "Webbetrieb", "Exploitation web"), parentID: diagnostics.id, sortIndex: 0 ) let snippets = [ Snippet( - title: text("Disk usage by directory", "按目录查看磁盘占用", "依目錄查看磁碟用量", "ディレクトリ別のディスク使用量"), + title: text( + "Disk usage by directory", "按目录查看磁盘占用", "依目錄查看磁碟用量", "ディレクトリ別のディスク使用量", "디렉터리별 디스크 사용량", + "Speicherplatz pro Verzeichnis", "Espace disque par dossier"), body: "du -sh /* | sort -h", packageID: diagnostics.id, targetHostIDs: [hosts[0].id], sortIndex: 0 ), Snippet( - title: text("Top memory consumers", "内存占用最高的进程", "記憶體用量最高的程序", "メモリ使用量の多いプロセス"), + title: text( + "Top memory consumers", "内存占用最高的进程", "記憶體用量最高的程序", "メモリ使用量の多いプロセス", "메모리 사용량이 많은 프로세스", + "Größte RAM-Verbraucher", "Processus les plus gourmands en mémoire"), body: "ps aux --sort=-%mem | head -20", packageID: diagnostics.id, targetHostIDs: [hosts[0].id, hosts[1].id], sortIndex: 1 ), Snippet( - title: text("Tail nginx errors", "持续查看 Nginx 错误", "持續查看 Nginx 錯誤", "nginxのエラーを追跡"), + title: text( + "Tail nginx errors", "持续查看 Nginx 错误", "持續查看 Nginx 錯誤", "nginxのエラーを追跡", "nginx 오류 추적", + "nginx-Fehler verfolgen", "Suivre les erreurs nginx"), body: "tail -f /var/log/nginx/error.log", packageID: webOperations.id, targetHostIDs: [hosts[0].id, hosts[1].id], sortIndex: 0 ), Snippet( - title: text("Listening ports", "监听中的端口", "監聽中的連接埠", "待ち受けポート"), + title: text( + "Listening ports", "监听中的端口", "監聽中的連接埠", "待ち受けポート", "수신 대기 포트", "Lauschende Ports", "Ports en écoute" + ), body: "ss -tulpn", sortIndex: 3 ), ] @@ -189,11 +221,12 @@ extension HostLibraryModel { PortForwardingRule( hostID: db.id, kind: .local, bindPort: 5432, targetHost: "localhost", targetPort: 5432, - label: text("Postgres", "Postgres 数据库", "Postgres 資料庫", "Postgres") + label: text("Postgres", "Postgres 数据库", "Postgres 資料庫", "Postgres", "Postgres", "Postgres", "Postgres") ), PortForwardingRule( hostID: web01.id, kind: .dynamic, bindPort: 1080, - label: text("SOCKS proxy", "SOCKS 代理", "SOCKS Proxy", "SOCKSプロキシ") + label: text( + "SOCKS proxy", "SOCKS 代理", "SOCKS Proxy", "SOCKSプロキシ", "SOCKS 프록시", "SOCKS-Proxy", "Proxy SOCKS") ), ] @@ -216,7 +249,10 @@ extension HostLibraryModel { hostID: web01.id, direction: .upload, localPath: "~/Config/nginx.conf", remotePath: "/etc/nginx/nginx.conf", byteCount: 8_192, - state: .failed(text("Permission denied", "权限被拒绝", "權限遭拒", "アクセスが拒否されました")), + state: .failed( + text( + "Permission denied", "权限被拒绝", "權限遭拒", "アクセスが拒否されました", "권한이 거부되었어요", "Zugriff verweigert", + "Permission refusée")), queuedAt: now.addingTimeInterval(-3_600) ), SFTPTransfer( @@ -247,7 +283,10 @@ extension HostLibraryModel { store.setStatus(.connecting, for: db.id) if let staging = hosts.first(where: { $0.name == "staging-app" }) { store.setStatus( - .failed(text("Connection refused", "连接被拒绝", "連線遭拒", "接続が拒否されました")), for: staging.id + .failed( + text( + "Connection refused", "连接被拒绝", "連線遭拒", "接続が拒否されました", "연결이 거부되었어요", "Verbindung abgelehnt", + "Connexion refusée")), for: staging.id ) } diff --git a/TFMTests/AppLanguageTests.swift b/TFMTests/AppLanguageTests.swift index 34380486..e72477bb 100644 --- a/TFMTests/AppLanguageTests.swift +++ b/TFMTests/AppLanguageTests.swift @@ -7,6 +7,29 @@ import Testing @testable import Termind struct AppLanguageTests { + @Test + func languagePickerKeepsSystemFirstThenSortsByLanguageCode() { + #expect(AppLanguage.allCases.first == .system) + let languageCodes = AppLanguage.allCases.dropFirst().map(\.rawValue) + #expect(languageCodes == languageCodes.sorted()) + } + + @Test(arguments: [AppLanguage.korean, .german, .french]) + @MainActor + func screenshotConversationLocalizesProseWithoutTranslatingTechnicalReferences(language: AppLanguage) { + let english = ConversationModel.demo(language: .english) + let localized = ConversationModel.demo(language: language) + #expect(localized.customTitle != english.customTitle) + #expect(localized.messages.count == english.messages.count) + for (message, source) in zip(localized.messages, english.messages) { + #expect(message.text != source.text) + #expect(message.role == source.role) + } + #expect(localized.messages.last?.text.contains("`127.0.0.1:8080`") == true) + #expect(localized.messages.last?.text.contains("`/health`") == true) + #expect(localized.toolCards.map(\.invocation.call.name) == ["ssh_exec", "ssh_exec"]) + } + @Test(arguments: [ ("en", "SSH authentication failed: key detail; password detail"), ("zh-Hans", "SSH认证失败:key detail;password detail"), diff --git a/TFMTests/Screenshots/ScreenshotDemoTests.swift b/TFMTests/Screenshots/ScreenshotDemoTests.swift index 0d6af88c..109541a9 100644 --- a/TFMTests/Screenshots/ScreenshotDemoTests.swift +++ b/TFMTests/Screenshots/ScreenshotDemoTests.swift @@ -100,7 +100,9 @@ import TermindPresentation @Test func vaultCaptureStatusesMatchItsTwoWorkspacesInEveryLanguage() throws { - for language in [AppLanguage.english, .japanese, .simplifiedChinese, .traditionalChinese] { + for language in [ + AppLanguage.english, .japanese, .korean, .german, .french, .simplifiedChinese, .traditionalChinese, + ] { let configuration = ScreenshotDemoConfiguration(scene: .hosts, language: language) let hosts = configuration.makeHostLibrary() let window = configuration.makeWindow(hosts: hosts) diff --git a/com.akinokaede.termind b/com.akinokaede.termind index 295a2570..7083ab2e 160000 --- a/com.akinokaede.termind +++ b/com.akinokaede.termind @@ -1 +1 @@ -Subproject commit 295a2570ddc787b4dba3f45f69f3c082ece1122b +Subproject commit 7083ab2eb8d1a14c13aebf1aad44859c9d0d396e diff --git a/fastlane/lib/screenshots.rb b/fastlane/lib/screenshots.rb index a61a52e8..6852ef76 100644 --- a/fastlane/lib/screenshots.rb +++ b/fastlane/lib/screenshots.rb @@ -22,7 +22,10 @@ class Screenshots raise "Initialize the website and assets first: git submodule update --init com.akinokaede.termind" end - LOCALES = { "en" => ["US", "en_US"], "zh-Hans" => ["CN", "zh_CN"], "zh-Hant" => ["TW", "zh_TW"], "ja" => ["JP", "ja_JP"] }.freeze + LOCALES = { + "en" => ["US", "en_US"], "zh-Hans" => ["CN", "zh_CN"], "zh-Hant" => ["TW", "zh_TW"], + "ja" => ["JP", "ja_JP"], "ko" => ["KR", "ko_KR"], "de" => ["DE", "de_DE"], "fr" => ["FR", "fr_FR"] + }.freeze MAC_SCENES = { "hosts" => ["vault", "mac"], "workspace" => ["workspace", "mac"], @@ -143,7 +146,9 @@ class Screenshots targets = targets.select { |target| !target.key?("BlueprintName") || target["BlueprintName"] == "#{@scheme}Tests" } raise "Missing #{@scheme}Tests in xctestrun" if targets.empty? targets.each do |target| - target["EnvironmentVariables"] = target.fetch("EnvironmentVariables", {}).merge(environment) + # Ghostty initializes the C locale from the environment. Keep numeric parsing + # invariant for CoreUI glyph geometry, independently of the localized app UI. + target["EnvironmentVariables"] = target.fetch("EnvironmentVariables", {}).merge(environment).merge("LC_NUMERIC" => "C") target["TestLanguage"] = language target["TestRegion"] = LOCALES.fetch(language).first target["CommandLineArguments"] = arguments + ["-AppleLanguages", "(#{language})", "-AppleLocale", LOCALES.fetch(language).last]