mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-30 06:03:48 +00:00
feat(settings): add application language selection
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 验证,覆盖自动检测与显式覆盖。
|
||||
Reference in New Issue
Block a user