feat(i18n): 支持九种语言与可搜索语言选择
This commit is contained in:
1 parent
7db2c8ad7a
commit
ef0bd2f5ee
38 files changed
+9107
-160
No files matched your search
+24
-9
@@ -9,7 +9,7 @@ hosting SwiftUI, no third-party dependencies. The Xcode project is generated fro
|
||||
It samples CPU, memory, disk and battery itself through `Metrics`, so it works without the app.
|
||||
- `Helper/` — the privileged helper (`XStatsHelper`) and its launchd plist, embedded in
|
||||
the app bundle for `SMAppService.daemon`.
|
||||
- `Packages/XStatsKit/Sources/Localization` — `tr(_:)` and the English table (see below).
|
||||
- `Packages/XStatsKit/Sources/Localization` — `tr(_:)`, language resolution and nine-language catalogs (see below).
|
||||
- `Packages/XStatsKit/Sources/SMC` — the AppleSMC user client, fan control, temperature
|
||||
key discovery.
|
||||
- `Packages/XStatsKit/Sources/Metrics` — one sampler per metric and `MetricsHub`; power and CPU
|
||||
@@ -247,14 +247,29 @@ merge concurrent edits, or use iCloud. A missing remote file requires an initial
|
||||
|
||||
Source strings are Simplified Chinese. `Scripts/l10n_wrap.py` wraps every Chinese literal in
|
||||
`tr(...)` (skipping logger calls, `case` patterns and multi-line strings) and lists the keys.
|
||||
`tr` returns the input unless English is active; otherwise it looks the text up in
|
||||
`Localization/Translations.swift` — exact keys first, then templates where `{}` stands for an
|
||||
interpolated value, longest literal fragments first, translating captured values once more. The
|
||||
language is configured at launch (System follows the global `AppleLanguages`). Changing it
|
||||
reconfigures `L10n`, rebuilds the main window and update prompt through `.id(language)`, rebuilds the
|
||||
app menu and redraws the menu bar; popovers are created on open. Names supplied by macOS follow the
|
||||
app's `AppleLanguages`, which changes at the next launch. Dates use `L10n.locale`. `--snapshot <dir> --language en` renders English screenshots and
|
||||
writes any untranslated string to `untranslated.txt`.
|
||||
`tr` returns the source for Simplified Chinese. English uses the Swift tables; Traditional Chinese,
|
||||
Japanese, Korean, German, Spanish, French and Arabic load bundled TSV catalogs from Localization/Resources.
|
||||
Exact keys take priority, followed by templates with {} or numbered placeholders. Captured strings
|
||||
are translated recursively; inserted values are not reinterpreted as placeholders. Missing entries
|
||||
fall back to English, then the source. Translation tables are immutable; global language and caches
|
||||
share a lock because background collectors also call tr.
|
||||
|
||||
The language picker uses a searchable native popover, native language names, a selected checkmark,
|
||||
and keyboard navigation. Search accepts Chinese, English, native names and language codes.
|
||||
Automatic detection walks global AppleLanguages in preference order, distinguishing zh-Hans from
|
||||
zh-Hant/TW/HK/MO and falling back to English if no supported language is found. Existing system,
|
||||
chinese and english preference values and WebDAV settings remain readable.
|
||||
|
||||
Changing language rebuilds SwiftUI roots and AppKit menu/window titles. Each SwiftUI window and popover
|
||||
receives the selected locale and layout direction; Arabic uses right-to-left layout and isolates
|
||||
interpolated paths/numbers with Unicode directional isolates. Dates use L10n.locale. For remote geographic
|
||||
data available only in Chinese/English, other languages use English names; country names use the locale.
|
||||
Some macOS-provided names follow the application's AppleLanguages on the next launch.
|
||||
|
||||
The nine supported languages are zh-Hans, zh-Hant, ja, ko, en, de, es, fr and ar. Snapshots accept these
|
||||
codes through --snapshot <dir> --language <code>, include the language picker, and record missing
|
||||
translations in untranslated.txt. New interface strings must be added to all catalogs; coverage and
|
||||
placeholder checks accompany the localization tests.
|
||||
|
||||
`--snapshot <dir>` renders every main-window page, popover, settings section and the menu bar in light and
|
||||
dark, through real `NSHostingView`s in off-screen windows — `ImageRenderer` washes out pages
|
||||
|
||||
@@ -12,6 +12,18 @@
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>zh-Hans</string>
|
||||
<string>zh-Hant</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>en</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fr</string>
|
||||
<string>ar</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>XStats</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
#### 新增
|
||||
|
||||
- 语言设置支持搜索下拉选择、自动检测和选中标记,增加繁体中文、日语、韩语、德语、西班牙语、法语与阿拉伯语;阿拉伯语使用从右到左布局,语言偏好继续参与 WebDAV 设置备份。
|
||||
|
||||
- 设置同步改用 WebDAV:配置已有 HTTPS 目录、用户名和密码,手动上传本机设置或下载后确认应用。密码只存本机钥匙串,备份不包含监控数据、历史记录和连接凭据;文件大小、格式与版本校验失败时保持本机设置不变。
|
||||
|
||||
#### 调整
|
||||
|
||||
@@ -16,7 +16,7 @@ let package = Package(
|
||||
.library(name: "XStatsUI", targets: ["XStatsUI"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Localization"),
|
||||
.target(name: "Localization", resources: [.process("Resources")]),
|
||||
.target(name: "SMC", dependencies: ["Localization"]),
|
||||
.target(name: "Metrics", dependencies: ["SMC", "Localization"], linkerSettings: [.linkedLibrary("IOReport")]),
|
||||
.target(name: "HelperShared", dependencies: ["Localization"]),
|
||||
@@ -30,7 +30,7 @@ let package = Package(
|
||||
.testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]),
|
||||
.testTarget(name: "UpdatesTests", dependencies: ["Updates"]),
|
||||
.testTarget(name: "WebDAVSyncTests", dependencies: ["WebDAVSync"]),
|
||||
.testTarget(name: "XStatsUITests", dependencies: ["XStatsUI", "Metrics", "WebDAVSync"]),
|
||||
.testTarget(name: "XStatsUITests", dependencies: ["XStatsUI", "Metrics", "WebDAVSync", "Localization"]),
|
||||
.testTarget(name: "LocalizationTests", dependencies: ["Localization"]),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 保留 system / chinese / english 的持久化值,已有本机设置及 WebDAV 备份可继续读取。
|
||||
public enum AppLanguage: String, CaseIterable, Sendable {
|
||||
case system, chinese, traditionalChinese, japanese, korean, english, german, spanish, french, arabic
|
||||
|
||||
public var languageCode: String {
|
||||
switch self {
|
||||
case .system: resolved.languageCode
|
||||
case .chinese: "zh-Hans"
|
||||
case .traditionalChinese: "zh-Hant"
|
||||
case .japanese: "ja"
|
||||
case .korean: "ko"
|
||||
case .english: "en"
|
||||
case .german: "de"
|
||||
case .spanish: "es"
|
||||
case .french: "fr"
|
||||
case .arabic: "ar"
|
||||
}
|
||||
}
|
||||
|
||||
public var nativeName: String {
|
||||
switch self {
|
||||
case .system: "自动检测"
|
||||
case .chinese: "简体中文"
|
||||
case .traditionalChinese: "繁體中文"
|
||||
case .japanese: "日本語"
|
||||
case .korean: "한국어"
|
||||
case .english: "English"
|
||||
case .german: "Deutsch"
|
||||
case .spanish: "Español"
|
||||
case .french: "Français"
|
||||
case .arabic: "العربية"
|
||||
}
|
||||
}
|
||||
|
||||
public var isRightToLeft: Bool { (self == .system ? resolved : self) == .arabic }
|
||||
public var isChinese: Bool { self == .chinese || self == .traditionalChinese }
|
||||
|
||||
/// 用户可按母语名称、中文名、英文名或语言代码搜索,忽略大小写和重音。
|
||||
public func matches(search: String) -> Bool {
|
||||
let query = search.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return true }
|
||||
let aliases: String
|
||||
switch self {
|
||||
case .system: aliases = "system auto detect automatically 自动检测 跟随系统"
|
||||
case .chinese: aliases = "Simplified Chinese 中文 简体 zh-CN zh-SG"
|
||||
case .traditionalChinese: aliases = "Traditional Chinese 中文 繁体中文 繁體 zh-TW zh-HK zh-MO"
|
||||
case .japanese: aliases = "Japanese 日语 日文 日本語"
|
||||
case .korean: aliases = "Korean 韩语 韓語 韩文 한국어"
|
||||
case .english: aliases = "English 英语 英語 英文"
|
||||
case .german: aliases = "German 德语 德語"
|
||||
case .spanish: aliases = "Spanish 西班牙语 西班牙語"
|
||||
case .french: aliases = "French 法语 法語"
|
||||
case .arabic: aliases = "Arabic 阿拉伯语 阿拉伯語"
|
||||
}
|
||||
let haystack = "\(nativeName) \(languageCode) \(aliases)"
|
||||
return haystack.range(of: query, options: [.caseInsensitive, .diacriticInsensitive],
|
||||
locale: Locale(identifier: "en_US_POSIX")) != nil
|
||||
}
|
||||
|
||||
/// 按用户的首选顺序选择支持的语言;不支持的首选项可回退到列表中的下一项。
|
||||
public static func resolve(preferredLanguages: [String]) -> AppLanguage {
|
||||
for identifier in preferredLanguages {
|
||||
let parts = identifier.replacingOccurrences(of: "_", with: "-").lowercased().split(separator: "-")
|
||||
guard let base = parts.first else { continue }
|
||||
switch base {
|
||||
case "zh":
|
||||
if parts.contains("hans") { return .chinese }
|
||||
if parts.contains("hant") || parts.contains("tw") || parts.contains("hk") || parts.contains("mo") {
|
||||
return .traditionalChinese
|
||||
}
|
||||
return .chinese
|
||||
case "ja": return .japanese
|
||||
case "ko": return .korean
|
||||
case "en": return .english
|
||||
case "de": return .german
|
||||
case "es": return .spanish
|
||||
case "fr": return .french
|
||||
case "ar": return .arabic
|
||||
default: continue
|
||||
}
|
||||
}
|
||||
return .english
|
||||
}
|
||||
|
||||
/// 读取全局首选语言,避免被应用自己写入的 AppleLanguages 覆盖。
|
||||
public var resolved: AppLanguage {
|
||||
guard self == .system else { return self }
|
||||
let global = CFPreferencesCopyValue("AppleLanguages" as CFString, kCFPreferencesAnyApplication,
|
||||
kCFPreferencesCurrentUser, kCFPreferencesAnyHost) as? [String]
|
||||
return Self.resolve(preferredLanguages: global ?? Locale.preferredLanguages)
|
||||
}
|
||||
|
||||
/// 只修改语言,保留用户地区;系统提供的部分文字在下次启动时更新。
|
||||
public func applyToProcessLocale(defaults: UserDefaults = .standard) {
|
||||
guard self != .system else {
|
||||
defaults.removeObject(forKey: "AppleLanguages")
|
||||
defaults.removeObject(forKey: "AppleLocale")
|
||||
return
|
||||
}
|
||||
let global = CFPreferencesCopyValue("AppleLocale" as CFString, kCFPreferencesAnyApplication,
|
||||
kCFPreferencesCurrentUser, kCFPreferencesAnyHost) as? String
|
||||
let region = global.flatMap { Locale(identifier: $0).region?.identifier } ?? "US"
|
||||
defaults.set([languageCode], forKey: "AppleLanguages")
|
||||
defaults.set(languageCode.replacingOccurrences(of: "-", with: "_") + "_" + region, forKey: "AppleLocale")
|
||||
}
|
||||
}
|
||||
@@ -1,166 +1,202 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 界面语言。源代码里的文案是简体中文;选择英文时,`tr` 在显示前把中文换成英文。
|
||||
///
|
||||
/// 翻译表以中文原文为键:没有插值的文案精确匹配;带插值的文案用 `{}` 表示插入的内容,
|
||||
/// 例如 “{} 个进程” → “{} processes”,英文里可以写 `{1}`、`{2}` 调整顺序。
|
||||
public enum AppLanguage: String, CaseIterable, Sendable {
|
||||
case system, chinese, english
|
||||
|
||||
/// 跟随系统时,系统首选语言是中文就用中文,否则用英文。
|
||||
/// 读全局偏好而不是 Locale.preferredLanguages:后者会被应用自己写入的 AppleLanguages 覆盖
|
||||
public var resolved: AppLanguage {
|
||||
guard self == .system else { return self }
|
||||
let global = CFPreferencesCopyValue("AppleLanguages" as CFString, kCFPreferencesAnyApplication,
|
||||
kCFPreferencesCurrentUser, kCFPreferencesAnyHost) as? [String]
|
||||
let preferred = (global?.first ?? Locale.preferredLanguages.first ?? "zh").lowercased()
|
||||
return preferred.hasPrefix("zh") ? .chinese : .english
|
||||
}
|
||||
|
||||
/// 让系统提供的文字(日期、显示器名称、应用名称)与界面语言一致:写入应用自己的 AppleLanguages,下次启动生效
|
||||
public func applyToProcessLocale(defaults: UserDefaults = .standard) {
|
||||
// 地区保持用户设置(影响日期顺序、单位等),只换语言
|
||||
let region = (CFPreferencesCopyValue("AppleLocale" as CFString, kCFPreferencesAnyApplication,
|
||||
kCFPreferencesCurrentUser, kCFPreferencesAnyHost) as? String)
|
||||
.map { Locale(identifier: $0).region?.identifier ?? "US" } ?? "US"
|
||||
switch self {
|
||||
case .system:
|
||||
defaults.removeObject(forKey: "AppleLanguages")
|
||||
defaults.removeObject(forKey: "AppleLocale")
|
||||
case .chinese:
|
||||
defaults.set(["zh-Hans"], forKey: "AppleLanguages")
|
||||
defaults.set("zh_Hans_\(region)", forKey: "AppleLocale")
|
||||
case .english:
|
||||
defaults.set(["en"], forKey: "AppleLanguages")
|
||||
defaults.set("en_\(region)", forKey: "AppleLocale")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum L10n {
|
||||
/// 默认中文;应用启动时按设置调用 configure。单元测试与辅助工具不调用,始终保持中文原文
|
||||
nonisolated(unsafe) public private(set) static var language: AppLanguage = .chinese
|
||||
/// 截图与调试时记录没有翻译的中文,便于补全
|
||||
nonisolated(unsafe) public static var missLog: URL?
|
||||
// 所有可变全局状态都由同一把锁保护;tr 同时用于 UI 和后台采样任务。
|
||||
private static let lock = NSLock()
|
||||
nonisolated(unsafe) private static var selectedLanguage: AppLanguage = .chinese
|
||||
nonisolated(unsafe) private static var cache: [String: String] = [:]
|
||||
nonisolated(unsafe) private static var misses = Set<String>()
|
||||
nonisolated(unsafe) private static var missingLogURL: URL?
|
||||
|
||||
public static var language: AppLanguage { lock.withLock { selectedLanguage } }
|
||||
public static var missLog: URL? {
|
||||
get { lock.withLock { missingLogURL } }
|
||||
set { lock.withLock { missingLogURL = newValue } }
|
||||
}
|
||||
public static var isEnglish: Bool { language == .english }
|
||||
/// 在线地名只有中英文两套数据时,其他语言使用英文,国家名称仍由当前 Locale 本地化。
|
||||
public static var usesEnglishNames: Bool { !language.isChinese }
|
||||
public static var locale: Locale { locale(for: language) }
|
||||
|
||||
/// 格式化日期用的地区:英文界面用英文月份名,地区沿用系统设置
|
||||
public static var locale: Locale {
|
||||
guard isEnglish else { return .current }
|
||||
return Locale(identifier: "en_" + (Locale.current.region?.identifier ?? "US"))
|
||||
public static func locale(for language: AppLanguage) -> Locale {
|
||||
let region = Locale.current.region?.identifier ?? "US"
|
||||
return Locale(identifier: language.resolved.languageCode.replacingOccurrences(of: "-", with: "_") + "_" + region)
|
||||
}
|
||||
|
||||
public static func configure(_ language: AppLanguage) {
|
||||
lock.lock()
|
||||
self.language = language.resolved
|
||||
cache = [:]
|
||||
lock.unlock()
|
||||
let resolved = language.resolved
|
||||
lock.withLock {
|
||||
selectedLanguage = resolved
|
||||
cache.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
private static let lock = NSLock()
|
||||
nonisolated(unsafe) private static var cache: [String: String] = [:]
|
||||
nonisolated(unsafe) private static var misses = Set<String>()
|
||||
|
||||
static func translate(_ text: String) -> String {
|
||||
guard language == .english, containsChinese(text) else { return text }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if let cached = cache[text] { return cached }
|
||||
let result = Translations.shared.translate(text) ?? {
|
||||
recordMiss(text)
|
||||
return text
|
||||
}()
|
||||
// 带数字的文案不断变化,缓存设上限
|
||||
if cache.count > 4000 { cache.removeAll(keepingCapacity: true) }
|
||||
cache[text] = result
|
||||
return result
|
||||
lock.withLock {
|
||||
guard selectedLanguage != .chinese, containsChinese(text) else { return text }
|
||||
if let cached = cache[text] { return cached }
|
||||
let catalog = Translations.catalog(for: selectedLanguage)
|
||||
let translated = catalog?.translate(text, fallback: Translations.shared)
|
||||
if translated == nil { recordMiss(text) }
|
||||
let result = translated ?? Translations.shared.translate(text) ?? text
|
||||
if cache.count > 4000 { cache.removeAll(keepingCapacity: true) }
|
||||
cache[text] = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
static func containsChinese(_ text: String) -> Bool {
|
||||
text.unicodeScalars.contains { (0x4E00...0x9FFF).contains($0.value) || (0x3000...0x303F).contains($0.value) || (0xFF00...0xFFEF).contains($0.value) }
|
||||
text.unicodeScalars.contains {
|
||||
(0x4E00...0x9FFF).contains($0.value) || (0x3000...0x303F).contains($0.value) || (0xFF00...0xFFEF).contains($0.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅在持锁状态调用。按语言记录缺失条目,缺失新文案回退英文而非空白。
|
||||
private static func recordMiss(_ text: String) {
|
||||
guard let missLog, misses.insert(text).inserted else { return }
|
||||
if let handle = try? FileHandle(forWritingTo: missLog) {
|
||||
let key = "\(selectedLanguage.languageCode)\t\(text)"
|
||||
guard let missingLogURL, misses.insert(key).inserted else { return }
|
||||
let line = key.replacingOccurrences(of: "\n", with: "\\n") + "\n"
|
||||
if let handle = try? FileHandle(forWritingTo: missingLogURL) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(Data((text.replacingOccurrences(of: "\n", with: "\\n") + "\n").utf8))
|
||||
handle.write(Data(line.utf8))
|
||||
try? handle.close()
|
||||
} else {
|
||||
try? Data((text + "\n").utf8).write(to: missLog)
|
||||
try? Data(line.utf8).write(to: missingLogURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 界面文案:英文界面下返回译文,否则原样返回
|
||||
/// 中文原文作为稳定键,界面显示前按所选语言翻译。
|
||||
@inline(__always)
|
||||
public func tr(_ text: String) -> String {
|
||||
L10n.translate(text)
|
||||
}
|
||||
public func tr(_ text: String) -> String { L10n.translate(text) }
|
||||
|
||||
/// 翻译表:精确匹配表与带 `{}` 的模板
|
||||
final class Translations: @unchecked Sendable {
|
||||
static let shared = Translations()
|
||||
/// 不可变翻译表,可在采样和界面线程共享。占位符支持 {} 以及重排后的 {1}、{2}。
|
||||
final class Translations: Sendable {
|
||||
static let shared = Translations(tables: tables)
|
||||
private static let catalogs: [AppLanguage: Translations] = {
|
||||
var result: [AppLanguage: Translations] = [.english: shared]
|
||||
for language in AppLanguage.allCases where language != .system && language != .chinese && language != .english {
|
||||
guard let url = Bundle.module.url(forResource: language.languageCode, withExtension: "tsv"),
|
||||
let text = try? String(contentsOf: url, encoding: .utf8) else { continue }
|
||||
result[language] = Translations(tables: [text], rightToLeft: language.isRightToLeft)
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
private var exact: [String: String] = [:]
|
||||
private var templates: [Template] = []
|
||||
static func catalog(for language: AppLanguage) -> Translations? { catalogs[language.resolved] }
|
||||
|
||||
struct Template {
|
||||
private let exact: [String: String]
|
||||
private let templates: [Template]
|
||||
private let rightToLeft: Bool
|
||||
let sourceKeys: Set<String>
|
||||
|
||||
struct Template: Sendable {
|
||||
let fragments: [String]
|
||||
let regex: NSRegularExpression
|
||||
let english: String
|
||||
let translation: String
|
||||
}
|
||||
|
||||
private init() {
|
||||
for table in Translations.tables {
|
||||
init(tables: [String], rightToLeft: Bool = false) {
|
||||
var values: [String: String] = [:]
|
||||
for table in tables {
|
||||
for line in table.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
let parts = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard parts.count == 2 else { continue }
|
||||
let source = String(parts[0]).replacingOccurrences(of: "\\n", with: "\n")
|
||||
let english = String(parts[1]).replacingOccurrences(of: "\\n", with: "\n")
|
||||
if source.contains("{}") {
|
||||
let fragments = source.components(separatedBy: "{}")
|
||||
let pattern = "^" + fragments.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "(.*?)") + "$"
|
||||
if let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) {
|
||||
templates.append(Template(fragments: fragments.filter { !$0.isEmpty }, regex: regex, english: english))
|
||||
}
|
||||
} else {
|
||||
exact[source] = english
|
||||
}
|
||||
let translation = String(parts[1]).replacingOccurrences(of: "\\n", with: "\n")
|
||||
// 错误占位符会丢失路径/数值,拒绝该条目并交由英文回退处理。
|
||||
guard !translation.isEmpty, Self.validPlaceholders(source: source, translation: translation) else { continue }
|
||||
values[source] = translation
|
||||
}
|
||||
}
|
||||
// 片段越长越具体,优先匹配
|
||||
templates.sort { $0.fragments.joined().count > $1.fragments.joined().count }
|
||||
sourceKeys = Set(values.keys)
|
||||
var exact: [String: String] = [:]
|
||||
var templates: [Template] = []
|
||||
for (source, translation) in values {
|
||||
if source.contains("{}") {
|
||||
let fragments = source.components(separatedBy: "{}")
|
||||
let pattern = "^" + fragments.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "(.*?)") + "$"
|
||||
if let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) {
|
||||
templates.append(Template(fragments: fragments.filter { !$0.isEmpty }, regex: regex, translation: translation))
|
||||
}
|
||||
} else {
|
||||
exact[source] = translation
|
||||
}
|
||||
}
|
||||
self.exact = exact
|
||||
// 长模板优先;长度相同时稳定排序,避免相近模板因字典顺序出现随机匹配。
|
||||
self.templates = templates.sorted {
|
||||
let left = $0.fragments.joined(), right = $1.fragments.joined()
|
||||
return left.count == right.count ? left < right : left.count > right.count
|
||||
}
|
||||
self.rightToLeft = rightToLeft
|
||||
}
|
||||
|
||||
func translate(_ text: String, depth: Int = 0) -> String? {
|
||||
if let english = exact[text] { return english }
|
||||
func translate(_ text: String, depth: Int = 0, fallback: Translations? = nil) -> String? {
|
||||
if let translation = exact[text] { return translation }
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
for template in templates where template.fragments.allSatisfy(text.contains) {
|
||||
guard let match = template.regex.firstMatch(in: text, range: range) else { continue }
|
||||
var values: [String] = []
|
||||
for index in 1..<match.numberOfRanges {
|
||||
guard let r = Range(match.range(at: index), in: text) else { continue }
|
||||
let value = String(text[r])
|
||||
// 插进来的内容本身也可能是中文(例如来自其他模块的名称),能翻就翻
|
||||
values.append(depth < 2 && L10n.containsChinese(value) ? (translate(value, depth: depth + 1) ?? value) : value)
|
||||
guard let range = Range(match.range(at: index), in: text) else { continue }
|
||||
let value = String(text[range])
|
||||
values.append(depth < 2 && L10n.containsChinese(value)
|
||||
? (translate(value, depth: depth + 1, fallback: fallback)
|
||||
?? fallback?.translate(value, depth: depth + 1) ?? value) : value)
|
||||
}
|
||||
return fill(template.english, values)
|
||||
return fill(template.translation, values)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func fill(_ english: String, _ values: [String]) -> String {
|
||||
var result = english
|
||||
// 先替换带序号的 {1}{2},再按顺序替换 {}
|
||||
for (index, value) in values.enumerated() {
|
||||
result = result.replacingOccurrences(of: "{\(index + 1)}", with: value)
|
||||
private func fill(_ translation: String, _ values: [String]) -> String {
|
||||
// RTL 模板中的路径、IP 和数值独立定向,不让标点与相邻阿拉伯文互相重排。
|
||||
let values = rightToLeft ? values.map { "\u{2068}\($0)\u{2069}" } : values
|
||||
// 只扫描模板,绝不再次扫描插入值,保留文件名或命令里本来就有的 {}。
|
||||
var result = ""
|
||||
var cursor = translation.startIndex
|
||||
var sequential = 0
|
||||
for match in Self.placeholder.matches(in: translation, range: NSRange(translation.startIndex..., in: translation)) {
|
||||
guard let range = Range(match.range, in: translation),
|
||||
let numberRange = Range(match.range(at: 1), in: translation) else { continue }
|
||||
result += translation[cursor..<range.lowerBound]
|
||||
let number = translation[numberRange]
|
||||
let index: Int
|
||||
if number.isEmpty {
|
||||
index = sequential
|
||||
sequential += 1
|
||||
} else {
|
||||
index = (Int(number) ?? 0) - 1
|
||||
}
|
||||
result += values.indices.contains(index) ? values[index] : String(translation[range])
|
||||
cursor = range.upperBound
|
||||
}
|
||||
var iterator = values.makeIterator()
|
||||
while let range = result.range(of: "{}") {
|
||||
result.replaceSubrange(range, with: iterator.next() ?? "")
|
||||
return result + translation[cursor...]
|
||||
}
|
||||
|
||||
private static let placeholder = try! NSRegularExpression(pattern: #"\{(\d*)\}"#)
|
||||
|
||||
private static func validPlaceholders(source: String, translation: String) -> Bool {
|
||||
let count = source.components(separatedBy: "{}").count - 1
|
||||
var sequential = 0
|
||||
var used = Set<Int>()
|
||||
for match in placeholder.matches(in: translation, range: NSRange(translation.startIndex..., in: translation)) {
|
||||
guard let range = Range(match.range(at: 1), in: translation) else { return false }
|
||||
let number = translation[range]
|
||||
if number.isEmpty {
|
||||
sequential += 1
|
||||
used.insert(sequential)
|
||||
} else {
|
||||
guard let index = Int(number), index > 0 else { return false }
|
||||
used.insert(index)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return used == Set(1..<(count + 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
extension Translations {
|
||||
static let languagePickerEnglish = #"""
|
||||
语言 Language
|
||||
应用 UI 语言 App UI language
|
||||
自动检测 Detect automatically
|
||||
搜索语言 Search languages
|
||||
没有匹配的语言 No matching languages
|
||||
选择语言 Choose a language
|
||||
"""#
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,14 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
// 英文译文:每行“中文原文<Tab>English”,由 Scripts/l10n_wrap.py 收集原文。
|
||||
// 带插值的原文用 {} 表示插入的内容;译文可以用 {1}、{2} 调整顺序。
|
||||
|
||||
extension Translations {
|
||||
static var tables: [String] {
|
||||
[english, webDAVEnglish]
|
||||
[english, webDAVEnglish, languagePickerEnglish]
|
||||
}
|
||||
|
||||
static let english = #"""
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import Metrics
|
||||
@@ -272,6 +277,9 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
updateAction: #selector(checkForUpdatesFromMenu))
|
||||
menuBar.dismissPopovers()
|
||||
menuBar.refreshImages()
|
||||
speedTestWindow.refreshLanguage()
|
||||
egressWindow.refreshLanguage()
|
||||
updateWindow.refreshLanguage()
|
||||
}
|
||||
|
||||
private func updateNetworkVisibility() {
|
||||
@@ -334,10 +342,11 @@ public enum XStatsApplication {
|
||||
L10n.configure(stored)
|
||||
if let index = CommandLine.arguments.firstIndex(of: "--snapshot") {
|
||||
let path = CommandLine.arguments.dropFirst(index + 1).first ?? "snapshots"
|
||||
// --language en:渲染英文截图,并把没有翻译的中文记到 untranslated.txt
|
||||
// --language 接受语言代码或设置枚举值,缺失翻译记录到 untranslated.txt。
|
||||
if let languageIndex = CommandLine.arguments.firstIndex(of: "--language"),
|
||||
CommandLine.arguments.dropFirst(languageIndex + 1).first == "en" {
|
||||
L10n.configure(.english)
|
||||
let identifier = CommandLine.arguments.dropFirst(languageIndex + 1).first {
|
||||
let language = AppLanguage(rawValue: identifier) ?? AppLanguage.resolve(preferredLanguages: [identifier])
|
||||
L10n.configure(language)
|
||||
let log = URL(fileURLWithPath: path).appendingPathComponent("untranslated.txt")
|
||||
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: path), withIntermediateDirectories: true)
|
||||
try? FileManager.default.removeItem(at: log)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import SwiftUI
|
||||
@@ -7,6 +12,7 @@ import SwiftUI
|
||||
final class EgressWindowController: NSObject, NSWindowDelegate {
|
||||
private let model: AppModel
|
||||
private var window: NSWindow?
|
||||
func refreshLanguage() { window?.title = tr("出口与分流") }
|
||||
var onVisibilityChange: ((Bool) -> Void)?
|
||||
|
||||
init(model: AppModel) {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import SwiftUI
|
||||
@@ -7,6 +12,7 @@ import SwiftUI
|
||||
final class SpeedTestWindowController: NSObject, NSWindowDelegate {
|
||||
private let model: AppModel
|
||||
private var window: NSWindow?
|
||||
func refreshLanguage() { window?.title = tr("网络测速") }
|
||||
var onVisibilityChange: ((Bool) -> Void)?
|
||||
|
||||
init(model: AppModel) {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import SwiftUI
|
||||
@@ -7,6 +12,7 @@ import SwiftUI
|
||||
final class UpdateWindowController: NSObject, NSWindowDelegate {
|
||||
private let model: AppModel
|
||||
private var window: NSWindow?
|
||||
func refreshLanguage() { window?.title = tr("软件更新") }
|
||||
|
||||
init(model: AppModel) {
|
||||
self.model = model
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Localization
|
||||
import SwiftUI
|
||||
|
||||
/// 每个独立窗口/弹窗都读取当前选择,立即更新日期语言、文字方向与已计算的文案。
|
||||
private struct AppLanguageEnvironment: ViewModifier {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let language = model.settings.language.resolved
|
||||
content
|
||||
.environment(\.locale, L10n.locale(for: language))
|
||||
.environment(\.layoutDirection, language.isRightToLeft ? .rightToLeft : .leftToRight)
|
||||
.id(model.settings.language)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appLanguageEnvironment() -> some View { modifier(AppLanguageEnvironment()) }
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import Metrics
|
||||
@@ -29,6 +34,7 @@ struct EgressWindowView: View {
|
||||
.background(DS.Palette.background)
|
||||
// 内容延伸到透明标题栏下方,顶栏与红绿灯按钮在同一行
|
||||
.ignoresSafeArea()
|
||||
.appLanguageEnvironment()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,9 +762,9 @@ private enum EgressText {
|
||||
/// 国家 · 省 / 州 · 城市;英文界面优先用英文地名,重复的相邻项只留一个
|
||||
static func location(_ geo: EgressGeo?, fallback country: String?) -> String {
|
||||
guard let code = geo?.countryCode ?? country else { return "" }
|
||||
let name = Locale(identifier: L10n.isEnglish ? "en" : "zh-Hans").localizedString(forRegionCode: code) ?? code
|
||||
let region = L10n.isEnglish ? (geo?.regionEnglish ?? geo?.region) : geo?.region
|
||||
let city = L10n.isEnglish ? (geo?.cityEnglish ?? geo?.city) : geo?.city
|
||||
let name = L10n.locale.localizedString(forRegionCode: code) ?? code
|
||||
let region = L10n.usesEnglishNames ? (geo?.regionEnglish ?? geo?.region) : geo?.region
|
||||
let city = L10n.usesEnglishNames ? (geo?.cityEnglish ?? geo?.city) : geo?.city
|
||||
var parts = [name]
|
||||
for part in [region, city].compactMap({ $0 }) where part != parts.last { parts.append(part) }
|
||||
return parts.joined(separator: " · ")
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import Metrics
|
||||
@@ -34,7 +39,7 @@ public struct MainWindowView: View {
|
||||
// 内容延伸到透明标题栏下方,由顶栏高度留出红绿灯按钮的位置
|
||||
.ignoresSafeArea()
|
||||
// 文案在各视图计算时翻译好,切换语言后整棵视图重建
|
||||
.id(model.settings.language)
|
||||
.appLanguageEnvironment()
|
||||
}
|
||||
|
||||
/// 顶栏在上、页面在下。没有用 macOS 26 的 safeAreaBar 让页面从顶栏下面滚过:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import Localization
|
||||
import Metrics
|
||||
import SwiftUI
|
||||
@@ -21,6 +26,7 @@ struct SpeedTestWindowView: View {
|
||||
}
|
||||
.background(DS.Palette.background)
|
||||
.ignoresSafeArea()
|
||||
.appLanguageEnvironment()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +186,7 @@ private struct CarrierBadge: View {
|
||||
let carrier: ChinaCarrier
|
||||
|
||||
var body: some View {
|
||||
Text(verbatim: L10n.isEnglish ? carrier.markEnglish : carrier.mark)
|
||||
Text(verbatim: L10n.usesEnglishNames ? carrier.markEnglish : carrier.mark)
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: DS.Space.s6, height: DS.Size.iconInline)
|
||||
@@ -236,7 +242,7 @@ private struct ChinaLatencyCard: View {
|
||||
.padding(.vertical, DS.Space.s1)
|
||||
ForEach(ChinaNode.provinces, id: \.code) { province in
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
Text(L10n.isEnglish ? province.english : province.name)
|
||||
Text(L10n.usesEnglishNames ? province.english : province.name)
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -293,7 +299,7 @@ private struct GlobalNodeCard: View {
|
||||
ForEach(GlobalRegion.allCases, id: \.self) { region in
|
||||
let nodes = GlobalNode.all.filter { $0.region == region }
|
||||
if !nodes.isEmpty {
|
||||
Text(L10n.isEnglish ? region.nameEnglish : region.name)
|
||||
Text(L10n.usesEnglishNames ? region.nameEnglish : region.name)
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
.padding(.top, DS.Space.s1)
|
||||
@@ -319,7 +325,7 @@ private struct GlobalNodeRow: View {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
FlagImage(countryCode: node.countryCode, height: DS.Space.s4)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(L10n.isEnglish ? node.cityEnglish : node.city)
|
||||
Text(L10n.usesEnglishNames ? node.cityEnglish : node.city)
|
||||
.dsFont(.sm, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(verbatim: node.provider).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
@@ -415,7 +421,7 @@ private enum SpeedText {
|
||||
}
|
||||
|
||||
static func carrier(_ carrier: ChinaCarrier) -> String {
|
||||
L10n.isEnglish ? carrier.nameEnglish : carrier.name
|
||||
L10n.usesEnglishNames ? carrier.nameEnglish : carrier.name
|
||||
}
|
||||
|
||||
static func budget(_ budget: SpeedTestBudget) -> String {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import HelperShared
|
||||
import Localization
|
||||
@@ -244,9 +249,9 @@ private struct AddressSection: View {
|
||||
|
||||
/// 国家 · 省 / 州 · 城市;英文界面优先用英文地名,重复的相邻项只留一个
|
||||
private func location(_ addresses: PublicAddresses, code: String) -> String {
|
||||
let country = Locale(identifier: L10n.isEnglish ? "en" : "zh-Hans").localizedString(forRegionCode: code) ?? code
|
||||
let region = L10n.isEnglish ? (addresses.regionEnglish ?? addresses.region) : addresses.region
|
||||
let city = L10n.isEnglish ? (addresses.cityEnglish ?? addresses.city) : addresses.city
|
||||
let country = L10n.locale.localizedString(forRegionCode: code) ?? code
|
||||
let region = L10n.usesEnglishNames ? (addresses.regionEnglish ?? addresses.region) : addresses.region
|
||||
let city = L10n.usesEnglishNames ? (addresses.cityEnglish ?? addresses.city) : addresses.city
|
||||
var parts: [String] = [country]
|
||||
for part in [region, city].compactMap({ $0 }) where part != parts.last { parts.append(part) }
|
||||
return parts.joined(separator: " · ")
|
||||
@@ -405,7 +410,7 @@ private struct PuritySection: View {
|
||||
.foregroundStyle(risk.flags.isEmpty ? DS.Palette.success : DS.Palette.error)
|
||||
}
|
||||
}
|
||||
if let recommendation = purity.recommendation, !L10n.isEnglish {
|
||||
if let recommendation = purity.recommendation, !L10n.usesEnglishNames {
|
||||
Text(verbatim: recommendation)
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import Metrics
|
||||
@@ -36,6 +41,7 @@ struct PopoverFrame<Header: View, Content: View>: View {
|
||||
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
|
||||
.fixedSize(horizontal: false, vertical: isSnapshot)
|
||||
.background(DS.Palette.background, in: RoundedRectangle(cornerRadius: DS.Radius.xl))
|
||||
.appLanguageEnvironment()
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Localization
|
||||
import SwiftUI
|
||||
|
||||
/// 使用原生 popover 承载搜索和单选列表,保留外部点击与 Escape 关闭的系统行为。
|
||||
struct LanguagePicker: View {
|
||||
@Binding var selection: AppLanguage
|
||||
@State private var isPresented = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
isPresented.toggle()
|
||||
} label: {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
Text(selection.title)
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.accessibilityLabel(tr("选择语言"))
|
||||
.accessibilityValue(selection.title)
|
||||
.popover(isPresented: $isPresented, arrowEdge: .top) {
|
||||
LanguagePickerList(selection: selection) { language in
|
||||
isPresented = false
|
||||
selection = language
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 面板单独持有搜索和键盘高亮;每次打开重置,不影响持久化的实际选择。
|
||||
struct LanguagePickerList: View {
|
||||
let selection: AppLanguage
|
||||
let choose: (AppLanguage) -> Void
|
||||
@State private var query = ""
|
||||
@State private var highlighted: AppLanguage?
|
||||
@FocusState private var searchFocused: Bool
|
||||
|
||||
private var languages: [AppLanguage] {
|
||||
AppLanguage.allCases.filter { $0.matches(search: query) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
TextField(tr("搜索语言"), text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($searchFocused)
|
||||
.accessibilityLabel(tr("搜索语言"))
|
||||
.onSubmit {
|
||||
if let language = highlighted ?? languages.first { choose(language) }
|
||||
}
|
||||
.onKeyPress(.downArrow) { move(by: 1); return .handled }
|
||||
.onKeyPress(.upArrow) { move(by: -1); return .handled }
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
|
||||
if languages.isEmpty {
|
||||
Text(tr("没有匹配的语言"))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 2) {
|
||||
ForEach(languages) { language in
|
||||
Button {
|
||||
choose(language)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(language.title)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
if selection == language {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.contentShape(Rectangle())
|
||||
.background(highlighted == language ? Color.primary.opacity(0.10) : .clear,
|
||||
in: RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(selection == language ? .isSelected : [])
|
||||
.onHover { hovering in
|
||||
if hovering { highlighted = language }
|
||||
}
|
||||
.id(language)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
.frame(height: min(CGFloat(languages.count) * 32 + 10, 330))
|
||||
.onChange(of: highlighted) { _, next in
|
||||
if let next { proxy.scrollTo(next, anchor: .center) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.frame(width: 250)
|
||||
.padding(4)
|
||||
.onAppear {
|
||||
highlighted = selection
|
||||
searchFocused = true
|
||||
}
|
||||
.onChange(of: query) { _, _ in highlighted = languages.first }
|
||||
}
|
||||
|
||||
private func move(by offset: Int) {
|
||||
guard !languages.isEmpty else { return }
|
||||
let current = highlighted.flatMap { languages.firstIndex(of: $0) } ?? (offset > 0 ? -1 : languages.count)
|
||||
highlighted = languages[min(max(current + offset, 0), languages.count - 1)]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import HelperShared
|
||||
import Localization
|
||||
@@ -86,10 +91,9 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
GroupRow {
|
||||
SettingRow(title: "语言 / Language", subtitle: tr("立即切换;显示器、应用名称等由系统提供的文字在下次启动时切换")) {
|
||||
SegmentedControl(selection: $settings.language,
|
||||
options: AppLanguage.allCases.map { ($0, $0.title) })
|
||||
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
|
||||
SettingRow(title: tr("语言"), subtitle: tr("应用 UI 语言")) {
|
||||
LanguagePicker(selection: $settings.language)
|
||||
.help(tr("立即切换;显示器、应用名称等由系统提供的文字在下次启动时切换"))
|
||||
}
|
||||
}
|
||||
GroupRow {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import SwiftUI
|
||||
@@ -138,7 +143,7 @@ struct UpdatePromptView: View {
|
||||
.padding(.top, DS.Size.windowHeader)
|
||||
.frame(width: DS.Size.updateWindowWidth)
|
||||
.background(DS.Palette.background)
|
||||
.id(model.settings.language)
|
||||
.appLanguageEnvironment()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import Metrics
|
||||
@@ -13,6 +18,7 @@ enum SnapshotRenderer {
|
||||
|
||||
let defaults = UserDefaults(suiteName: "XStats.snapshot") ?? .standard
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
settings.language = L10n.language
|
||||
settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature, .battery]
|
||||
// 历史页用示例数据:最近 24 小时每分钟一条,中间留一段“睡眠”空档
|
||||
let historyURL = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-snapshot-history.sqlite")
|
||||
@@ -78,6 +84,9 @@ enum SnapshotRenderer {
|
||||
}
|
||||
write(UpdatePromptView {}, model: model, appearance: appearance,
|
||||
to: outputDirectory.appendingPathComponent("update-prompt-\(suffix).png"))
|
||||
write(LanguagePickerList(selection: settings.language) { _ in }.appLanguageEnvironment(),
|
||||
model: model, appearance: appearance,
|
||||
to: outputDirectory.appendingPathComponent("language-picker-\(suffix).png"))
|
||||
for item in MenuBarItem.allCases where item != .fan {
|
||||
write(PopoverRootView(item: item), model: model, appearance: appearance,
|
||||
to: outputDirectory.appendingPathComponent("popover-\(item.rawValue)-\(suffix).png"))
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import Foundation
|
||||
import Localization
|
||||
import Metrics
|
||||
@@ -247,11 +252,7 @@ extension AppLanguage: Identifiable {
|
||||
|
||||
/// 语言名称用各自的文字写,不随界面语言翻译
|
||||
var title: String {
|
||||
switch self {
|
||||
case .system: tr("跟随系统")
|
||||
case .chinese: "简体中文"
|
||||
case .english: "English"
|
||||
}
|
||||
self == .system ? tr("自动检测") : nativeName
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Copyright (c) 2026 GiantAccel, LLC
|
||||
// XStats modifications Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
||||
|
||||
import Foundation
|
||||
import Localization
|
||||
import Metrics
|
||||
@@ -90,7 +95,12 @@ public final class ProcessExplainer {
|
||||
}
|
||||
|
||||
private static var instructions: String {
|
||||
L10n.isEnglish ? englishInstructions : chineseInstructions
|
||||
switch L10n.language {
|
||||
case .chinese: chineseInstructions
|
||||
case .english: englishInstructions
|
||||
default:
|
||||
englishInstructions.replacingOccurrences(of: "in English", with: "in \(L10n.language.nativeName)")
|
||||
}
|
||||
}
|
||||
|
||||
private static let chineseInstructions = """
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Localization
|
||||
|
||||
struct LanguageSupportTests {
|
||||
@Test func containsExactlyNineLanguagesAndAutomaticDetection() {
|
||||
#expect(AppLanguage.allCases.count == 10)
|
||||
#expect(Set(AppLanguage.allCases.filter { $0 != .system }.map(\.languageCode)) ==
|
||||
Set(["zh-Hans", "zh-Hant", "ja", "ko", "en", "de", "es", "fr", "ar"]))
|
||||
#expect(AppLanguage(rawValue: "chinese") == .chinese)
|
||||
#expect(AppLanguage(rawValue: "english") == .english)
|
||||
#expect(AppLanguage(rawValue: "system") == .system)
|
||||
}
|
||||
|
||||
@Test func resolvesScriptsRegionsAndPreferenceOrder() {
|
||||
for value in ["zh-Hant", "zh-TW", "zh-HK", "zh_MO", "zh-Hant-CN"] {
|
||||
#expect(AppLanguage.resolve(preferredLanguages: [value]) == .traditionalChinese)
|
||||
}
|
||||
for value in ["zh-Hans", "zh-CN", "zh-SG", "zh-Hans-TW", "zh"] {
|
||||
#expect(AppLanguage.resolve(preferredLanguages: [value]) == .chinese)
|
||||
}
|
||||
#expect(AppLanguage.resolve(preferredLanguages: ["ru-RU", "ja-JP", "en"]) == .japanese)
|
||||
#expect(AppLanguage.resolve(preferredLanguages: ["es-MX", "zh-CN"]) == .spanish)
|
||||
#expect(AppLanguage.resolve(preferredLanguages: ["ar-EG"]) == .arabic)
|
||||
#expect(AppLanguage.resolve(preferredLanguages: ["ko-KR"]) == .korean)
|
||||
#expect(AppLanguage.resolve(preferredLanguages: ["de-CH"]) == .german)
|
||||
#expect(AppLanguage.resolve(preferredLanguages: ["fr-CA"]) == .french)
|
||||
#expect(AppLanguage.resolve(preferredLanguages: ["ru"]) == .english)
|
||||
#expect(AppLanguage.resolve(preferredLanguages: []) == .english)
|
||||
}
|
||||
|
||||
@Test func searchesNativeChineseEnglishAndAccents() {
|
||||
#expect(AppLanguage.japanese.matches(search: "日语"))
|
||||
#expect(AppLanguage.japanese.matches(search: "日本"))
|
||||
#expect(AppLanguage.german.matches(search: "german"))
|
||||
#expect(AppLanguage.german.matches(search: "DEUTSCH"))
|
||||
#expect(AppLanguage.french.matches(search: "francais"))
|
||||
#expect(AppLanguage.spanish.matches(search: "espanol"))
|
||||
#expect(AppLanguage.arabic.matches(search: "العربية"))
|
||||
#expect(AppLanguage.traditionalChinese.matches(search: "繁体"))
|
||||
#expect(AppLanguage.system.matches(search: "auto"))
|
||||
#expect(AppLanguage.allCases.allSatisfy { $0.matches(search: " ") })
|
||||
#expect(AppLanguage.allCases.contains { $0.matches(search: "zzzzzz") } == false)
|
||||
}
|
||||
|
||||
@Test func setsProcessLocaleWithoutChangingOtherPreferences() {
|
||||
let name = "LanguageSupportTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: name)!
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
defaults.set("keep", forKey: "unrelated")
|
||||
AppLanguage.traditionalChinese.applyToProcessLocale(defaults: defaults)
|
||||
#expect(defaults.stringArray(forKey: "AppleLanguages") == ["zh-Hant"])
|
||||
#expect(defaults.string(forKey: "AppleLocale")?.hasPrefix("zh_Hant_") == true)
|
||||
AppLanguage.arabic.applyToProcessLocale(defaults: defaults)
|
||||
#expect(defaults.stringArray(forKey: "AppleLanguages") == ["ar"])
|
||||
AppLanguage.system.applyToProcessLocale(defaults: defaults)
|
||||
// 移除应用覆盖后 UserDefaults 会继承全局值,应检查自身域而不是合并后的查询结果。
|
||||
#expect(defaults.persistentDomain(forName: name)?["AppleLanguages"] == nil)
|
||||
#expect(defaults.persistentDomain(forName: name)?["AppleLocale"] == nil)
|
||||
#expect(defaults.string(forKey: "unrelated") == "keep")
|
||||
#expect(AppLanguage.arabic.isRightToLeft)
|
||||
#expect(AppLanguage.french.isRightToLeft == false)
|
||||
#expect(L10n.locale(for: .french).language.languageCode?.identifier == "fr")
|
||||
}
|
||||
|
||||
@Test func templatesPreserveLiteralBracesAndTranslateNestedValues() {
|
||||
let table = Translations(tables: ["内存\tMemory\n打开 {}\tOpen {}\n{} / {}\t{2} then {1}"])
|
||||
#expect(table.translate("打开 config{}.json") == "Open config{}.json")
|
||||
#expect(table.translate("打开 内存") == "Open Memory")
|
||||
#expect(table.translate("first / second") == "second then first")
|
||||
let rtl = Translations(tables: ["地址:{}\tالعنوان: {}"], rightToLeft: true)
|
||||
#expect(rtl.translate("地址:192.0.2.1") == "العنوان: \u{2068}192.0.2.1\u{2069}")
|
||||
}
|
||||
|
||||
@Test func rejectsBrokenPlaceholdersAndEmptyTranslations() {
|
||||
let table = Translations(tables: ["下载 {}\tDownload\n{} / {}\t{1} twice {1}\n设置\t\n内存\tMemory"])
|
||||
#expect(table.sourceKeys == ["内存"])
|
||||
#expect(table.translate("下载 file.zip") == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: AppLanguage.allCases.filter { $0 != .system && $0 != .chinese })
|
||||
func everyLanguageHasCompleteCatalog(_ language: AppLanguage) throws {
|
||||
let catalog = try #require(Translations.catalog(for: language))
|
||||
#expect(catalog.sourceKeys == Translations.shared.sourceKeys)
|
||||
for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言",
|
||||
"设置", "内存", "下载并应用", "XStats 基于 OpenStats 开发,感谢原项目的开源贡献 · MIT License"] {
|
||||
#expect(catalog.translate(key)?.isEmpty == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Localization
|
||||
import Testing
|
||||
@testable import XStatsUI
|
||||
|
||||
@MainActor
|
||||
struct LanguageSettingsTests {
|
||||
@Test(arguments: AppLanguage.allCases)
|
||||
func restoresAndExportsEveryLanguage(_ language: AppLanguage) throws {
|
||||
let name = "LanguageSettingsTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: name)!
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
defaults.set(language.rawValue, forKey: AppSettings.languageKey)
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
#expect(settings.language == language)
|
||||
let document = settings.exportDocument()
|
||||
let decoded = try JSONDecoder().decode(SettingsDocument.self, from: JSONEncoder().encode(document))
|
||||
#expect(AppLanguage(rawValue: try #require(decoded.language)) == language)
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -41,15 +41,17 @@ The update installer still verifies bundle identity and signatures: packages bui
|
||||
|
||||
Requires macOS 14 (Sonoma) or later. Developed and tested mainly on Apple silicon; on Intel Macs, Apple
|
||||
Intelligence process explanations are unavailable, CPU cores are not split into performance and efficiency
|
||||
groups, and some power and frequency readings may be missing. The interface is in English and Simplified Chinese, following the
|
||||
system language by default; switch it in Settings → General → Language.
|
||||
groups, and some power and frequency readings may be missing. The interface supports Simplified Chinese,
|
||||
Traditional Chinese, Japanese, Korean, English, German, Spanish, French and Arabic.
|
||||
It detects your preferred system language by default. Search and switch languages in Settings → General → Language;
|
||||
Arabic uses a right-to-left layout.
|
||||
|
||||
## Recent updates
|
||||
|
||||
<!-- changelog:start -->
|
||||
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
|
||||
|
||||
Latest release **0.6.1** (2026-09-18) · **2** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
|
||||
Latest release **0.6.1** (2026-09-18) · **3** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
|
||||
|
||||
<!-- changelog:end -->
|
||||
|
||||
|
||||
+2
-2
@@ -32,8 +32,8 @@ XStats は、Mac の各 CPU コア、GPU、メモリ負荷、通信速度、デ
|
||||
|
||||
macOS 14(Sonoma)以降が必要です。主に Apple Silicon で開発・検証しています。
|
||||
Intel Mac では Apple Intelligence によるプロセス説明を利用できず、CPU コアの性能コア/効率コアへの分類や、一部の消費電力・周波数表示にも制限があります。
|
||||
**アプリの表示言語は現在、簡体字中国語と英語です。日本語と韓国語に対応しているのは README です。**
|
||||
表示言語はシステム設定に従い、アプリの設定から変更できます。
|
||||
アプリは簡体字中国語、繁体字中国語、日本語、韓国語、英語、ドイツ語、スペイン語、フランス語、アラビア語に対応しています。
|
||||
初期設定ではシステムの優先言語を自動検出します。設定の言語メニューで検索して切り替えられ、アラビア語では右から左のレイアウトを使用します。
|
||||
|
||||
XStats の公式サイトはまだありません。自動更新と既存の GeoIP サービスは従来の構成を維持していますが、アカウントログインは WebDAV 同期に置き換えられました。
|
||||
更新時のアプリ識別子・署名チェックは維持されているため、OpenStats 用のパッケージで XStats を置き換えることはできません。
|
||||
|
||||
+2
-2
@@ -32,8 +32,8 @@ XStats는 CPU 코어별 사용량, GPU, 메모리 압력, 네트워크 속도,
|
||||
|
||||
macOS 14(Sonoma) 이상이 필요하며, 주로 Apple Silicon에서 개발하고 검증합니다.
|
||||
Intel Mac에서는 Apple Intelligence 프로세스 설명을 사용할 수 없고, CPU의 성능 코어/효율 코어 구분과 일부 전력·주파수 표시에도 제한이 있습니다.
|
||||
**앱의 표시 언어는 현재 중국어 간체와 영어입니다. 일본어와 한국어는 README 문서에만 지원됩니다.**
|
||||
앱 언어는 기본적으로 시스템 설정을 따르며, 앱 설정에서 바꿀 수 있습니다.
|
||||
앱은 중국어 간체, 중국어 번체, 일본어, 한국어, 영어, 독일어, 스페인어, 프랑스어, 아랍어를 지원합니다.
|
||||
기본적으로 시스템의 선호 언어를 자동 감지합니다. 설정의 언어 메뉴에서 검색하여 전환할 수 있으며, 아랍어는 오른쪽에서 왼쪽으로 배치됩니다.
|
||||
|
||||
XStats에는 아직 공식 웹사이트가 없습니다. 자동 업데이트와 기존 GeoIP 서비스는 유지하지만, 계정 로그인은 WebDAV 동기화로 대체했습니다.
|
||||
업데이트 시 앱 식별자와 서명을 계속 검증하므로, OpenStats용 설치 패키지로 XStats를 교체할 수는 없습니다.
|
||||
|
||||
@@ -39,20 +39,22 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登
|
||||
|
||||
需要 macOS 14(Sonoma)或更高版本。主要在 Apple 芯片上开发和测试;Intel 机型上不能用 Apple 智能解释进程,
|
||||
CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
|
||||
界面支持简体中文与 English,默认跟随系统语言,可在“设置 · 通用 · 语言”中切换。
|
||||
界面支持简体中文、繁體中文、日本語、한국어、English、Deutsch、Español、Français 和 العربية。
|
||||
默认自动检测系统首选语言,可在“设置 · 通用 · 语言”中搜索并切换;阿拉伯语使用从右到左布局。
|
||||
|
||||
## 最近更新
|
||||
|
||||
<!-- changelog:start -->
|
||||
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
|
||||
|
||||
最新版本 **0.6.1**(2026-09-18) · 开发中 **2** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
|
||||
最新版本 **0.6.1**(2026-09-18) · 开发中 **3** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
|
||||
|
||||
<details open>
|
||||
<summary><b>2026-09-20</b> · 未发布 · 新增 1 · 调整 1</summary>
|
||||
<summary><b>2026-09-20</b> · 未发布 · 新增 2 · 调整 1</summary>
|
||||
|
||||
**新增**
|
||||
|
||||
- 语言设置支持搜索下拉选择、自动检测和选中标记,增加繁体中文、日语、韩语、德语、西班牙语、法语与阿拉伯语;阿拉伯语使用从右到左布局,语言偏好继续参与 WebDAV 设置备份。
|
||||
- 设置同步改用 WebDAV:配置已有 HTTPS 目录、用户名和密码,手动上传本机设置或下载后确认应用。密码只存本机钥匙串,备份不包含监控数据、历史记录和连接凭据;文件大小、格式与版本校验失败时保持本机设置不变。
|
||||
|
||||
**调整**
|
||||
|
||||
@@ -46,6 +46,7 @@ targets:
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
CFBundleDevelopmentRegion: zh-Hans
|
||||
CFBundleLocalizations: [zh-Hans, zh-Hant, ja, ko, en, de, es, fr, ar]
|
||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
||||
LSUIElement: true
|
||||
LSApplicationCategoryType: public.app-category.utilities
|
||||
|
||||
Reference in new issue
Block a user