feat(settings): add application language selection

This commit is contained in:
Qiu Yuzhou
2026-10-15 19:07:25 +08:00
parent 297fc30c41
commit 7bf46eb173
7 changed files with 250 additions and 0 deletions
@@ -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
}
}
@@ -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)
}
}
}
@@ -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"
@@ -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)
}
}
@@ -68,6 +68,7 @@ struct SettingsView: View {
.toggleStyle(.switch)
}
.padding(.vertical, 4)
LanguageSettingsRow()
} header: {
sectionHeader("常规")
}
@@ -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")
}
}
+40
View File
@@ -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 验证,覆盖自动检测与显式覆盖。