From 7bf46eb17328ca4af3211340498d06c80a52f951 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Thu, 15 Oct 2026 19:07:25 +0800 Subject: [PATCH] feat(settings): add application language selection --- .../Composition/AppLanguagePreferences.swift | 30 ++++++++++ .../App/Presentation/AppLanguage.swift | 31 ++++++++++ .../App/Resources/Localizable.xcstrings | 55 ++++++++++++++++++ .../App/UI/Settings/LanguageSettingsRow.swift | 36 ++++++++++++ .../App/UI/Settings/SettingsView.swift | 1 + .../AppLanguagePreferencesTests.swift | 57 +++++++++++++++++++ docs/design/app-language-selection.md | 40 +++++++++++++ 7 files changed, 250 insertions(+) create mode 100644 ShadowsocksX-NG2/App/Composition/AppLanguagePreferences.swift create mode 100644 ShadowsocksX-NG2/App/Presentation/AppLanguage.swift create mode 100644 ShadowsocksX-NG2/App/UI/Settings/LanguageSettingsRow.swift create mode 100644 ShadowsocksX-NG2/Tests/Settings/AppLanguagePreferencesTests.swift create mode 100644 docs/design/app-language-selection.md diff --git a/ShadowsocksX-NG2/App/Composition/AppLanguagePreferences.swift b/ShadowsocksX-NG2/App/Composition/AppLanguagePreferences.swift new file mode 100644 index 00000000..f45e3bd4 --- /dev/null +++ b/ShadowsocksX-NG2/App/Composition/AppLanguagePreferences.swift @@ -0,0 +1,30 @@ +import Combine +import Foundation + +/// 应用语言覆盖使用 macOS 的应用级 AppleLanguages 偏好;不写全局偏好。 +/// Bundle 在进程内缓存语言选择,因此保存后由下次启动统一应用。 +@MainActor +final class AppLanguagePreferences: ObservableObject { + @Published private(set) var selection: AppLanguage + + private let defaults: UserDefaults + + init( + defaults: UserDefaults = .standard, + applicationID: String = "com.qiuyuzhou.ShadowsocksX-NG2" + ) { + self.defaults = defaults + let languages = + defaults.persistentDomain(forName: applicationID)?["AppleLanguages"] as? [String] + selection = languages?.first.flatMap(AppLanguage.init(rawValue:)) ?? .automatic + } + + func select(_ language: AppLanguage) { + if language == .automatic { + defaults.removeObject(forKey: "AppleLanguages") + } else { + defaults.set([language.rawValue], forKey: "AppleLanguages") + } + selection = language + } +} diff --git a/ShadowsocksX-NG2/App/Presentation/AppLanguage.swift b/ShadowsocksX-NG2/App/Presentation/AppLanguage.swift new file mode 100644 index 00000000..1dbb9689 --- /dev/null +++ b/ShadowsocksX-NG2/App/Presentation/AppLanguage.swift @@ -0,0 +1,31 @@ +import Foundation + +enum AppLanguage: String, CaseIterable { + case automatic + case simplifiedChinese = "zh-Hans" + case english = "en" + + func currentValueTitle(in bundle: Bundle = .main) -> String { + self == .automatic ? localizedName(in: bundle) : nativeName + } + + func menuTitle(in bundle: Bundle = .main) -> String { + "\(nativeName) · \(localizedName(in: bundle))" + } + + private var nativeName: String { + switch self { + case .automatic: "Auto Detect" + case .simplifiedChinese: "简体中文" + case .english: "English" + } + } + + private func localizedName(in bundle: Bundle) -> String { + switch self { + case .automatic: String(localized: "自动检测", bundle: bundle) + case .simplifiedChinese: String(localized: "简体中文", bundle: bundle) + case .english: String(localized: "英文", bundle: bundle) + } + } +} diff --git a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings index 2dd026b3..90c3c3c3 100644 --- a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings @@ -1906,6 +1906,61 @@ } } } + }, + "语言": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + } + } + }, + "下次启动生效": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Takes effect next launch" + } + } + } + }, + "自动检测": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Detect" + } + } + } + }, + "简体中文": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simplified Chinese" + } + } + } + }, + "英文": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "English" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/UI/Settings/LanguageSettingsRow.swift b/ShadowsocksX-NG2/App/UI/Settings/LanguageSettingsRow.swift new file mode 100644 index 00000000..284b35ff --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Settings/LanguageSettingsRow.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct LanguageSettingsRow: View { + @StateObject private var preferences = AppLanguagePreferences() + + var body: some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("语言") + Text("下次启动生效") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 16) + Picker( + "语言", + selection: Binding( + get: { preferences.selection }, + set: { preferences.select($0) }), + content: { + ForEach(AppLanguage.allCases, id: \.self) { language in + Text(verbatim: language.menuTitle()).tag(language) + } + }, + currentValueLabel: { + Text(verbatim: preferences.selection.currentValueTitle()) + } + ) + .pickerStyle(.menu) + .labelsHidden() + .fixedSize() + .accessibilityIdentifier("appLanguagePicker") + } + .padding(.vertical, 4) + } +} diff --git a/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift b/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift index 6bbcb083..72ce4146 100644 --- a/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift +++ b/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift @@ -68,6 +68,7 @@ struct SettingsView: View { .toggleStyle(.switch) } .padding(.vertical, 4) + LanguageSettingsRow() } header: { sectionHeader("常规") } diff --git a/ShadowsocksX-NG2/Tests/Settings/AppLanguagePreferencesTests.swift b/ShadowsocksX-NG2/Tests/Settings/AppLanguagePreferencesTests.swift new file mode 100644 index 00000000..0a42a2fa --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Settings/AppLanguagePreferencesTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct AppLanguagePreferencesTests { + @Test func missingApplicationOverrideUsesAutomatic() throws { + let suite = "language-tests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let preferences = AppLanguagePreferences(defaults: defaults, applicationID: suite) + + #expect(preferences.selection == .automatic) + } + + @Test func languageSelectionSurvivesRelaunchAndAutomaticRemovesOverride() throws { + let suite = "language-tests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(true, forKey: "silentLaunch") + let preferences = AppLanguagePreferences(defaults: defaults, applicationID: suite) + + for language in [AppLanguage.english, .simplifiedChinese] { + preferences.select(language) + #expect(preferences.selection == language) + #expect( + AppLanguagePreferences(defaults: defaults, applicationID: suite).selection == language) + #expect(defaults.stringArray(forKey: "AppleLanguages") == [language.rawValue]) + } + + preferences.select(.automatic) + #expect( + AppLanguagePreferences(defaults: defaults, applicationID: suite).selection == .automatic) + #expect(defaults.persistentDomain(forName: suite)?["AppleLanguages"] == nil) + #expect(defaults.bool(forKey: "silentLaunch")) + } + + @Test func menuNamesUseNativePrefixAndCurrentInterfaceTranslation() throws { + let appBundle = AppArtifact.bundle + let english = try #require( + Bundle(url: appBundle.bundleURL.appendingPathComponent("Contents/Resources/en.lproj"))) + let chinese = try #require( + Bundle(url: appBundle.bundleURL.appendingPathComponent("Contents/Resources/zh-Hans.lproj"))) + + #expect(AppLanguage.automatic.menuTitle(in: english) == "Auto Detect · Auto Detect") + #expect(AppLanguage.simplifiedChinese.menuTitle(in: english) == "简体中文 · Simplified Chinese") + #expect(AppLanguage.english.menuTitle(in: english) == "English · English") + #expect(AppLanguage.automatic.menuTitle(in: chinese) == "Auto Detect · 自动检测") + #expect(AppLanguage.simplifiedChinese.menuTitle(in: chinese) == "简体中文 · 简体中文") + #expect(AppLanguage.english.menuTitle(in: chinese) == "English · 英文") + #expect(AppLanguage.automatic.currentValueTitle(in: english) == "Auto Detect") + #expect(AppLanguage.simplifiedChinese.currentValueTitle(in: english) == "简体中文") + #expect(AppLanguage.english.currentValueTitle(in: chinese) == "English") + } +} diff --git a/docs/design/app-language-selection.md b/docs/design/app-language-selection.md new file mode 100644 index 00000000..24ed8080 --- /dev/null +++ b/docs/design/app-language-selection.md @@ -0,0 +1,40 @@ +# 应用语言选择 + +## 范围 + +在设置的「常规」末尾新增语言下拉框,提供自动检测、简体中文、英文。 +默认自动检测。保存后下次启动生效,不自动退出或重启应用。 +本文件记录本次需求与实现取舍,不作为项目当前状态的持续维护文档。 + +## 展示 + +- 收起时显示所选项的单个名称:自动检测使用当前界面语言,语言项使用自称。 +- 展开时显示「自称 · 当前界面语言名称」。重复名称保留。 +- 自动检测的固定前缀为 `Auto Detect`。 +- 顺序为自动检测、简体中文、英文。 +- 行标题为「语言」/ `Language`,说明为「下次启动生效」/ `Takes effect next launch`。 + +| 选项 | 中文界面 | 英文界面 | +| --- | --- | --- | +| 自动检测 | Auto Detect · 自动检测 | Auto Detect · Auto Detect | +| 简体中文 | 简体中文 · 简体中文 | 简体中文 · Simplified Chinese | +| 英文 | English · 英文 | English · English | + +## 偏好与生效 + +语言选择只影响本应用。显式选择写入应用级 `AppleLanguages`,自动检测移除该覆盖。 +自动检测沿用 macOS 对应用支持语言的匹配;开发语言为简体中文。 +不改全局语言、地区格式、代理设置或代理运行状态。 +应用现有 SwiftUI 与 Foundation 本地化在下次启动读取同一应用偏好。 +本次不迁移既有未本地化文案。 + +## Module 与测试 seam + +`AppLanguagePreferences` 的 Interface 提供当前选择与保存选择。 +该 Module 隐藏应用偏好域的读取及语言覆盖的写入。 +`AppLanguage` 提供收起名称与双语菜单名称。 +使用系统原生 Picker,独立提供当前值标签。 + +测试通过上述 Interface 检查缺失偏好、持久化往返、清除覆盖与双语名称。 +测试使用进程私有 UserDefaults 域。 +启动解析另外通过隔离的小型应用 bundle 验证,覆盖自动检测与显式覆盖。