From d2f3a80d8dd726ffe500b4e743252fcd2bc7dbb3 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 9 Sep 2026 17:02:03 +0800 Subject: [PATCH] Fix localization --- .../Connections/PortForwardingRule.swift | 2 +- .../Features/Keychain/KeychainPageView.swift | 4 +- .../Features/Keychain/NewIdentitySheet.swift | 2 +- .../Features/Ports/PortForwardingView.swift | 17 +- .../SSH/SSHForwardingSession.swift | 11 +- Termind/Resources/Localizable.xcstrings | 220 ++++++++++++++++++ Termind/UI/Components/SharedChrome.swift | 4 +- TermindTests/AppLanguageTests.swift | 49 ++++ 8 files changed, 290 insertions(+), 19 deletions(-) diff --git a/Termind/Domain/Connections/PortForwardingRule.swift b/Termind/Domain/Connections/PortForwardingRule.swift index 075d2467..e3cdf08f 100644 --- a/Termind/Domain/Connections/PortForwardingRule.swift +++ b/Termind/Domain/Connections/PortForwardingRule.swift @@ -47,7 +47,7 @@ nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, Sendable case .local: "\(bindAddress) → \(Self.address(targetHost ?? "", port: targetPort ?? 0))" case .remote: - "SSH \(bindAddress) → This Mac \(Self.address(targetHost ?? "", port: targetPort ?? 0))" + String(localized: "SSH \(bindAddress) → This Mac \(Self.address(targetHost ?? "", port: targetPort ?? 0))") case .dynamic: "SOCKS5 \(bindAddress)" } } diff --git a/Termind/Features/Keychain/KeychainPageView.swift b/Termind/Features/Keychain/KeychainPageView.swift index e3de71ec..1776f6a7 100644 --- a/Termind/Features/Keychain/KeychainPageView.swift +++ b/Termind/Features/Keychain/KeychainPageView.swift @@ -405,7 +405,7 @@ private struct SoftwareKeyEditor: View { } let existing: SSHKeyRef? - let title: String + let title: LocalizedStringKey let symbol: String let close: () -> Void let loadPrivateKey: (() throws -> String)? @@ -433,7 +433,7 @@ private struct SoftwareKeyEditor: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion init( - existing: SSHKeyRef? = nil, title: String, + existing: SSHKeyRef? = nil, title: LocalizedStringKey, symbol: String, close: @escaping () -> Void, loadPrivateKey: (() throws -> String)? = nil, save: diff --git a/Termind/Features/Keychain/NewIdentitySheet.swift b/Termind/Features/Keychain/NewIdentitySheet.swift index 80f0201e..a25d2e89 100644 --- a/Termind/Features/Keychain/NewIdentitySheet.swift +++ b/Termind/Features/Keychain/NewIdentitySheet.swift @@ -91,7 +91,7 @@ struct NewIdentitySheet: View { var body: some View { VaultDetailScaffold( - title: existing == nil ? configuration.newTitle : configuration.editTitle, + title: LocalizedStringKey(existing == nil ? configuration.newTitle : configuration.editTitle), symbol: "person.badge.key.fill", close: close, closeLabel: requestedClose == nil ? "Back" : "Close", closeSymbol: requestedClose == nil ? "chevron.backward" : "xmark" diff --git a/Termind/Features/Ports/PortForwardingView.swift b/Termind/Features/Ports/PortForwardingView.swift index d8b0d87c..52f9b9ba 100644 --- a/Termind/Features/Ports/PortForwardingView.swift +++ b/Termind/Features/Ports/PortForwardingView.swift @@ -161,7 +161,7 @@ private struct HostForwardingContextMenu: View { var body: some View { let isRunning = app.forwarding.isEnabled(rule.id) Button( - isRunning ? "Stop" : "Start", + isRunning ? String(localized: "Stop") : String(localized: "Start"), systemImage: isRunning ? MenuSymbol.stop : MenuSymbol.start, action: toggle ) @@ -302,19 +302,20 @@ struct PortForwardingEditorSheet: View { } private var bindPortLabel: String { - draft.kind == .dynamic ? "SOCKS Listen Port" : "Listen Port" + draft.kind == .dynamic ? String(localized: "SOCKS Listen Port") : String(localized: "Listen Port") } private var bindAddressLabel: String { switch draft.kind { - case .local: "Listen Address on This Mac" - case .remote: "Listen Address on SSH Host" - case .dynamic: "SOCKS Listen Address on This Mac" + case .local: String(localized: "Listen Address on This Mac") + case .remote: String(localized: "Listen Address on SSH Host") + case .dynamic: String(localized: "SOCKS Listen Address on This Mac") } } private var targetHostLabel: String { - draft.kind == .remote ? "Destination Host on This Mac" : "Destination Host from SSH Host" + draft.kind == .remote + ? String(localized: "Destination Host on This Mac") : String(localized: "Destination Host from SSH Host") } private var exposesNetwork: Bool { @@ -346,8 +347,8 @@ struct PortForwardingEditorSheet: View { switch draft.kind { case .local: return "\(start) → \(ssh) → \(targetHost):\(targetPort)" case .remote: - return "\(ssh) → listen \(start) → This Mac → \(targetHost):\(targetPort)" - case .dynamic: return "SOCKS5 \(start) → \(ssh) → Requested Target" + return String(localized: "\(ssh) → listen \(start) → This Mac → \(targetHost):\(targetPort)") + case .dynamic: return String(localized: "SOCKS5 \(start) → \(ssh) → Requested Target") } } diff --git a/Termind/Infrastructure/SSH/SSHForwardingSession.swift b/Termind/Infrastructure/SSH/SSHForwardingSession.swift index 0c78747b..dd29cdc2 100644 --- a/Termind/Infrastructure/SSH/SSHForwardingSession.swift +++ b/Termind/Infrastructure/SSH/SSHForwardingSession.swift @@ -99,12 +99,12 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { guard let routeTransport else { throw TermindSSHError.disconnected } socket = routeTransport.socket guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { - throw TermindSSHError.library("Unable to create an SSH forwarding session.") + throw TermindSSHError.library(String(localized: "Unable to create an SSH forwarding session.")) } session = created libssh2_session_set_blocking(created, 1) guard libssh2_session_handshake(created, socket) == 0 else { - throw SSHSessionWorker.sessionError(created, prefix: "SSH handshake failed") + throw SSHSessionWorker.sessionError(created, prefix: String(localized: "SSH handshake failed")) } let observed = try SSHSessionWorker.observation( host: configuration.host, port: configuration.port, session: created @@ -146,7 +146,8 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { ) } catch { throw TermindSSHError.route( - "Unable to listen on \(rule.bindAddress): \(error.localizedDescription)" + String( + localized: "Unable to listen on \(rule.bindAddress): \(error.localizedDescription)") ) } case .remote: @@ -159,7 +160,7 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { guard let listener else { throw TermindSSHError.route( SSHSessionWorker.sessionError( - session, prefix: "Unable to start remote forwarding" + session, prefix: String(localized: "Unable to start remote forwarding") ).localizedDescription ) } @@ -214,7 +215,7 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { let keepalive = libssh2_keepalive_send(session, &next) if keepalive < 0, keepalive != LIBSSH2_ERROR_EAGAIN { let message = SSHSessionWorker.sessionError( - session, prefix: "The forwarding SSH connection closed" + session, prefix: String(localized: "The forwarding SSH connection closed") ).localizedDescription cleanup() failureHandler(message) diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 59fa4f6c..c8e7231c 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -223,6 +223,16 @@ } } }, + "%@ → listen %@ → This Mac → %@:%@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@→监听%2$@→这台Mac→%3$@:%4$@" + } + } + } + }, "%@%@ of %@ (%lld%%)" : { "localizations" : { "en" : { @@ -3634,6 +3644,26 @@ } } }, + "Destination Host from SSH Host" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH主机可访问的目标主机" + } + } + } + }, + "Destination Host on This Mac" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这台Mac可访问的目标主机" + } + } + } + }, "Destination Port" : { "localizations" : { "zh-Hans" : { @@ -4006,6 +4036,26 @@ } } }, + "Edit Group" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑组" + } + } + } + }, + "Edit Host" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑主机" + } + } + } + }, "Edit Identity" : { "localizations" : { "zh-Hans" : { @@ -4016,6 +4066,26 @@ } } }, + "Edit Key" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑密钥" + } + } + } + }, + "Edit Package" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑包" + } + } + } + }, "Edit Path" : { "comment" : "A button that opens a dialog to edit the current path.", "isCommentAutoGenerated" : true, @@ -4048,6 +4118,26 @@ } } }, + "Edit Port Forward" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑端口转发" + } + } + } + }, + "Edit Snippet" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑片段" + } + } + } + }, "Effort" : { "localizations" : { "zh-Hans" : { @@ -5940,6 +6030,36 @@ } } }, + "Listen Address on SSH Host" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH主机上的监听地址" + } + } + } + }, + "Listen Address on This Mac" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这台Mac上的监听地址" + } + } + } + }, + "Listen Port" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监听端口" + } + } + } + }, "Listening on localhost:%@" : { "localizations" : { "zh-Hans" : { @@ -6452,6 +6572,16 @@ } } }, + "New Port Forward" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建端口转发" + } + } + } + }, "New Provider" : { "localizations" : { "zh-Hans" : { @@ -9152,6 +9282,16 @@ } } }, + "SSH handshake failed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH握手失败" + } + } + } + }, "Save" : { "localizations" : { "zh-Hans" : { @@ -10068,6 +10208,26 @@ } } }, + "SOCKS Listen Address on This Mac" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这台Mac上的SOCKS监听地址" + } + } + } + }, + "SOCKS Listen Port" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SOCKS监听端口" + } + } + } + }, "SOCKS5" : { "localizations" : { "zh-Hans" : { @@ -10078,6 +10238,16 @@ } } }, + "SOCKS5 %@ → %@ → Requested Target" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SOCKS5 %1$@→%2$@→请求目标" + } + } + } + }, "SOCKS5 on This Mac → SSH Host → Requested Target" : { "localizations" : { "zh-Hans" : { @@ -10160,6 +10330,16 @@ } } }, + "SSH %@ → This Mac %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH %1$@→这台Mac %2$@" + } + } + } + }, "SSH Command" : { "localizations" : { "zh-Hans" : { @@ -11022,6 +11202,16 @@ } } }, + "The forwarding SSH connection closed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用于转发的SSH连接已关闭" + } + } + } + }, "The forwarding host is unavailable." : { "localizations" : { "zh-Hans" : { @@ -12238,6 +12428,16 @@ } } }, + "Unable to create an SSH forwarding session." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法创建SSH转发会话。" + } + } + } + }, "Unable to generate a secure token." : { "localizations" : { "zh-Hans" : { @@ -12248,6 +12448,26 @@ } } }, + "Unable to listen on %@: %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法监听%1$@:%2$@" + } + } + } + }, + "Unable to start remote forwarding" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法启动远程转发" + } + } + } + }, "Unable to store the token." : { "localizations" : { "zh-Hans" : { diff --git a/Termind/UI/Components/SharedChrome.swift b/Termind/UI/Components/SharedChrome.swift index 270a7f1b..42846118 100644 --- a/Termind/UI/Components/SharedChrome.swift +++ b/Termind/UI/Components/SharedChrome.swift @@ -119,10 +119,10 @@ struct SheetScaffold: View { /// resembles the manager toolbars rather than a floating sheet: a clear close /// affordance, a quiet title, scrollable work, and actions anchored at the foot. struct VaultDetailScaffold: View { - let title: String + let title: LocalizedStringKey let symbol: String let close: () -> Void - var closeLabel = "Close" + var closeLabel: LocalizedStringKey = "Close" var closeSymbol = "xmark" @ViewBuilder let content: Content @ViewBuilder let footer: Footer diff --git a/TermindTests/AppLanguageTests.swift b/TermindTests/AppLanguageTests.swift index ca6de50b..f62a0fc9 100644 --- a/TermindTests/AppLanguageTests.swift +++ b/TermindTests/AppLanguageTests.swift @@ -66,6 +66,55 @@ struct AppLanguageTests { } } + @Test + func editorLabelsAndForwardingMessagesHaveChineseTranslations() throws { + let repository = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + let url = repository.appending(path: "Termind/Resources/Localizable.xcstrings") + let root = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any] + ) + let strings = try #require(root["strings"] as? [String: Any]) + let labels = [ + "New Host", "Edit Host", "Import Key", "Generate Key", "Edit Key", "Export to Hosts", + "New Group", "Edit Group", "New Snippet", "Edit Snippet", "New Package", "Edit Package", + "New Identity", "Edit Identity", "Add Hosts", "Close", + "New Port Forward", "Edit Port Forward", "Back", "Start", "Stop", + "SOCKS Listen Port", "Listen Port", "Listen Address on This Mac", + "Listen Address on SSH Host", "SOCKS Listen Address on This Mac", + "Destination Host on This Mac", "Destination Host from SSH Host", + "Unable to create an SSH forwarding session.", "SSH handshake failed", + "Unable to start remote forwarding", "The forwarding SSH connection closed", + ] + let interpolations: [String: [String]] = [ + "%@ → listen %@ → This Mac → %@:%@": ["jump.example", "[::1]:8080", "db.internal", "5432"], + "SOCKS5 %@ → %@ → Requested Target": ["[::1]:1080", "jump.example"], + "SSH %@ → This Mac %@": ["[::1]:8080", "[2001:db8::1]:443"], + "Unable to listen on %@: %@": ["[::1]:8080", "Address already in use"], + ] + + for key in labels + interpolations.keys.sorted() { + let entry = try #require(strings[key] as? [String: Any], "Missing editor or forwarding string: \(key)") + let localizations = try #require(entry["localizations"] as? [String: Any]) + let chinese = try #require(localizations["zh-Hans"] as? [String: Any]) + let unit = try #require(chinese["stringUnit"] as? [String: Any]) + let translation = try #require(unit["value"] as? String) + #expect(unit["state"] as? String == "translated") + #expect(translation.range(of: "\\p{Han}", options: .regularExpression) != nil) + + if let arguments = interpolations[key] { + let placeholders = translation.matches(of: /%(?:[1-9][0-9]*\$)?@/) + #expect(placeholders.count == arguments.count, "Placeholder mismatch: \(key)") + let rendered = String(format: translation, arguments: arguments) + var remaining = rendered[...] + for argument in arguments { + let range = try #require(remaining.range(of: argument), "Lost or reordered value: \(key)") + remaining = remaining[range.upperBound...] + } + } + } + } + @Test func countedToolResourcesUseCatalogPluralRules() throws { let repository = URL(fileURLWithPath: #filePath)