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]