英文界面:翻译层、语言设置与英文文案

This commit is contained in:
gentpan committed 2026-09-13 09:16:04 +05:00
1 parent de7a50b57c
commit e6bed09dc0
63 files changed
+2288 -925

No files matched your search

+1
View File
@@ -21,6 +21,7 @@
- 蓝牙设备电量:本机信息页列出已连接的键盘、鼠标、触控板和耳机的电量(AirPods 分左耳、右耳、充电盒),低于 20% 标红;通知里新增“蓝牙设备电量低”(低于 15%)。
- 全局快捷键(设置 · 通用):可为显示 / 隐藏主窗口、打开进程页、开关防休眠、释放内存录制快捷键,在任何应用中都能用,不需要辅助功能权限;被其他应用占用时提示更换。
- 桌面小组件“系统概览”:小号显示 CPU、内存圆环与磁盘用量,中号显示 CPU、内存、磁盘、电池四个圆环和磁盘可用空间;在自己的沙盒进程里读取数据,主应用没开也能显示,刷新间隔由系统决定(几分钟一次)。
- 英文界面(设置 · 通用 · 语言):跟随系统、简体中文、English 三选一,系统首选语言不是中文时默认显示英文;约 880 条界面文案、通知、错误提示和 Apple 智能解释的回答都有英文版本,日期按英文格式显示,切换后重启生效。
#### 调整
+9 -6
View File
@@ -6,6 +6,7 @@ let package = Package(
defaultLocalization: "zh-Hans",
platforms: [.macOS(.v14)],
products: [
.library(name: "Localization", targets: ["Localization"]),
.library(name: "SMC", targets: ["SMC"]),
.library(name: "Metrics", targets: ["Metrics"]),
.library(name: "HelperShared", targets: ["HelperShared"]),
@@ -14,18 +15,20 @@ let package = Package(
.library(name: "OpenStatsUI", targets: ["OpenStatsUI"]),
],
targets: [
.target(name: "SMC"),
.target(name: "Metrics", dependencies: ["SMC"], linkerSettings: [.linkedLibrary("IOReport")]),
.target(name: "HelperShared"),
.target(name: "Cleaner"),
.target(name: "Updates"),
.target(name: "OpenStatsUI", dependencies: ["Metrics", "SMC", "HelperShared", "Cleaner", "Updates"],
.target(name: "Localization"),
.target(name: "SMC", dependencies: ["Localization"]),
.target(name: "Metrics", dependencies: ["SMC", "Localization"], linkerSettings: [.linkedLibrary("IOReport")]),
.target(name: "HelperShared", dependencies: ["Localization"]),
.target(name: "Cleaner", dependencies: ["Localization"]),
.target(name: "Updates", dependencies: ["Localization"]),
.target(name: "OpenStatsUI", dependencies: ["Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates"],
resources: [.copy("Resources/Flags")]),
.testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]),
.testTarget(name: "CleanerTests", dependencies: ["Cleaner"]),
.testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]),
.testTarget(name: "UpdatesTests", dependencies: ["Updates"]),
.testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics"]),
.testTarget(name: "LocalizationTests", dependencies: ["Localization"]),
],
swiftLanguageModes: [.v6]
)
@@ -1,4 +1,5 @@
import Foundation
import Localization
/// 一个可以卸载的应用
public struct InstalledApp: Sendable, Identifiable, Hashable {
@@ -26,15 +27,15 @@ public struct AppLeftover: Sendable, Identifiable, Hashable {
public var title: String {
switch self {
case .application: "应用本体"
case .support: "应用数据"
case .caches: "缓存"
case .preferences: "偏好设置"
case .containers: "沙盒容器"
case .savedState: "窗口状态"
case .logs: "日志与崩溃报告"
case .webData: "网页数据"
case .launchAgents: "登录启动项"
case .application: tr("应用本体")
case .support: tr("应用数据")
case .caches: tr("缓存")
case .preferences: tr("偏好设置")
case .containers: tr("沙盒容器")
case .savedState: tr("窗口状态")
case .logs: tr("日志与崩溃报告")
case .webData: tr("网页数据")
case .launchAgents: tr("登录启动项")
}
}
}
@@ -53,10 +54,10 @@ public enum AppUninstallError: Error, Sendable, Equatable, CustomStringConvertib
public var description: String {
switch self {
case .systemApp: "系统自带的应用不能卸载"
case .running: "应用正在运行,请先退出"
case .notAnApp: "不是应用程序"
case .unsafePath(let path): "不在可卸载的位置:\(path)"
case .systemApp: tr("系统自带的应用不能卸载")
case .running: tr("应用正在运行,请先退出")
case .notAnApp: tr("不是应用程序")
case .unsafePath(let path): tr("不在可卸载的位置:\(path)")
}
}
}
@@ -1,4 +1,5 @@
import Foundation
import Localization
public enum CleanEngine {
// MARK: 扫描
@@ -120,7 +121,7 @@ public enum CleanEngine {
report.removedCount += 1
log?.record(item: item, rule: scan.rule, action: useTrash ? "trash" : "delete", detail: nil)
} catch {
report.failures.append("\(item.url.lastPathComponent):\(error.localizedDescription)")
report.failures.append(tr("\(item.url.lastPathComponent):\(error.localizedDescription)"))
log?.record(item: item, rule: scan.rule, action: "fail", detail: error.localizedDescription)
}
}
@@ -1,4 +1,5 @@
import Foundation
import Localization
public enum CleanCategory: String, Sendable, CaseIterable, Identifiable {
case system, browser, developer, downloads, trash
@@ -7,11 +8,11 @@ public enum CleanCategory: String, Sendable, CaseIterable, Identifiable {
public var title: String {
switch self {
case .system: "系统"
case .browser: "浏览器"
case .developer: "开发者"
case .downloads: "下载"
case .trash: "废纸篓"
case .system: tr("系统")
case .browser: tr("浏览器")
case .developer: tr("开发者")
case .downloads: tr("下载")
case .trash: tr("废纸篓")
}
}
}
@@ -31,10 +32,10 @@ public enum SkipReason: Sendable, Equatable, Hashable {
public var title: String {
switch self {
case .appRunning(let name): "\(name) 正在运行"
case .needsFullDiskAccess: "需要完全磁盘访问权限"
case .recentlyModified: "刚刚被使用"
case .protected: "受保护"
case .appRunning(let name): tr("\(name) 正在运行")
case .needsFullDiskAccess: tr("需要完全磁盘访问权限")
case .recentlyModified: tr("刚刚被使用")
case .protected: tr("受保护")
}
}
}
@@ -1,4 +1,5 @@
import Foundation
import Localization
/// 一个 launchd 启动项(LaunchAgents / LaunchDaemons 里的 plist)
public struct LaunchItem: Sendable, Identifiable, Hashable {
@@ -12,9 +13,9 @@ public struct LaunchItem: Sendable, Identifiable, Hashable {
public var title: String {
switch self {
case .user: "当前用户"
case .allUsers: "所有用户"
case .system: "系统服务"
case .user: tr("当前用户")
case .allUsers: tr("所有用户")
case .system: tr("系统服务")
}
}
@@ -133,19 +134,19 @@ public enum LaunchItems {
/// 停用:写入 launchd 的停用记录并卸载,不删除 plist,随时可以重新启用。只处理当前用户的启动项
public static func setEnabled(_ enabled: Bool, item: LaunchItem) -> String? {
guard item.scope == .user else { return "所有用户与系统级的启动项需要管理员权限,请在系统设置的登录项中管理" }
guard item.scope == .user else { return tr("所有用户与系统级的启动项需要管理员权限,请在系统设置的登录项中管理") }
let domain = "gui/\(getuid())"
let target = "\(domain)/\(item.label)"
if enabled {
_ = run(["enable", target])
let result = runStatus(["bootstrap", domain, item.plist.path])
// 已经加载时返回 5 / 37,不算失败
return [0, 5, 37].contains(result.status) ? nil : "启用失败:\(result.output)"
return [0, 5, 37].contains(result.status) ? nil : tr("启用失败:\(result.output)")
} else {
_ = run(["disable", target])
let result = runStatus(["bootout", target])
// 没有加载时返回 3 / 113,不算失败
return [0, 3, 36, 113].contains(result.status) ? nil : "停用失败:\(result.output)"
return [0, 3, 36, 113].contains(result.status) ? nil : tr("停用失败:\(result.output)")
}
}
@@ -1,4 +1,5 @@
import Foundation
import Localization
/// 清理规则。目录清单参考了 Mole 的清理范围(仅参考范围,代码为独立实现)。
public enum RuleCatalog {
@@ -17,8 +18,8 @@ public enum RuleCatalog {
]
static let userCaches = CleanRule(
id: "system.caches", category: .system, title: "应用缓存",
detail: "~/Library/Caches 中各应用的缓存,删除后会按需重建", symbol: "archivebox",
id: "system.caches", category: .system, title: tr("应用缓存"),
detail: tr("~/Library/Caches 中各应用的缓存,删除后会按需重建"), symbol: "archivebox",
checksOwnerApp: true
) { env in
try children(of: env.home + "/Library/Caches").filter { url in
@@ -28,8 +29,8 @@ public enum RuleCatalog {
}
static let logs = CleanRule(
id: "system.logs", category: .system, title: "应用日志与崩溃报告",
detail: "~/Library/Logs 中的日志和诊断报告", symbol: "doc.text",
id: "system.logs", category: .system, title: tr("应用日志与崩溃报告"),
detail: tr("~/Library/Logs 中的日志和诊断报告"), symbol: "doc.text",
checksOwnerApp: true
) { env in
try children(of: env.home + "/Library/Logs")
@@ -67,8 +68,8 @@ public enum RuleCatalog {
static func browserRule(_ browser: Browser) -> CleanRule {
CleanRule(
id: "browser.\(browser.id)", category: .browser, title: "\(browser.name) 缓存",
detail: "网页缓存与脚本缓存,不涉及 Cookie、历史记录和密码", symbol: "globe",
id: "browser.\(browser.id)", category: .browser, title: tr("\(browser.name) 缓存"),
detail: tr("网页缓存与脚本缓存,不涉及 Cookie、历史记录和密码"), symbol: "globe",
blockingApps: [(browser.bundleID, browser.name)], minimumAge: 0
) { env in
let fileManager = FileManager.default
@@ -109,32 +110,32 @@ public enum RuleCatalog {
// MARK: 开发者
static let xcodeDerivedData = CleanRule(
id: "developer.derivedData", category: .developer, title: "Xcode 编译缓存",
detail: "DerivedData,下次编译时自动重建", symbol: "hammer",
id: "developer.derivedData", category: .developer, title: tr("Xcode 编译缓存"),
detail: tr("DerivedData,下次编译时自动重建"), symbol: "hammer",
blockingApps: [("com.apple.dt.Xcode", "Xcode")]
) { env in
try childrenIfExists(of: env.home + "/Library/Developer/Xcode/DerivedData")
}
static let simulatorCaches = CleanRule(
id: "developer.simulatorCaches", category: .developer, title: "模拟器缓存",
detail: "CoreSimulator 的动态库与运行时缓存", symbol: "iphone",
blockingApps: [("com.apple.iphonesimulator", "模拟器")]
id: "developer.simulatorCaches", category: .developer, title: tr("模拟器缓存"),
detail: tr("CoreSimulator 的动态库与运行时缓存"), symbol: "iphone",
blockingApps: [("com.apple.iphonesimulator", tr("模拟器"))]
) { env in
try childrenIfExists(of: env.home + "/Library/Developer/CoreSimulator/Caches")
}
static let npmCache = CleanRule(
id: "developer.npm", category: .developer, title: "npm 缓存",
detail: "~/.npm/_cacache,安装依赖时自动重新下载", symbol: "shippingbox"
id: "developer.npm", category: .developer, title: tr("npm 缓存"),
detail: tr("~/.npm/_cacache,安装依赖时自动重新下载"), symbol: "shippingbox"
) { env in
let path = env.home + "/.npm/_cacache"
return FileManager.default.fileExists(atPath: path) ? [URL(fileURLWithPath: path)] : []
}
static let xcodeArchives = CleanRule(
id: "developer.archives", category: .developer, title: "Xcode 归档",
detail: "打包生成的 .xcarchive,删除后无法重新符号化旧版本崩溃日志", symbol: "archivebox.fill",
id: "developer.archives", category: .developer, title: tr("Xcode 归档"),
detail: tr("打包生成的 .xcarchive,删除后无法重新符号化旧版本崩溃日志"), symbol: "archivebox.fill",
selectedByDefault: false, policy: .trash, minimumAge: 0
) { env in
try childrenIfExists(of: env.home + "/Library/Developer/Xcode/Archives")
@@ -143,8 +144,8 @@ public enum RuleCatalog {
// MARK: 下载
static let incompleteDownloads = CleanRule(
id: "downloads.incomplete", category: .downloads, title: "未完成的下载",
detail: "超过 1 天未更新的 .crdownload / .part / .download", symbol: "arrow.down.circle",
id: "downloads.incomplete", category: .downloads, title: tr("未完成的下载"),
detail: tr("超过 1 天未更新的 .crdownload / .part / .download"), symbol: "arrow.down.circle",
policy: .trash, minimumAge: 86_400
) { env in
try childrenIfExists(of: env.home + "/Downloads")
@@ -152,8 +153,8 @@ public enum RuleCatalog {
}
static let installers = CleanRule(
id: "downloads.installers", category: .downloads, title: "安装包",
detail: "下载目录中的 .dmg / .pkg / .xip / .iso,移到废纸篓", symbol: "shippingbox.fill",
id: "downloads.installers", category: .downloads, title: tr("安装包"),
detail: tr("下载目录中的 .dmg / .pkg / .xip / .iso,移到废纸篓"), symbol: "shippingbox.fill",
selectedByDefault: false, policy: .trash, minimumAge: 0
) { env in
try childrenIfExists(of: env.home + "/Downloads")
@@ -163,8 +164,8 @@ public enum RuleCatalog {
// MARK: 废纸篓
static let trash = CleanRule(
id: "trash", category: .trash, title: "清空废纸篓",
detail: "永久删除废纸篓中的内容,无法恢复", symbol: "trash",
id: "trash", category: .trash, title: tr("清空废纸篓"),
detail: tr("永久删除废纸篓中的内容,无法恢复"), symbol: "trash",
selectedByDefault: false, minimumAge: 0
) { env in
try childrenIfExists(of: env.home + "/.Trash")
@@ -1,4 +1,5 @@
import Foundation
import Localization
public enum SafetyError: Error, Sendable, Equatable, CustomStringConvertible {
case notAbsolute
@@ -11,13 +12,13 @@ public enum SafetyError: Error, Sendable, Equatable, CustomStringConvertible {
public var description: String {
switch self {
case .notAbsolute: "不是绝对路径"
case .traversal: "路径包含 .."
case .controlCharacter: "路径包含控制字符"
case .outsideAllowedRoots: "不在允许清理的目录内"
case .isRoot: "不能删除清理目录本身"
case .protectedName(let name): "受保护的项目:\(name)"
case .applicationSupportNotCache: "Application Support 中只允许清理缓存目录"
case .notAbsolute: tr("不是绝对路径")
case .traversal: tr("路径包含 ..")
case .controlCharacter: tr("路径包含控制字符")
case .outsideAllowedRoots: tr("不在允许清理的目录内")
case .isRoot: tr("不能删除清理目录本身")
case .protectedName(let name): tr("受保护的项目:\(name)")
case .applicationSupportNotCache: tr("Application Support 中只允许清理缓存目录")
}
}
}
@@ -1,5 +1,6 @@
import Darwin
import Foundation
import Localization
/// 常用公共 DNS。只用 IPv4 地址:没有 IPv6 的网络里写入 IPv6 服务器会拖慢解析
public enum DNSPreset: String, CaseIterable, Identifiable, Sendable {
@@ -9,11 +10,11 @@ public enum DNSPreset: String, CaseIterable, Identifiable, Sendable {
public var title: String {
switch self {
case .automatic: "自动"
case .automatic: tr("自动")
case .cloudflare: "Cloudflare"
case .google: "Google"
case .tencent: "腾讯 DNSPod"
case .aliyun: "阿里云"
case .tencent: tr("腾讯 DNSPod")
case .aliyun: tr("阿里云")
}
}
@@ -0,0 +1,166 @@
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?
public static var isEnglish: Bool { language == .english }
/// 格式化日期用的地区:英文界面用英文月份名,地区沿用系统设置
public static var locale: Locale {
guard isEnglish else { return .current }
return Locale(identifier: "en_" + (Locale.current.region?.identifier ?? "US"))
}
public static func configure(_ language: AppLanguage) {
lock.lock()
self.language = language.resolved
cache = [:]
lock.unlock()
}
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
}
static func containsChinese(_ text: String) -> Bool {
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) {
handle.seekToEndOfFile()
handle.write(Data((text.replacingOccurrences(of: "\n", with: "\\n") + "\n").utf8))
try? handle.close()
} else {
try? Data((text + "\n").utf8).write(to: missLog)
}
}
}
/// 界面文案:英文界面下返回译文,否则原样返回
@inline(__always)
public func tr(_ text: String) -> String {
L10n.translate(text)
}
/// 翻译表:精确匹配表与带 `{}` 的模板
final class Translations: @unchecked Sendable {
static let shared = Translations()
private var exact: [String: String] = [:]
private var templates: [Template] = []
struct Template {
let fragments: [String]
let regex: NSRegularExpression
let english: String
}
private init() {
for table in Translations.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
}
}
}
// 片段越长越具体,优先匹配
templates.sort { $0.fragments.joined().count > $1.fragments.joined().count }
}
func translate(_ text: String, depth: Int = 0) -> String? {
if let english = exact[text] { return english }
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)
}
return fill(template.english, 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)
}
var iterator = values.makeIterator()
while let range = result.range(of: "{}") {
result.replaceSubrange(range, with: iterator.next() ?? "")
}
return result
}
}
@@ -0,0 +1,898 @@
// 英文译文:每行“中文原文<Tab>English”,由 Scripts/l10n_wrap.py 收集原文。
// 带插值的原文用 {} 表示插入的内容;译文可以用 {1}、{2} 调整顺序。
extension Translations {
static var tables: [String] {
[english]
}
static let english = #"""
{} 英寸 {}-inch
· {} 项使用中 · {} in use
· 移到废纸篓 · Moves to Trash
# OpenStats 诊断信息 # OpenStats Diagnostics
1 分钟 1 min
1 小时 1 hour
10 根柱子是最近 10 次采样(约 20 秒)的变化 10 bars show the last 10 samples (about 20 seconds)
15 分钟 15 min
2 小时 2 hours
24 小时 24 hours
5 分钟 5 min
5 小时 5 hours
60 秒峰值 Peak
7 天 7 days
ASN 库 ASN database
Apple 智能暂时不可用。 Apple Intelligence is temporarily unavailable.
Apple 智能模型还在下载或准备中,请稍后再试。 The Apple Intelligence model is still downloading or preparing. Try again later.
Apple 智能没有给出回答:{} Apple Intelligence didn't answer: {}
Apple 智能解释 Apple Intelligence
Apple(系统自带) Apple (built in)
Application Support 中只允许清理缓存目录 Only cache folders inside Application Support can be cleaned
CPU 时间 CPU Time
CPU 时间 {} CPU time {}
CPU 最高 CPU max
CPU 核心最高温度 Hottest CPU core
CPU 温度 CPU Temperature
CPU 温度 {} CPU temperature {}
CPU 温度 {},已持续超过 1 分钟。可以检查高占用进程,或在“温度与风扇”里提高风扇转速。 CPU temperature has been {} for over a minute. Check busy processes, or raise the fan speed in Temperature & Fans.
CPU 温度偏高 CPU is warm
CPU 温度持续 1 分钟高于设定值 CPU stays above the set temperature for 1 minute
CPU 温度达到 {},已恢复系统自动控制 CPU reached {}; fans returned to system control
CPU 温度过高 CPU is too hot
CPU 负载偏高 CPU load is high
CPU 负载很高 CPU load is very high
CPU 过热 CPU Overheating
CPU:{}(以单核满载为 100%) CPU: {} (100% = one full core)
Cloudflare(1.1.1.1) Cloudflare (1.1.1.1)
CoreSimulator 的动态库与运行时缓存 CoreSimulator dyld and runtime caches
DNS 地址格式不正确 Invalid DNS address
DNS 已设置为 {} DNS set to {}
DNS 缓存已刷新 DNS cache flushed
DerivedData,下次编译时自动重建 DerivedData; rebuilt on the next build
GPU 占用 GPU Usage
Google(8.8.8.8) Google (8.8.8.8)
IP 地址 IP Addresses
IP 归属地数据库 IP Location Database
OpenStats {} 已发布 OpenStats {} is available
OpenStats 控制 Controlled by OpenStats
OpenStats 控制中 Controlled by OpenStats
OpenStats 的通知被关闭了,打开的提醒不会显示。 Notifications for OpenStats are turned off, so alerts won't appear.
OpenStats 辅助工具 OpenStats Helper
OpenStats 通知测试 OpenStats Test Notification
OpenStats 防休眠 OpenStats Keep Awake
OpenStats 需要授权以安装新版本。 OpenStats needs permission to install the new version.
OpenStats 需要管理员权限来修改“{}”的 DNS。 OpenStats needs administrator permission to change DNS for “{}”.
OpenStats 需要管理员权限来刷新 DNS 缓存。 OpenStats needs administrator permission to flush the DNS cache.
OpenStats 需要管理员权限来释放内存。 OpenStats needs administrator permission to free memory.
OpenStats-诊断-{} OpenStats-Diagnostics-{}
OpenStats-诊断-{}.zip OpenStats-Diagnostics-{}.zip
SMC [{}] 不支持的数据类型 {} SMC [{}] unsupported data type {}
SMC [{}] 被固件拒绝 (0x{}) SMC [{}] rejected by firmware (0x{})
SMC [{}] 调用失败 ({}) SMC [{}] call failed ({})
SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License SMC access and the Apple Silicon fan unlock sequence are ported from this project · MIT License
SSD 健康 SSD Health
VPN / 代理 VPN / Proxy
VPN 隧道 VPN Tunnel
Xcode 归档 Xcode Archives
Xcode 编译缓存 Xcode Build Cache
ipinfo.io 在线查询 ipinfo.io (online)
macOS 系统目录(系统组件) macOS system folder (system component)
npm 缓存 npm Cache
{} {} 英寸 {} {}-inch
{} · {} 核{} {} · {} cores{}
{} · 每 {} 秒 {} · every {}s
{} · 约 {} {} · about {}
{} · 负载 {}/{} {} · load {}/{}
{} 个 {}
{} 个传感器 {} sensors
{} 个应用,系统自带的不列出 {} apps; built-in apps are not listed
{} 个进程 {} processes
{} 分钟 {} min
{} 可用 {} · 共 {} {} {} free · {} total
{} 天 {} d
{} 天 {} 小时 {} d {} h
{} 小时 {} h
{} 小时 {} 分钟 {} h {} min
{} 文件类型不对 {} has the wrong file type
{} 校验不一致,已丢弃 {} failed the checksum and was discarded
{} 核 {} cores
{} 核图形处理器 {}-core GPU
{} 正在运行 {} is running
{} 正在运行,卸载前需要先退出。 {} is running. Quit it before uninstalling.
{} 秒 {}s
{} 组 {} groups
{} 缓存 {} Cache
{} 英寸 {}-inch
{} 项 {} items
{} 项 · 可直接停用 {} items · can be turned off here
{} 项 · 需要管理员权限,只读 {} items · needs admin, read-only
{} 项失败 {} failed
{}%(阈值 {}%) {}% (threshold {}%)
{}{}只剩 {}%,记得充电。 {}{} is down to {}%. Time to charge.
{}{}({}) {}{} ({})
{}中 {}
{}前 {} ago
{}后充满 Full in {}
{}电量低 {} battery low
{}({}) {} ({})
{},{} 秒,后台{} {}, {}s, background {}
{},{},健康 {} {}, {}, health {}
{}:{} {}: {}
~/.npm/_cacache,安装依赖时自动重新下载 ~/.npm/_cacache; downloaded again when installing dependencies
~/Library/Caches 中各应用的缓存,删除后会按需重建 App caches in ~/Library/Caches; rebuilt as needed
~/Library/Logs 中的日志和诊断报告 Logs and diagnostic reports in ~/Library/Logs
“{}”只剩 {} 可用。可以用 OpenStats 的清理功能释放缓存。 “{}” has only {} free. Use OpenStats Cleanup to clear caches.
、 ,
一般 Fair
一键安装 Install Now
上传 Upload
上传 {} · 下载 {} Up {} · Down {}
上传与下载速度、IP 地址、DNS Upload and download speed, IP addresses, DNS
上次检查 {},最新 {} Last checked {}, latest {}
上次检查:{} Last checked: {}
下载 Download
下载 {} 失败 Failed to download {}
下载后归属地查询完全离线;数据库由 OpenStats 官网每周同步 MaxMind 的最新版本 Once downloaded, lookups are fully offline. The database is synced weekly from MaxMind by the OpenStats website.
下载失败:{} Download failed: {}
下载数据库 Download Database
下载目录中的 .dmg / .pkg / .xip / .iso,移到废纸篓 .dmg / .pkg / .xip / .iso files in Downloads; moved to Trash
不可用 Unavailable
不在允许清理的目录内 Not inside an allowed cleanup folder
不在可卸载的位置:{} Not in a location that can be uninstalled: {}
不提供电量 No battery info
不是团队 {} 签名的完整应用({}) Not a complete app signed by team {} ({})
不是城市、国家或 ASN 数据库({}) Not a City, Country or ASN database ({})
不是应用程序 Not an application
不是绝对路径 Not an absolute path
不能删除清理目录本身 Can't delete the cleanup folder itself
不限 No limit
不限时 Indefinitely
与 30 秒前持平 Same as 30s ago
丢包 Loss
严重 Critical
中等负载 Moderate load
主显示器 Main display
主窗口与弹窗的配色;菜单栏始终跟随系统 Colors for the window and popovers; the menu bar always follows the system
交换区 Swap
仅系统不休眠 Keep system awake
介质错误 Media errors
从左侧选择应用,或把应用拖到这里 Choose an app on the left, or drop one here
从文件导入… Import from File…
从未 Never
从程序坞移除图标 Remove from Dock
以单核满载为 100% 100% = one full core
以后再说 Later
以系统权限运行的后台服务,只接受本应用的请求 A background service with system privileges that only accepts requests from this app
仪表盘 Dashboard
会一并找出它留在资源库里的缓存、偏好设置、容器与登录启动项,全部移到废纸篓,可以放回 Also finds its caches, preferences, containers and launch agents in your Library and moves them all to the Trash, where you can restore them
会结束这个应用的 {} 个进程,未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。 This ends {} processes of the app and unsaved work may be lost. Force Quit ends them immediately without letting the app save.
传输速率 Link rate
位置 Location
位置:{} Location: {}
低负载 Light load
使用历史 Usage History
使用电池 On battery
使用电池且电量低于该值时,自动关闭合盖运行 Turn off lid-closed mode when on battery below this level
例如 1.1.1.1, 8.8.8.8 e.g. 1.1.1.1, 8.8.8.8
保持唤醒 Keep Awake
保持运行 Keep alive
信号强度 Signal
修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。 Changing DNS needs administrator permission: it's applied directly when the helper is installed, otherwise the system asks each time.
偏好设置 Preferences
偏高 Elevated
停用 Turn Off
停用失败:{} Failed to turn off: {}
健康 {} Health {}
健康度 Health
元数据不是字典 Metadata is not a dictionary
充电上限可在系统设置中设为 80%–100%,长期接电源时有助于延缓电池老化 Set a charge limit of 80%–100% in System Settings to slow battery aging when you stay plugged in
充电中 Charging
充电盒 Case
全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃) Global shortcuts (work in any app; must include ⌘, ⌥ or ⌃)
全选 Select All
全部 All
公网 IP Public IP
公网 IP 查询 Public IP Lookup
公网 IPv4 Public IPv4
公网 IPv6 Public IPv6
共 {} {} total
共 {} 条记录 {} records
关 Off
关于 About
关于 OpenStats About OpenStats
关闭 Off
其他 Other
其他位置 Other locations
其他程序手动控制 Controlled by another app
其他程序控制 Another app
内存 Memory
内存 {} Memory {}
内存与图形 Memory & Graphics
内存压力严重 Critical Memory Pressure
内存压力严重的时段:{} 个(图上红色标记) Critical memory pressure periods: {} (marked red)
内存压力偏高 Memory pressure is elevated
内存压力持续 30 秒处于严重,系统开始大量换页 Memory pressure stays critical for 30 seconds and the system starts heavy paging
内存已整理 Memory cleaned up
内存水位 Memory Levels
内存类型 Memory type
内存:{} Memory: {}
写入 Write
出现介质错误,建议备份 Media errors found; back up your data
切换到浅色 Switch to Light
切换到深色 Switch to Dark
刚刚 Just now
刚刚被使用 Recently used
到时后自动恢复系统默认的睡眠行为。 Normal sleep behavior resumes when time runs out.
刷新 Refresh
刷新 DNS 缓存 Flush DNS Cache
刷新 DNS 缓存、释放内存、为网络服务设置 DNS 服务器 Flush the DNS cache, free memory, set DNS servers for network services
刷新状态 Refresh Status
刷新间隔 Refresh Interval
刷新频率 Refresh Rate
剩余 {} {} left
剩余寿命 Life left
剪切 Cut
功耗 Power
包名是 {} Bundle ID is {}
包含城市数据 Include City Data
协议版本 Protocol version
单行 Inline
单行文字 Inline Text
占用 Usage
占用超过 85% 时数值与图形显示为红色 Show values and graphs in red above 85%
卷 Volume
卸载 Uninstall
卸载“{}”? Uninstall “{}”?
卸载失败:{} Uninstall failed: {}
卸载应用 Uninstaller
卸载辅助工具 Uninstall Helper
历史 History
历史记录 History
历史记录已关闭,下面是关闭前记录的数据。 History recording is off. Below is data recorded before it was turned off.
压力 {} Pressure {}
压力{} {}
压力走势 Pressure Trend
压缩 Compressed
压缩与交换 Compression & Swap
压缩为你省下 Saved by compression
压缩包里应当只有一个 .app The archive should contain exactly one .app
压缩比 Compression ratio
原因 Reason
去批准 Approve
去授权 Grant Access
双行圆点 Dots
双行文字 Stacked Text
双行箭头 Arrows
反馈问题 Report a Problem
发现新版本 {} Version {} available
发生这些状况时发送系统通知,点通知打开对应页面 Send a notification when these happen; click it to open the related page
发送 Send
发送测试通知 Send Test Notification
取消 Cancel
受保护 Protected
受保护的项目:{} Protected item: {}
另有 {} 移到废纸篓 Plus {} moved to Trash
另有 {} 项 {} more
只含当前用户的进程 Your processes only
只在打开温度与风扇页面时记录 Recorded only while Temperature & Fans is open
只在菜单栏显示 GPU 或打开 GPU 相关页面时记录 Recorded only when GPU is in the menu bar or a GPU page is open
只在菜单栏显示温度、开启过热通知或打开温度页面时记录 Recorded only when temperature is in the menu bar, overheating alerts are on, or a temperature page is open
只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒) Sampling interval when only the menu bar is shown; 1 second with a popover or window open (2 seconds on Processes)
只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动 Only files named after the app's bundle ID, plus folders with the app's name in Application Support and Logs, are included. Keychains and data shared with other apps are never touched.
可以恢复,但清空废纸篓前不会释放空间 Recoverable, but space isn't freed until the Trash is emptied
可执行文件:{} Executable: {}
可清理 · 已选 {} 项 Cleanable · {} selected
可用 Available
可用 {} {} available
可用内存只剩 {},系统正在压缩和交换内存。关闭不用的应用可以缓解。 Only {} of memory is available and the system is compressing and swapping. Quitting unused apps will help.
可用空间包含系统可以随时清除的缓存,与访达显示一致 Available space includes purgeable caches, matching Finder
右侧风扇 Right Fan
右耳 Right
各核心负载 Core Load
各项指标正常 Everything looks good
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 Your Mac stays awake with the lid closed, so downloads, renders and remote sessions continue.
合并为一个 Combined
合盖后继续运行 Keep Running with Lid Closed
合盖电量下限 Lid Mode Battery Floor
合盖睡眠 Lid sleep
合盖运行 Lid Closed
合盖运行时散热变差,请勿放入包中。使用电池且电量低于 {}% 时会自动关闭。 Cooling is worse with the lid closed, so don't put your Mac in a bag. Turns off automatically on battery below {}%.
合盖运行设置 Lid Mode
合盖运行需要修改系统睡眠设置,需安装辅助工具(管理员授权一次)。 Lid mode changes system sleep settings and needs the helper (one-time admin approval).
同时从程序坞移除图标 Also remove its Dock icon
后台低频探测 Background Probing
启动于 Booted
启动时和之后每天检查一次,发现新版本时显示更新摘要 Checks at launch and daily, and shows what's new when an update is found
启动磁盘 Startup Disk
启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次 Startup disk has less than 10% or 10 GB free; checked every 10 minutes
启动项 Startup Items
启用 Turn On
启用 {} Enable {}
启用失败:{} Failed to turn on: {}
唤醒 Wakeups
团队 {} team {}
国内网络建议选阿里云或腾讯;选路由器只检测本地连接 In mainland China, Alibaba Cloud or Tencent work best; Router only checks the local link
国家、城市与 ASN 在本机查询,不经过任何在线服务 Country, city and ASN are looked up on this Mac without any online service
国家库 Country database
图形 Graphics
图形处理器 GPU
图形核心 GPU cores
图标 Icon
圆环 Ring
圆环表示当前占用比例 A ring shows current usage
在线升级 Online Updates
在线升级:发现新版本时显示更新摘要,一键安装并自动重启 Online updates: see what's new, install in one click and relaunch automatically
在线查询 Online lookup
在菜单栏显示 Show in Menu Bar
在菜单栏显示{} Show {} in menu bar
在访达中显示 Show in Finder
型号 Model
城市库 City database
处理器 Processor
处理器核心 CPU cores
备用空间 Available spare
备用空间低于阈值,建议备份 Spare capacity below threshold; back up your data
外观 Appearance
存储 Storage
它只做这几件事 It only does these things
安全温度 Safety Temperature
安装 Install
安装包 Installers
安装包内容不正确:{} The update package is invalid: {}
安装包校验值与版本清单不一致,已放弃安装 The update's checksum doesn't match the manifest; installation stopped
安装失败:{} Install failed: {}
安装辅助工具 Install Helper
安静 Quiet
定时 ping 一个地址,记录网络是否通畅 Pings an address regularly to record whether the network is reachable
定时探测网络 Connectivity Probe
容量 Capacity
导入失败:{} Import failed: {}
导出… Export…
导出失败:{} Export failed: {}
导出时间 Exported at
导出诊断信息 Export Diagnostics
寿命偏低,注意备份 Low remaining life; keep backups
寿命按厂商估算的已用比例计算;写入量越大消耗越快,日常使用通常可用很多年 Life is based on the vendor's wear estimate. Heavy writes wear it faster; typical use lasts many years.
将清理 {} 个项目,共 {}。{} {} items, {} total, will be cleaned. {}
将移到废纸篓 Will move to Trash
小标签在上、数值在下,最紧凑 Small label above the value; most compact
尚未下载 Not downloaded
尚未检查 Not checked yet
屏幕保持常亮 Keep display on
屏幕可按设置关闭,下载、编译等后台任务继续运行 The display can turn off as usual while downloads and builds keep running
屏幕和系统都不会因闲置而关闭或休眠 Neither the display nor the system sleeps when idle
峰值 Peak
工具 Tools
左侧风扇 Left Fan
左耳 Left
已{}“{}” {} “{}”
已使用 Developer ID 签名(团队 {}),辅助工具只接受同一团队签名的 OpenStats。 Signed with Developer ID (team {}); the helper only accepts OpenStats signed by the same team.
已停用 Off
已充满 Charged
已到设定时间,防休眠已关闭 Time's up; Keep Awake is off
已加载 Loaded
已取消 Cancelled
已启用 Enabled
已导入 {} Imported {}
已导出 {} Exported {}
已将 {} 与 {} 项残留移到废纸篓,约 {}{}{}。需要时可以在废纸篓里放回。 Moved {} and {} leftovers to the Trash, about {}{}{}. You can put them back from the Trash.
已恢复自动获取 DNS DNS is automatic again
已拷贝 Copied
已是最新版本 Up to date
已更新 {} 个数据库 Updated {} databases
已用 Used
已用 {} · {} {} used · {}
已用内存占比 Memory used
已经是最新 Already up to date
已记录的数据会从本机删除,无法恢复。 Recorded data will be deleted from this Mac and can't be recovered.
已运行 Uptime
已运行 Up
已连接 Connected
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 A connected keyboard, mouse, headphones or similar drops below 15%; checked every 10 minutes
已选 {} 项 · {} {} selected · {}
已释放 Freed
已释放 {} 缓存内存 Freed {} of cached memory
布局 Layout
平均 Average
平均 {} · 峰值 {}{} Avg {} · Peak {}{}
平均 {} · 最高 {} Avg {} · Max {}
平均温度 Average temperature
平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU Load average per core: below 1 means no waiting; above 1 means tasks are waiting for the CPU
序列号 Serial Number
应用 Apps
应用 {} / 辅助工具 {} App {} / Helper {}
应用与勾选的 {} 项残留会移到废纸篓,约 {}。清空废纸篓前都可以放回。 The app and {} selected leftovers will move to the Trash, about {}. You can restore them until you empty the Trash.
应用包内未找到辅助工具,请使用完整构建的 OpenStats.app Helper not found in the app bundle. Use a complete OpenStats.app build.
应用拒绝退出,可以试试强制退出 The app refused to quit. Try Force Quit.
应用数据 App Data
应用日志与崩溃报告 App Logs & Crash Reports
应用本体 Application
应用正在运行,请先退出 The app is running. Quit it first.
应用程序文件夹 Applications folder
应用缓存 App Caches
应用退出或断开连接时,自动恢复风扇与睡眠设置 Restores fan and sleep settings when the app quits or disconnects
废纸篓 Trash
延迟 Latency
开 On
开关防休眠 Toggle Keep Awake
开发构建不支持在线升级 Development builds can't update online
开发构建不支持在线升级,请下载安装包 Development builds can't update online; download the installer instead
开发版 Development
开发者 Developer
开启 On
开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep) Turn lid-closed sleep prevention on or off (same as pmset disablesleep)
开机以来 Since boot
开机后上传 Uploaded since boot
开机后下载 Downloaded since boot
开机后自动在菜单栏显示 OpenStats Show OpenStats in the menu bar after you log in
异常断电 Unsafe shutdowns
弹窗显示 Popover Sections
强冷 Max
强制退出 Force Quit
归属地 Location
归属地数据库 Location database
当前 {} Current {}
当前 {} · 发布于 {} · {} · {} Current {} · Released {} · {} · {}
当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。 This is an ad-hoc signed development build, so the helper can only check the bundle ID. Team verification turns on automatically with a Developer ID build.
当前效果(实时数据) Preview (live data)
当前版本 Current version
当前版本 {} Current version {}
当前版本 {} · {} · {} Current version {} · {} · {}
当前用户 Current User
录制快捷键 Record Shortcut
循环 {} 次 {} cycles
循环次数 Cycle count
忙 Busy
快捷开关 Quick Toggles
性能核 Performance
总占用 Total
恢复系统睡眠失败:{} Failed to restore system sleep: {}
我的 Mine
截图已输出到 {} Snapshots saved to {}
所属应用:{}({}) App: {} ({})
所有内容先移到废纸篓。 Everything goes to the Trash first.
所有指标合成一个图标,点击打开主窗口 All metrics in one icon; click to open the main window
所有用户 All Users
所有用户与系统级的启动项需要管理员权限,请在系统设置的登录项中管理 All-users and system items need admin rights; manage them in Login Items in System Settings
所有磁盘合计 All disks
手动 Manual
手动下载 Download Manually
打包版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志和崩溃报告,反馈问题时附上;会去掉序列号、IP 与硬件地址 Bundles version, system and helper status, main settings, and the last 3 days of logs and crash reports to attach to a bug report. Serial numbers, IP and hardware addresses are removed.
打包生成的 .xcarchive,删除后无法重新符号化旧版本崩溃日志 .xcarchive bundles; without them old crash logs can't be symbolicated
打开 OpenStats Open OpenStats
打开 OpenStats 主窗口 Open the OpenStats Window
打开 SMC 失败 ({}) Failed to open SMC ({})
打开登录项设置 Open Login Items Settings
打开网络详情时使用 Used while network details are open
打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求 When network details open, asks Cloudflare (1.1.1.1) or ipify for your public address; location and ASN come from the local database. At most once every 10 minutes.
打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询 Looks up your public address when network details open; until the local database is ready, location and ASN come from ipinfo.io
打开进程页 Show Processes
打开通知设置 Open Notification Settings
执行中 Working
扫描中 Scanning
扫描中… Scanning…
扫描于 Scanned
抖动 Jitter
拷贝 Copy
拷贝 PID Copy PID
拷贝 PID {} Copy PID {}
持续时间 Duration
按下快捷键… Press shortcut…
按应用 By App
按应用汇总 By App
按系统休眠 System sleep
按进程 By Process
换入 / 换出 Swap in / out
掌托 Palm rest
排队程度 Run Queue
探测目标 Probe Target
探测间隔 Probe Interval
接口 Interface
接通电源 Plugged in
搜索名称、PID 或用户 Search name, PID or user
搜索应用 Search apps
撤销 Undo
散热模式 Cooling
数据来源 Source
整机 System
整机 {} System {}
整机功耗 System Power
新版本未通过 Apple 公证检查,已放弃安装 The new version failed Apple notarization; installation stopped
新版本需要 macOS {} 或更高版本 The new version requires macOS {} or later
无 None
无 Bundle ID No bundle ID
无法创建电源断言({}) Couldn't create power assertion ({})
无法创建签名要求 Couldn't create the signing requirement
无法打开 Couldn't open
无法读取 Unreadable
无法读取签名 Couldn't read the signature
无法连接辅助工具 Couldn't connect to the helper
无法连接辅助工具:{} Couldn't connect to the helper: {}
无需清理 Nothing to clean
日志与崩溃报告 Logs & Crash Reports
显卡信息 GPU info
显示 / 隐藏主窗口 Show / Hide Main Window
显示 {} 项 {} shown
显示为 {}×{} Looks like {}×{}
显示名称:{} Display name: {}
显示器 Displays
显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB) Shows city names; the database is about 60 MB. When off, only the country database (about 9 MB) is downloaded.
显示序列号 Show Serial Number
显示项目 Items
更新内容 What's New
更新失败:{} Update failed: {}
替换后未找到应用 App not found after replacement
替换应用失败:{} Failed to replace the app: {}
最低 {} · 最高 {} · 目标 {} RPM Min {} · Max {} · Target {} RPM
最小化 Minimize
最强 Strongest
最忙的核心 Busiest core
最近 60 秒 Last 60 seconds
最近没有应用在读写磁盘 No apps are reading or writing right now
最近的内存压力走势 Recent memory pressure
最近错误 Last error
最高 Max
最高 / 平均 Max / Avg
最高温度 Max temperature
服务器上还没有数据库,稍后再试 The server doesn't have the database yet. Try again later.
服务器清单里没有 {} {} is not in the server manifest
服务器返回 {} Server returned {}
未使用 Unused
未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。 Unsaved work may be lost. Force Quit ends it immediately without letting it save.
未安装 Not Installed
未完成的下载 Incomplete Downloads
未开启,Mac 按系统设置休眠 Off; your Mac sleeps as usual
未找到 AppleSMC 服务 AppleSMC service not found
未检测到可调节的风扇 No adjustable fans found
未检测到风扇 No fans detected
未知状态 Unknown status
未签名或临时签名 Unsigned or ad-hoc signed
未签名(开发构建) Unsigned (development build)
未连接 Not connected
未连接网络 No network
本产品包含 MaxMind 创建的 GeoLite2 数据,可从 https://www.maxmind.com 获取。 This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.
本地 GeoLite2(MaxMind) Local GeoLite2 (MaxMind)
本地 IPv4 Local IPv4
本地 IPv6 Local IPv6
本地 MaxMind GeoLite2 Local MaxMind GeoLite2
本机信息 This Mac
机型 Model
机型标识符 Model Identifier
极好 Excellent
构建 Build
查看 View
查看全部进程 All Processes
查看包含的项目 Show items
查看完整更新日志 Full Changelog
查看清理日志 View Cleanup Log
查询中… Looking up…
查询公网 IP Look Up Public IP
查询已关闭 Lookup off
柱状历史 Bar History
标签与数值同一行,最易读 Label and value on one line; easiest to read
标识 Identifier
核心 Cores
核心分工 Core Types
核心数 Cores
核心热力图 Core Heatmap
核心负载 Core Load
检查更新 Check for Updates
检查更新… Check for Updates…
检查更新失败 Update check failed
模式 Mode
模拟器 Simulators
模拟器缓存 Simulator Caches
正在下载 Downloading
正在使用 In use
正在修改 DNS… Changing DNS…
正在刷新… Flushing…
正在安装,完成后 OpenStats 会自动重启。 Installing. OpenStats will relaunch when it's done.
正在导出… Exporting…
正在扫描应用… Scanning apps…
正在收集走势… Collecting trend…
正在更新… Updating…
正在本机生成… Generating on this Mac…
正在查找残留… Finding leftovers…
正在核对校验值、开发者签名与 Apple 公证… Verifying checksum, developer signature and Apple notarization…
正在检查… Checking…
正在检查更新 Checking for updates
正在清理… Cleaning…
正在移除… Removing…
正在统计… Counting…
正在统计各进程流量… Measuring per-process traffic…
正在计算可清理空间 Calculating cleanable space
正在读取 SMART 信息;外置磁盘或不支持 NVMe SMART 的磁盘不显示 Reading SMART data; external disks and disks without NVMe SMART aren't shown
正在读取… Loading…
正在读取传感器… Reading sensors…
正在读取进程… Reading processes…
正在读取风扇… Reading fans…
正在释放… Freeing…
正常 Normal
此设备没有可调节的风扇 This Mac has no adjustable fans
每 3 天检查一次 Checks every 3 days
每 3 天检查一次 · 上次检查 {} Checks every 3 days · last checked {}
每个指标一个图标,点击弹出该项详情 One icon per metric; click for its details
每分钟把主要指标的平均值与峰值写入本机数据库,保留 7 天,不上传 Writes averages and peaks of key metrics to a local database every minute. Kept for 7 days, never uploaded.
每核 {} {} per core
每点 {} {} per point
每秒让 CPU 从空闲中唤醒的次数,越高越耗电 Times per second the CPU is woken from idle; higher uses more power
每秒读写磁盘的字节数 Bytes read and written per second
每行一个核心,每列一次采样,最新的在右边 One row per core, one column per sample; newest on the right
每项独立 Separate
比 30 秒前低 {}% {}% lower than 30s ago
比 30 秒前高 {}% {}% higher than 30s ago
永久删除废纸篓中的内容,无法恢复 Permanently deletes items in the Trash; can't be undone
沙盒容器 Containers
没有匹配的进程 No matching processes
没有启动项 No startup items
没有已连接的蓝牙设备 No connected Bluetooth devices
没有检测到显示器 No displays detected
没有移动任何文件{} Nothing was moved{}
流量历史 Traffic History
流量经过 {},系统 DNS 可能由它接管,修改后不一定生效。 Traffic goes through {}, which may take over system DNS, so changes might not apply.
浅色 Light
浅色菜单栏预览 Light menu bar preview
浏览器 Browsers
深色 Dark
深色菜单栏预览 Dark menu bar preview
清单里的文件地址无效 Invalid file URL in the manifest
清理 Cleanup
清理中… Cleaning…
清理可回收的缓存内存(需要管理员授权或辅助工具) Frees reclaimable cached memory (needs admin approval or the helper)
清理所选 {} Clean Selected {}
清空废纸篓 Empty Trash
清除 Clear
清除… Clear…
清除全部历史记录? Clear all history?
清除历史 Clear History
清除快捷键 Clear shortcut
清除搜索 Clear search
温度 Temperature
温度与风扇 Thermals
温度传感器 Temperature sensors
温度余量 Thermal headroom
温度单位 Temperature Unit
满载 Maxed out
点击拷贝 Click to copy
版本 Version
版本 {} Version {}
版本 {} · {} Version {} · {}
版本 {}{} Version {}{}
版本是 {},清单写的是 {} Version is {}, but the manifest says {}
版本清单格式不正确 Invalid version manifest
物理地址 Hardware address
状态 Status
状态圆点 Status Dot
状态良好 Healthy
现在 Now
用 Apple 智能解释 Explain with Apple Intelligence
用 ping 测量延迟与丢包,在网络详情里以格子显示 Measures latency and loss with ping, shown as a grid in network details
用户 User
用户 {} User {}
用户资源库 User Library
用系统图标代替文字标签 System icons instead of text labels
由 Apple 智能在这台 Mac 上生成,不联网;内容可能不准确,结束进程前请自行确认。 Generated on this Mac by Apple Intelligence, offline. It may be inaccurate; double-check before ending a process.
由 SMC 与系统能耗统计读取;新款芯片不提供可靠的 CPU 单独功耗 Read from the SMC and system energy stats; newer chips don't report reliable CPU-only power
由 macOS 调节 Managed by macOS
由辅助工具执行 Handled by the helper
电池 Battery
电池供电 On battery
电池健康度下降 Battery Health Low
电池充电 Battery charging
电池放电 Battery discharging
电池最大容量为 {},续航会明显缩短。可以在“系统设置 › 电池”查看是否建议维修。 Battery maximum capacity is {}, so battery life will be noticeably shorter. Check System Settings › Battery for service recommendations.
电池最大容量低于 80%,每 30 天最多提醒一次 Battery maximum capacity drops below 80%; at most once every 30 days
电池设置 Battery Settings
电源 Power
电源供电 On power adapter
电源输入 Adapter input
电源适配器{} Power adapter{}
电量 Battery
电量下限 Battery floor
电量低于 {}%,已关闭合盖运行 Battery below {}%; lid mode turned off
电量条 Meter
登录启动项 Launch Agents
登录时启动 Launch at Login
登录时运行 Runs at login
登录项设置 Login Items
监控 Monitor
目标转速 Target speed
确认清理 Clean
确认通知能正常显示 Check that notifications appear
磁盘 Disk
磁盘报告了严重警告,建议尽快备份 The disk reported a critical warning; back up soon
磁盘清理 Disk Cleanup
磁盘空间不足 Low Disk Space
磁盘空间偏紧 Disk space is tight
离 100°C 还差多少度;越接近 0,越可能因过热降频 Degrees below 100°C; the closer to 0, the more likely thermal throttling
离线查询 Offline lookup
移到废纸篓 Move to Trash
空 empty
空格 Space
空闲 Idle
窗口 Window
窗口状态 Saved State
立即检查更新 Check Now
竖向电量条表示当前占用比例 A vertical meter shows current usage
等待批准 Waiting for Approval
签名团队 Signing team
签名方:{} Signed by: {}
签名校验未通过:{} Signature check failed: {}
粘贴 Paste
系统 System
系统或其他用户的进程,OpenStats 不提供结束 A system or other user's process; OpenStats can't end it
系统服务 System Services
系统正在把内存写到磁盘,可能会变慢。可以关掉占用大的应用。 The system is swapping memory to disk and may slow down. Quit apps that use a lot of memory.
系统版号 Build
系统级资源库(第三方驱动、辅助工具或后台服务) System Library (third-party drivers, helpers or background services)
系统维护 Maintenance
系统自动 Automatic
系统自带的应用不能卸载 Built-in apps can't be uninstalled
系统调节 System
累计写入 Data written
累计读取 Data read
繁忙 Busy
线程 Threads
线程 {} Threads {}
线程数(只能读取自己的进程) Thread count (your processes only)
结束 {} 失败:{} Failed to end {}: {}
结束“{}”? End “{}”?
结束它会注销当前用户,已禁止 Ending it would log you out, so it's blocked
结束进程… End Process…
统一内存(CPU 与 GPU 共享) Unified memory (shared by CPU and GPU)
继续运行 Keep running
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) Green is normal, orange is high (above 60%), red is very high (above 85%)
缓存 Caches
缓存与日志直接删除,下载内容移到废纸篓。 Caches and logs are deleted; downloads go to the Trash.
缓存也先移到废纸篓 Move caches to Trash too
编辑 Edit
缺少 node_count / record_size / ip_version Missing node_count / record_size / ip_version
网络 Network
网络名称 Network name
网络已恢复 Network Restored
网络断开 Network Down
网络运营方 Network operator
网络连接中断超过 20 秒,恢复后再提示一次 Network has been down for over 20 seconds; notifies again when it's back
网络连接已中断超过 20 秒。可以打开网络详情查看接口与路由器状态。 The network has been down for over 20 seconds. Open network details to check the interface and router.
网络连接已经恢复。 The network connection is back.
网络:{} Network: {}
网页数据 Web Data
网页缓存与脚本缓存,不涉及 Cookie、历史记录和密码 Web and script caches; cookies, history and passwords are untouched
联动 Wired
能效核 Efficiency
腾讯 DNSPod Tencent
腾讯(119.29.29.29) Tencent (119.29.29.29)
自动 Auto
自动更新 Auto Update
自动检查更新 Check Automatically
自动(由路由器分配) Automatic (from router)
自定义 Custom
自定义模式下 CPU 达到 {}°C 会自动交还系统控制;退出应用时风扇恢复自动。 In Custom mode, fans return to system control when the CPU reaches {}°C, and to automatic when you quit.
自定义转速时,CPU 达到该温度自动恢复系统控制 With a custom speed, fans return to system control at this CPU temperature
自己的进程为实际占用内存,系统进程为常驻内存 Footprint for your processes, resident memory for system processes
致谢 Acknowledgements
良好 Good
芯片 Chip
菜单栏 Menu Bar
菜单栏图标 Menu Bar Icons
菜单栏布局 Menu Bar Layout
菜单栏项目 Menu Bar Items
菜单栏风格 Menu Bar Style
蓝牙设备 Bluetooth Devices
蓝牙设备电量低 Bluetooth Battery Low
解压失败:{} Unzip failed: {}
计算中 Calculating
记录历史数据 Record History
设置 Settings
设置 · {} Settings · {}
设置… Settings…
设置风扇目标转速,或恢复系统自动控制 Set fan target speeds, or return them to automatic
详情关闭时,菜单栏显示网络项期间每 {} 秒探测一次,打开详情就能看到最近的连接情况;关闭后只在打开详情时探测,更省电 While details are closed and the network item is in the menu bar, probes every {} seconds so recent results are ready when you open details. Turn off to probe only while details are open and save power.
语言 Language
请从菜单退出 OpenStats Quit OpenStats from its menu
请先在“系统设置 → Apple 智能与 Siri”中开启 Apple 智能。 Turn on Apple Intelligence in System Settings → Apple Intelligence & Siri first.
请在“系统设置 › 通用 › 登录项”中允许 OpenStats 的后台项目。 Allow OpenStats in System Settings › General › Login Items.
读 {} · 写 {} Read {} · Write {}
读写最多的应用 Top Disk Activity
读写速度 Read & Write
读取 Read
读取中 Loading
调节风扇需要安装辅助工具,仅需管理员授权一次。 Fan control needs the helper, which requires one-time admin approval.
调速模式 Fan Mode
负载在上升 Load rising
负载在下降 Load falling
负载平稳 Load steady
超级核 Super
超过 1 天未更新的 .crdownload / .part / .download .crdownload / .part / .download files untouched for over a day
跟随整体 Follow global
跟随系统 System
路径不可读 Path not readable
路径包含 .. Path contains ..
路径包含控制字符 Path contains control characters
路由器 Router
路由器(网关) Router (gateway)
跳过 {} 项 {} skipped
跳过此版本 Skip This Version
转速 {} Speed {}
转速最高的风扇 Fastest fan
软件更新 Software Update
轻量的 macOS 菜单栏系统监控:CPU、GPU、内存、网络、温度、风扇、防休眠与清理。 A lightweight macOS menu bar monitor: CPU, GPU, memory, network, temperature, fans, keep awake and cleanup.
较弱 Weak
辅助工具 Helper
辅助工具未启用 Helper is not enabled
辅助工具版本比应用旧,部分功能可能无法使用,请重新安装一次(需要管理员授权)。 The helper is older than the app and some features may not work. Reinstall it (admin approval required).
过热温度 Overheat Threshold
运行中 · PID {} Running · PID {}
还可用 Available
这个快捷键已被其他应用或系统占用,请换一个 This shortcut is used by another app or the system. Choose another.
这台 Mac 不提供功耗读数 This Mac doesn't report power
这台 Mac 不支持 Apple 智能。 This Mac doesn't support Apple Intelligence.
这台 Mac 没有电池 This Mac has no battery
这段时间没有记录 No records for this period
这段时间还没有记录。OpenStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。 No records for this period yet. OpenStats records once a minute while running, except during sleep and lock.
这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。 Lists LaunchAgents and LaunchDaemons from your Library folders. Turning one off records it as disabled and unloads it without deleting files, so you can turn it back on anytime. Login apps and background permissions are managed in System Settings.
进程 Processes
进程 {} · 线程 {} Processes {} · Threads {}
进程名:{} Process name: {}
进程启动以来累计占用的 CPU 时间 Total CPU time since the process started
进程页刷新频率调整为每 2 秒 Processes page now refreshes every 2 seconds
连接探测 Connectivity
连接探测历史 Probe History
连接探测只在需要时运行,更省电 Connectivity probing runs only when needed, saving power
退出 Quit
退出 OpenStats Quit OpenStats
退出应用 Quit App
退出应用… Quit App…
适中 Moderate
选择 GeoLite2 City / Country / ASN 的 .mmdb 文件 Choose a GeoLite2 City / Country / ASN .mmdb file
通用 General
通电时间 Power-on hours
通电次数 Power cycles
通知 Notifications
通知可以正常显示。发生你打开的状况时,会像这样提醒你。 Notifications are working. You'll be alerted like this when something you turned on happens.
部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。 Some items need Full Disk Access to scan (Safari caches, Trash).
配置方式 Configuration
释放内存 Free Memory
重做 Redo
重新安装 Reinstall
重新扫描 Rescan
重新查询公网 IP Look Up Public IP Again
重新生成 Regenerate
闲 Idle
防休眠 Keep Awake
防休眠已开启 Keep Awake is on
阿里云 Alibaba Cloud
阿里云(223.5.5.5) Alibaba Cloud (223.5.5.5)
降温 Cooling
隐藏 OpenStats Hide OpenStats
隐藏序列号 Hide Serial Number
需要 macOS 26 及以上,并在系统设置中开启 Apple 智能。 Requires macOS 26 or later with Apple Intelligence turned on in System Settings.
需要完全磁盘访问权限 Needs Full Disk Access
需要注意 Needs attention
需要管理员授权 Needs admin approval
风扇 Fans
风扇 {} Fan {}
风扇安全温度 Fan Safety Temperature
风扇模式 Fan mode
风扇设置 Fan Settings
风扇转速 Fan Speed
风格 Style
饼图 Pie
饼图表示当前占用比例 A pie shows current usage
首次发送时系统会询问是否允许通知 The system asks for permission the first time
高占用进程 Top Processes
高负载 Heavy load
高负载时着色 Color High Load
({}) ({})
, ,
,{} 项未能移动 , {} couldn't be moved
,已从程序坞移除 , removed from the Dock
:{} : {}
重新启动 OpenStats 后生效 Takes effect after OpenStats relaunches
立即重启 Relaunch Now
1 项 1 item
1 个进程 1 process
1 个应用,系统自带的不列出 1 app; built-in apps are not listed
1 个传感器 1 sensor
"""#
}
@@ -1,5 +1,6 @@
import Foundation
import IOKit
import Localization
/// 已连接蓝牙设备的电量
public struct BluetoothDevice: Sendable, Equatable, Identifiable {
@@ -26,9 +27,9 @@ public enum BluetoothBatteryReader {
var devices = parseSystemProfiler(runSystemProfiler())
for (product, percent) in hidBatteries() {
if let index = devices.firstIndex(where: { $0.name == product }) {
if devices[index].batteries.isEmpty { devices[index].batteries = [("电量", percent)] }
if devices[index].batteries.isEmpty { devices[index].batteries = [(tr("电量"), percent)] }
} else {
devices.append(BluetoothDevice(name: product, address: "", kind: kind(forName: product, minorType: nil), batteries: [("电量", percent)]))
devices.append(BluetoothDevice(name: product, address: "", kind: kind(forName: product, minorType: nil), batteries: [(tr("电量"), percent)]))
}
}
return devices.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
@@ -43,9 +44,9 @@ public enum BluetoothBatteryReader {
for entry in section["device_connected"] as? [[String: Any]] ?? [] {
for (name, value) in entry {
guard let info = value as? [String: Any] else { continue }
let labels: [(String, String)] = [("device_batteryLevelMain", "电量"), ("device_batteryLevel", "电量"),
("device_batteryLevelLeft", "左耳"), ("device_batteryLevelRight", "右耳"),
("device_batteryLevelCase", "充电盒")]
let labels: [(String, String)] = [("device_batteryLevelMain", tr("电量")), ("device_batteryLevel", tr("电量")),
("device_batteryLevelLeft", tr("左耳")), ("device_batteryLevelRight", tr("右耳")),
("device_batteryLevelCase", tr("充电盒"))]
var batteries: [(String, Int)] = []
for (key, label) in labels {
if let percent = percentValue(info[key]), !batteries.contains(where: { $0.0 == label }) {
@@ -1,5 +1,6 @@
import Darwin
import Foundation
import Localization
public struct CPUSampler {
private var previousTicks: [[UInt32]] = []
@@ -13,7 +14,7 @@ public struct CPUSampler {
guard levels > 1 else {
return CPUTopology(brand: brand, logicalCores: logical,
clusters: [CPUCluster(id: 0, name: "核心", coreIndices: Array(0..<logical))])
clusters: [CPUCluster(id: 0, name: tr("核心"), coreIndices: Array(0..<logical))])
}
// 核心序号从最低性能档开始排列:perflevel(n-1) 在前,perflevel0 在后
@@ -31,10 +32,10 @@ public struct CPUSampler {
private static func localizedClusterName(_ raw: String) -> String {
switch raw.lowercased() {
case "super": "超级核"
case "performance": "性能核"
case "efficiency": "能效核"
default: raw.isEmpty ? "核心" : raw
case "super": tr("超级核")
case "performance": tr("性能核")
case "efficiency": tr("能效核")
default: raw.isEmpty ? tr("核心") : raw
}
}
@@ -1,4 +1,5 @@
import Foundation
import Localization
public struct History<Element: Sendable>: Sendable {
public let capacity: Int
@@ -82,16 +83,16 @@ public enum Format {
let hours = minutes / 60
let rest = minutes % 60
switch (hours, rest) {
case (0, _): return "\(rest) 分钟"
case (_, 0): return "\(hours) 小时"
default: return "\(hours) 小时 \(rest) 分钟"
case (0, _): return tr("\(rest) 分钟")
case (_, 0): return tr("\(hours) 小时")
default: return tr("\(hours) 小时 \(rest) 分钟")
}
}
/// CPU 时间:不到 1 分钟显示“12.34 秒”,不到 1 小时“12:34”,更长“1:02:03”
public static func cpuTime(_ seconds: Double) -> String {
let total = max(0, seconds)
if total < 60 { return "\(total.formatted(.number.precision(.fractionLength(2)))) 秒" }
if total < 60 { return tr("\(total.formatted(.number.precision(.fractionLength(2)))) 秒") }
let whole = Int(total)
let hours = whole / 3600, minutes = whole % 3600 / 60, secs = whole % 60
return hours > 0 ? String(format: "%d:%02d:%02d", hours, minutes, secs) : String(format: "%d:%02d", minutes, secs)
@@ -101,8 +102,8 @@ public enum Format {
let minutes = max(0, Int(now.timeIntervalSince(date) / 60))
let days = minutes / (60 * 24)
let hours = (minutes / 60) % 24
if days > 0 { return hours > 0 ? "\(days) 天 \(hours) 小时" : "\(days) 天" }
if hours > 0 { return "\(hours) 小时" }
return "\(minutes) 分钟"
if days > 0 { return hours > 0 ? tr("\(days) 天 \(hours) 小时") : tr("\(days) 天") }
if hours > 0 { return tr("\(hours) 小时") }
return tr("\(minutes) 分钟")
}
}
@@ -1,6 +1,7 @@
import Foundation
import IOKit
import IOKit.ps
import Localization
// MARK: - 磁盘
@@ -12,7 +13,7 @@ public enum DiskSampler {
let total = values.volumeTotalCapacity else { return nil }
// “重要用途可用空间”包含可清除空间,与访达显示一致
let available = values.volumeAvailableCapacityForImportantUsage ?? 0
return DiskUsage(volumeName: values.volumeLocalizedName ?? "磁盘",
return DiskUsage(volumeName: values.volumeLocalizedName ?? tr("磁盘"),
total: UInt64(total), available: UInt64(max(0, available)))
}
}
@@ -1,4 +1,5 @@
import Foundation
import Localization
import SQLite3
/// 每分钟一条的历史记录;没有采集到的指标为 nil
@@ -64,7 +65,7 @@ public actor HistoryDatabase {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
var handle: OpaquePointer?
guard sqlite3_open_v2(url.path, &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
let message = handle.map { String(cString: sqlite3_errmsg($0)) } ?? "无法打开"
let message = handle.map { String(cString: sqlite3_errmsg($0)) } ?? tr("无法打开")
sqlite3_close(handle)
throw Error.open(message)
}
@@ -1,5 +1,6 @@
import Darwin
import Foundation
import Localization
/// MaxMind DB(.mmdb)读取器,按格式规范 2.0 实现:二叉搜索树定位记录,再解码数据段。
/// 文件以内存映射方式打开,查询不需要把整个库读进内存。
@@ -39,11 +40,11 @@ public final class MaxMindDatabase: @unchecked Sendable {
}
var decoder = Decoder(data: data, pointerBase: marker.upperBound)
guard case .map(let metadata)? = try? decoder.decode(at: marker.upperBound).value else {
throw Error.invalidMetadata("元数据不是字典")
throw Error.invalidMetadata(tr("元数据不是字典"))
}
guard let nodes = metadata["node_count"]?.integer, let record = metadata["record_size"]?.integer,
[24, 28, 32].contains(record), let version = metadata["ip_version"]?.integer else {
throw Error.invalidMetadata("缺少 node_count / record_size / ip_version")
throw Error.invalidMetadata(tr("缺少 node_count / record_size / ip_version"))
}
nodeCount = nodes
recordSize = record
@@ -272,7 +273,7 @@ public struct GeoLocation: Sendable, Equatable {
}
public enum GeoLookup {
/// 名称优先取简体中文,没有时用英文
/// 中文界面优先取简体中文名称(没有时用英文),英文界面取英文
public static func locate(_ address: String, location: MaxMindDatabase?, asn: MaxMindDatabase?) -> GeoLocation? {
var result = GeoLocation()
if let record = location?.lookup(address) {
@@ -290,6 +291,6 @@ public enum GeoLookup {
private static func name(_ value: MaxMindDatabase.Value?) -> String? {
guard let names = value?["names"] else { return nil }
return names["zh-CN"]?.string ?? names["en"]?.string
return L10n.isEnglish ? names["en"]?.string : names["zh-CN"]?.string ?? names["en"]?.string
}
}
@@ -1,5 +1,6 @@
import Darwin
import Foundation
import Localization
import SMC
public struct CPUCluster: Sendable, Equatable, Identifiable {
@@ -27,9 +28,9 @@ public enum MemoryPressure: Int, Sendable {
public var title: String {
switch self {
case .normal: "正常"
case .warning: "偏高"
case .critical: "严重"
case .normal: tr("正常")
case .warning: tr("偏高")
case .critical: tr("严重")
}
}
}
@@ -1,5 +1,6 @@
import Darwin
import Foundation
import Localization
import SystemConfiguration
public struct NetworkSampler {
@@ -62,7 +63,7 @@ public struct NetworkSampler {
// utun / ipsec / ppp 是 VPN 或代理隧道,系统不提供显示名
let tunnelPrefixes = ["utun", "ipsec", "ppp"]
let displayName = systemName
?? (tunnelPrefixes.contains { bsdName.hasPrefix($0) } ? "VPN 隧道" : bsdName)
?? (tunnelPrefixes.contains { bsdName.hasPrefix($0) } ? tr("VPN 隧道") : bsdName)
let state = SCDynamicStoreCopyValue(store, "State:/Network/Interface/\(bsdName)/IPv4" as CFString) as? [String: Any]
let address = (state?["Addresses"] as? [String])?.first
@@ -1,4 +1,5 @@
import AppKit
import Localization
import Metrics
import SwiftUI
@@ -18,7 +19,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
public func applicationDidFinishLaunching(_ notification: Notification) {
_ = DiagnosticsExporter.launchDate
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "开发版"
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? tr("开发版")
Log.app.notice("OpenStats \(version, privacy: .public) 启动,macOS \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public)")
NSApp.mainMenu = MainMenu.make(target: self, settingsAction: #selector(openSettingsFromMenu),
updateAction: #selector(checkForUpdatesFromMenu))
@@ -270,8 +271,20 @@ public enum OpenStatsApplication {
@MainActor
public static func main() {
let app = NSApplication.shared
// 界面语言在创建任何界面之前确定,切换后重启生效
let stored = UserDefaults.standard.string(forKey: AppSettings.languageKey).flatMap(AppLanguage.init(rawValue:)) ?? .system
L10n.configure(stored)
if let index = CommandLine.arguments.firstIndex(of: "--snapshot") {
let path = CommandLine.arguments.dropFirst(index + 1).first ?? "snapshots"
// --language en:渲染英文截图,并把没有翻译的中文记到 untranslated.txt
if let languageIndex = CommandLine.arguments.firstIndex(of: "--language"),
CommandLine.arguments.dropFirst(languageIndex + 1).first == "en" {
L10n.configure(.english)
let log = URL(fileURLWithPath: path).appendingPathComponent("untranslated.txt")
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: path), withIntermediateDirectories: true)
try? FileManager.default.removeItem(at: log)
L10n.missLog = log
}
app.setActivationPolicy(.prohibited)
Task {
await SnapshotRenderer.run(outputDirectory: URL(fileURLWithPath: path))
@@ -280,6 +293,7 @@ public enum OpenStatsApplication {
app.run()
return
}
stored.applyToProcessLocale()
let controller = AppController()
app.delegate = controller
@@ -1,5 +1,6 @@
import AppKit
import Carbon.HIToolbox
import Localization
/// 可以绑定全局快捷键的操作
public enum HotKeyAction: String, CaseIterable, Identifiable, Codable, Sendable {
@@ -9,10 +10,10 @@ public enum HotKeyAction: String, CaseIterable, Identifiable, Codable, Sendable
var title: String {
switch self {
case .toggleMainWindow: "显示 / 隐藏主窗口"
case .showProcesses: "打开进程页"
case .toggleKeepAwake: "开关防休眠"
case .purgeMemory: "释放内存"
case .toggleMainWindow: tr("显示 / 隐藏主窗口")
case .showProcesses: tr("打开进程页")
case .toggleKeepAwake: tr("开关防休眠")
case .purgeMemory: tr("释放内存")
}
}
@@ -64,7 +65,7 @@ public struct HotKey: Codable, Equatable, Sendable {
static func keyName(keyCode: UInt16, fallback: String) -> String {
switch Int(keyCode) {
case kVK_Space: "空格"
case kVK_Space: tr("空格")
case kVK_Return: "↩"
case kVK_Tab: "⇥"
case kVK_Delete: "⌫"
@@ -1,4 +1,5 @@
import AppKit
import Localization
/// 应用菜单。仅菜单栏运行时不显示,但仍负责 ⌘C / ⌘V / ⌘W 等快捷键
@MainActor
@@ -7,29 +8,29 @@ enum MainMenu {
let main = NSMenu()
let app = NSMenu(title: "OpenStats")
app.addItem(withTitle: "关于 OpenStats", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
app.addItem(withTitle: "检查更新…", action: updateAction, keyEquivalent: "").target = target
app.addItem(withTitle: tr("关于 OpenStats"), action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
app.addItem(withTitle: tr("检查更新…"), action: updateAction, keyEquivalent: "").target = target
app.addItem(.separator())
app.addItem(withTitle: "设置…", action: settingsAction, keyEquivalent: ",").target = target
app.addItem(withTitle: tr("设置…"), action: settingsAction, keyEquivalent: ",").target = target
app.addItem(.separator())
app.addItem(withTitle: "隐藏 OpenStats", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
app.addItem(withTitle: tr("隐藏 OpenStats"), action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
app.addItem(.separator())
app.addItem(withTitle: "退出 OpenStats", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
app.addItem(withTitle: tr("退出 OpenStats"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
add(app, to: main)
let edit = NSMenu(title: "编辑")
edit.addItem(withTitle: "撤销", action: Selector(("undo:")), keyEquivalent: "z")
edit.addItem(withTitle: "重做", action: Selector(("redo:")), keyEquivalent: "Z")
let edit = NSMenu(title: tr("编辑"))
edit.addItem(withTitle: tr("撤销"), action: Selector(("undo:")), keyEquivalent: "z")
edit.addItem(withTitle: tr("重做"), action: Selector(("redo:")), keyEquivalent: "Z")
edit.addItem(.separator())
edit.addItem(withTitle: "剪切", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
edit.addItem(withTitle: "拷贝", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
edit.addItem(withTitle: "粘贴", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
edit.addItem(withTitle: "全选", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
edit.addItem(withTitle: tr("剪切"), action: #selector(NSText.cut(_:)), keyEquivalent: "x")
edit.addItem(withTitle: tr("拷贝"), action: #selector(NSText.copy(_:)), keyEquivalent: "c")
edit.addItem(withTitle: tr("粘贴"), action: #selector(NSText.paste(_:)), keyEquivalent: "v")
edit.addItem(withTitle: tr("全选"), action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
add(edit, to: main)
let window = NSMenu(title: "窗口")
window.addItem(withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
window.addItem(withTitle: "关闭", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
let window = NSMenu(title: tr("窗口"))
window.addItem(withTitle: tr("最小化"), action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
window.addItem(withTitle: tr("关闭"), action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
add(window, to: main)
NSApp.windowsMenu = window
@@ -1,4 +1,5 @@
import AppKit
import Localization
import SwiftUI
/// 菜单栏图标:每项独立时一个指标一个图标,点击弹出该项详情;合并时只有一个图标,点击打开主窗口
@@ -143,10 +144,10 @@ final class MenuBarController: NSObject {
private func showContextMenu(for statusItem: NSStatusItem) {
dismissPopovers()
let menu = NSMenu()
menu.addItem(withTitle: "打开 OpenStats", action: #selector(openMainWindow), keyEquivalent: "").target = self
menu.addItem(withTitle: "设置…", action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(withTitle: tr("打开 OpenStats"), action: #selector(openMainWindow), keyEquivalent: "").target = self
menu.addItem(withTitle: tr("设置…"), action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(.separator())
menu.addItem(withTitle: "退出 OpenStats", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
menu.addItem(withTitle: tr("退出 OpenStats"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
statusItem.menu = menu
statusItem.button?.performClick(nil)
statusItem.menu = nil
@@ -1,4 +1,5 @@
import AppKit
import Localization
import Metrics
/// 菜单栏上要画的读数,与数据来源解耦,设置页预览可以用示例数据绘制
@@ -64,11 +65,11 @@ struct MenuBarReading {
switch item {
case .cpu: cpu.map { "CPU \(Format.percent($0))" }
case .gpu: gpu.map { "GPU \(Format.percent($0))" }
case .memory: memory.map { "内存 \(Format.percent($0))" }
case .memory: memory.map { tr("内存 \(Format.percent($0))") }
case .network:
upload.flatMap { up in download.map { "上传 \(Format.menuBarRate(up)) · 下载 \(Format.menuBarRate($0))" } }
case .temperature: temperature.map { "CPU 温度 \(Format.temperature($0, fahrenheit: fahrenheit))" }
case .fan: fanRPM.map { "风扇 \(Format.rpm($0))" }
upload.flatMap { up in download.map { tr("上传 \(Format.menuBarRate(up)) · 下载 \(Format.menuBarRate($0))") } }
case .temperature: temperature.map { tr("CPU 温度 \(Format.temperature($0, fahrenheit: fahrenheit))") }
case .fan: fanRPM.map { tr("风扇 \(Format.rpm($0))") }
}
}
.joined(separator: "\n")
@@ -1,4 +1,5 @@
import AppKit
import Localization
import SwiftUI
/// 升级提示窗口:显示新版本摘要与“一键安装”
@@ -23,7 +24,7 @@ final class UpdateWindowController: NSObject, NSWindowDelegate {
private func makeWindow() -> NSWindow {
let window = NSWindow(contentRect: .zero, styleMask: [.titled, .closable, .fullSizeContentView],
backing: .buffered, defer: false)
window.title = "软件更新"
window.title = tr("软件更新")
WindowChrome.apply(to: window)
window.isReleasedWhenClosed = false
window.delegate = self
@@ -1,3 +1,4 @@
import Localization
import Metrics
import SwiftUI
@@ -205,7 +206,7 @@ struct CoreClusterBars: View {
.frame(height: DS.TextSize.xs.rawValue * 1.5)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("各核心负载")
.accessibilityLabel(tr("各核心负载"))
}
private func label(_ cluster: CPUCluster) -> String {
@@ -261,7 +262,7 @@ struct CoreHeatmap: View {
.frame(height: height)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("核心热力图")
.accessibilityLabel(tr("核心热力图"))
}
static func color(_ value: Double?) -> Color {
@@ -275,13 +276,13 @@ struct CoreHeatmap: View {
struct HeatLegend: View {
var body: some View {
HStack(spacing: DS.Space.s1) {
Text("闲").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("闲")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
ForEach([0.05, 0.3, 0.55, 0.8, 0.9], id: \.self) { value in
RoundedRectangle(cornerRadius: DS.Radius.sm / 2)
.fill(CoreHeatmap.color(value))
.frame(width: DS.Space.s2, height: DS.Space.s2)
}
Text("忙").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("忙")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
@@ -311,7 +312,7 @@ struct PressureStrip: View {
}
.frame(height: DS.Space.s2)
.accessibilityElement()
.accessibilityLabel("最近的内存压力走势")
.accessibilityLabel(tr("最近的内存压力走势"))
}
}
@@ -419,6 +420,6 @@ struct ProbeGrid: View {
.aspectRatio(CGFloat(columns) / CGFloat(rows), contentMode: .fit)
.fixedSize(horizontal: false, vertical: true)
.accessibilityElement()
.accessibilityLabel("连接探测历史")
.accessibilityLabel(tr("连接探测历史"))
}
}
@@ -1,3 +1,4 @@
import Localization
import Metrics
import SwiftUI
@@ -231,7 +232,7 @@ struct SidebarButton: View {
Spacer()
if let badge {
Circle().fill(badge.color).frame(width: DS.Space.s2, height: DS.Space.s2)
.accessibilityLabel("需要注意")
.accessibilityLabel(tr("需要注意"))
}
}
.foregroundStyle(isSelected ? DS.Palette.primary : hovering ? DS.Palette.textPrimary : DS.Palette.textSecondary)
@@ -375,7 +376,7 @@ struct DSToggle: View {
.buttonStyle(.plain)
.animation(DS.Motion.quick, value: isOn)
.accessibilityLabel(label)
.accessibilityValue(isOn ? "开" : "关")
.accessibilityValue(isOn ? tr("开") : tr("关"))
}
}
@@ -1,5 +1,6 @@
import Cleaner
import HelperShared
import Localization
import Metrics
import SwiftUI
@@ -13,8 +14,8 @@ struct CleanerPage: View {
MaintenanceCard()
}
if model.cleaner.needsFullDiskAccess {
InfoBanner(icon: "lock.shield", text: "部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。", tone: .warning) {
Button("去授权") { model.cleaner.openFullDiskAccessSettings() }
InfoBanner(icon: "lock.shield", text: tr("部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。"), tone: .warning) {
Button(tr("去授权")) { model.cleaner.openFullDiskAccessSettings() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
@@ -49,11 +50,11 @@ private struct SummaryCard: View {
Card(padding: DS.Space.s3, spacing: DS.Space.s3) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "sparkles").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("磁盘清理").dsFont(.xs, weight: .semibold)
Text(tr("磁盘清理")).dsFont(.xs, weight: .semibold)
Spacer()
if let last = cleaner.lastScan {
TimelineView(.periodic(from: .now, by: 60)) { context in
Chip(text: "扫描于 " + Self.relative(last, now: context.date))
Chip(text: tr("扫描于 ") + Self.relative(last, now: context.date))
}
}
}
@@ -73,7 +74,7 @@ private struct SummaryCard: View {
if let disk = model.store.disk {
VStack(alignment: .leading, spacing: DS.Space.s1) {
ProgressTrack(fraction: disk.usedFraction)
Text(verbatim: "\(disk.volumeName) 可用 \(Format.bytes(disk.available, base: .decimal)) · 共 \(Format.bytes(disk.total, base: .decimal))")
Text(verbatim: tr("\(disk.volumeName) 可用 \(Format.bytes(disk.available, base: .decimal)) · 共 \(Format.bytes(disk.total, base: .decimal))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
@@ -82,22 +83,22 @@ private struct SummaryCard: View {
if cleaner.isConfirming {
InfoBanner(icon: "exclamationmark.triangle.fill", text: confirmationText, tone: .warning) {
HStack(spacing: DS.Space.s2) {
Button("取消") { cleaner.cancelClean() }
Button(tr("取消")) { cleaner.cancelClean() }
.buttonStyle(DSButtonStyle(kind: .secondary))
Button("确认清理") { cleaner.confirmClean() }
Button(tr("确认清理")) { cleaner.confirmClean() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
}
} else {
HStack(spacing: DS.Space.s2) {
Button { cleaner.scan() } label: {
Label(cleaner.phase == .scanning ? "扫描中…" : "重新扫描", systemImage: "arrow.clockwise")
Label(cleaner.phase == .scanning ? tr("扫描中…") : tr("重新扫描"), systemImage: "arrow.clockwise")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(cleaner.isBusy)
Button { cleaner.requestClean() } label: {
Label(cleaner.phase == .cleaning ? "清理中…" : "清理所选 \(Format.bytes(cleaner.selectedBytes, base: .decimal))",
Label(cleaner.phase == .cleaning ? tr("清理中…") : tr("清理所选 \(Format.bytes(cleaner.selectedBytes, base: .decimal))"),
systemImage: "trash")
}
.buttonStyle(DSButtonStyle(kind: .primary))
@@ -111,21 +112,21 @@ private struct SummaryCard: View {
private var headline: String {
let cleaner = model.cleaner
if cleaner.phase == .finished { return Format.bytes(cleaner.report?.freedBytes ?? 0, base: .decimal) }
return cleaner.scans.isEmpty ? "扫描中" : Format.bytes(cleaner.totalBytes, base: .decimal)
return cleaner.scans.isEmpty ? tr("扫描中") : Format.bytes(cleaner.totalBytes, base: .decimal)
}
private var subline: String {
let cleaner = model.cleaner
switch cleaner.phase {
case .idle, .scanning: return "正在计算可清理空间"
case .cleaning: return "正在清理…"
case .ready: return "可清理 · 已选 \(cleaner.selectedScans.count) 项"
case .idle, .scanning: return tr("正在计算可清理空间")
case .cleaning: return tr("正在清理…")
case .ready: return tr("可清理 · 已选 \(cleaner.selectedScans.count) 项")
case .finished:
guard let report = cleaner.report else { return "" }
var parts = ["已释放"]
if report.trashedBytes > 0 { parts.append("另有 \(Format.bytes(report.trashedBytes, base: .decimal)) 移到废纸篓") }
if report.skippedCount > 0 { parts.append("跳过 \(report.skippedCount) 项") }
if !report.failures.isEmpty { parts.append("\(report.failures.count) 项失败") }
var parts = [tr("已释放")]
if report.trashedBytes > 0 { parts.append(tr("另有 \(Format.bytes(report.trashedBytes, base: .decimal)) 移到废纸篓")) }
if report.skippedCount > 0 { parts.append(tr("跳过 \(report.skippedCount) 项")) }
if !report.failures.isEmpty { parts.append(tr("\(report.failures.count) 项失败")) }
return parts.joined(separator: " · ")
}
}
@@ -133,13 +134,13 @@ private struct SummaryCard: View {
private var confirmationText: String {
let cleaner = model.cleaner
let count = cleaner.selectedScans.reduce(0) { $0 + $1.items.count }
let trashNote = model.settings.cleanPrefersTrash ? "所有内容先移到废纸篓。" : "缓存与日志直接删除,下载内容移到废纸篓。"
return "将清理 \(count) 个项目,共 \(Format.bytes(cleaner.selectedBytes, base: .decimal))。\(trashNote)"
let trashNote = model.settings.cleanPrefersTrash ? tr("所有内容先移到废纸篓。") : tr("缓存与日志直接删除,下载内容移到废纸篓。")
return tr("将清理 \(count) 个项目,共 \(Format.bytes(cleaner.selectedBytes, base: .decimal))。\(trashNote)")
}
private static func relative(_ date: Date, now: Date) -> String {
let minutes = Int(now.timeIntervalSince(date) / 60)
return minutes < 1 ? "刚刚" : "\(Format.duration(minutes: minutes))前"
return minutes < 1 ? tr("刚刚") : tr("\(Format.duration(minutes: minutes))前")
}
}
@@ -152,14 +153,14 @@ private struct MaintenanceCard: View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "wrench.and.screwdriver").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("系统维护").dsFont(.xs, weight: .semibold)
Text(tr("系统维护")).dsFont(.xs, weight: .semibold)
}
.foregroundStyle(DS.Palette.textSecondary)
MaintenanceButton(command: .flushDNS, icon: "network", title: "刷新 DNS 缓存")
MaintenanceButton(command: .purgeMemory, icon: "memorychip", title: "释放内存")
MaintenanceButton(command: .flushDNS, icon: "network", title: tr("刷新 DNS 缓存"))
MaintenanceButton(command: .purgeMemory, icon: "memorychip", title: tr("释放内存"))
Text(model.helper.isReady ? "由辅助工具执行" : "需要管理员授权")
Text(model.helper.isReady ? tr("由辅助工具执行") : tr("需要管理员授权"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
@@ -189,7 +190,7 @@ private struct MaintenanceButton: View {
Text(title).dsFont(.sm, weight: .medium)
Spacer(minLength: 0)
if isRunning {
Text("执行中").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("执行中")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
.foregroundStyle(DS.Palette.textPrimary)
@@ -292,7 +293,7 @@ private struct RuleRow: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("查看包含的项目")
.help(tr("查看包含的项目"))
}
}
@@ -309,10 +310,10 @@ private struct RuleRow: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("在访达中显示")
.help(tr("在访达中显示"))
}
if scan.items.count > Self.previewLimit {
Text(verbatim: "另有 \(scan.items.count - Self.previewLimit) 项")
Text(verbatim: tr("另有 \(scan.items.count - Self.previewLimit) 项"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
@@ -326,10 +327,10 @@ private struct RuleRow: View {
private var statusText: String {
if let blocked = scan.blocked { return blocked.title }
if scan.items.isEmpty { return "无需清理" }
var text = "\(scan.items.count) 项"
if scan.skippedCount > 0 { text += " · \(scan.skippedCount) 项使用中" }
if scan.rule.policy == .trash { text += " · 移到废纸篓" }
if scan.items.isEmpty { return tr("无需清理") }
var text = tr("\(scan.items.count) 项")
if scan.skippedCount > 0 { text += tr(" · \(scan.skippedCount) 项使用中") }
if scan.rule.policy == .trash { text += tr(" · 移到废纸篓") }
return text
}
}
@@ -343,13 +344,13 @@ private struct CleanerFooter: View {
@Bindable var settings = model.settings
HStack(spacing: DS.Space.s3) {
DSToggle(isOn: $settings.cleanPrefersTrash, label: "缓存也先移到废纸篓")
DSToggle(isOn: $settings.cleanPrefersTrash, label: tr("缓存也先移到废纸篓"))
VStack(alignment: .leading, spacing: 0) {
Text("缓存也先移到废纸篓").dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
Text("可以恢复,但清空废纸篓前不会释放空间").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("缓存也先移到废纸篓")).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
Text(tr("可以恢复,但清空废纸篓前不会释放空间")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
Spacer()
Button("查看清理日志") { model.cleaner.revealLog() }
Button(tr("查看清理日志")) { model.cleaner.revealLog() }
.buttonStyle(DSButtonStyle(kind: .ghost))
}
.padding(.horizontal, DS.Space.s1)
@@ -1,3 +1,4 @@
import Localization
import Metrics
import SwiftUI
@@ -24,8 +25,8 @@ private struct CapacityCard: View {
var body: some View {
let disk = model.store.disk
Card {
CardHeader(icon: "internaldrive", title: disk?.volumeName ?? "启动磁盘",
detail: disk.map { "共 \(Format.bytes($0.total, base: .decimal))" } ?? "读取中")
CardHeader(icon: "internaldrive", title: disk?.volumeName ?? tr("启动磁盘"),
detail: disk.map { tr("共 \(Format.bytes($0.total, base: .decimal))") } ?? tr("读取中"))
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) {
Text(verbatim: disk.map { "\(Int(($0.usedFraction * 100).rounded()))" } ?? "—")
.dsFont(.xxl, weight: .semibold)
@@ -37,9 +38,9 @@ private struct CapacityCard: View {
ProgressTrack(fraction: disk?.usedFraction ?? 0,
color: (disk?.usedFraction ?? 0) > 0.9 ? DS.Palette.warning : DS.Palette.primary)
if let disk {
InfoRow(label: "已用", text: Format.bytes(disk.used, base: .decimal))
InfoRow(label: "可用", text: Format.bytes(disk.available, base: .decimal))
Text("可用空间包含系统可以随时清除的缓存,与访达显示一致")
InfoRow(label: tr("已用"), text: Format.bytes(disk.used, base: .decimal))
InfoRow(label: tr("可用"), text: Format.bytes(disk.available, base: .decimal))
Text(tr("可用空间包含系统可以随时清除的缓存,与访达显示一致"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -56,16 +57,16 @@ private struct ActivityCard: View {
let reads = store.diskReadHistory.elements
let writes = store.diskWriteHistory.elements
Card {
CardHeader(icon: "arrow.up.arrow.down", title: "读写速度", detail: "所有磁盘合计")
CardHeader(icon: "arrow.up.arrow.down", title: tr("读写速度"), detail: tr("所有磁盘合计"))
HStack(spacing: DS.Space.s4) {
rate("读取", store.diskActivity?.readRate, color: DS.NetworkPalette.download)
rate("写入", store.diskActivity?.writeRate, color: DS.NetworkPalette.upload)
rate(tr("读取"), store.diskActivity?.readRate, color: DS.NetworkPalette.download)
rate(tr("写入"), store.diskActivity?.writeRate, color: DS.NetworkPalette.upload)
Spacer()
}
// 上半写入、下半读取,与网络流量图同一套配色
MirroredRateChart(upload: writes, download: reads, height: DS.Size.chartHeight * 2)
InfoRow(label: "60 秒峰值") {
Text(verbatim: "读 \(Format.menuBarRate(reads.max() ?? 0)) · 写 \(Format.menuBarRate(writes.max() ?? 0))")
InfoRow(label: tr("60 秒峰值")) {
Text(verbatim: tr("读 \(Format.menuBarRate(reads.max() ?? 0)) · 写 \(Format.menuBarRate(writes.max() ?? 0))"))
}
}
}
@@ -90,7 +91,7 @@ private struct HealthCard: View {
var body: some View {
let health = model.store.diskHealth
Card {
CardHeader(icon: "heart.text.square", title: "SSD 健康", detail: health?.model ?? "读取中")
CardHeader(icon: "heart.text.square", title: tr("SSD 健康"), detail: health?.model ?? tr("读取中"))
if let health {
let tone = tone(health)
HStack(alignment: .center, spacing: DS.Space.s6) {
@@ -100,12 +101,12 @@ private struct HealthCard: View {
.dsFont(.base, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
.monospacedDigit()
Text("剩余寿命").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("剩余寿命")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
VStack(alignment: .leading, spacing: DS.Space.s1) {
StatusBadge(text: summary(health), tone: tone)
Text("寿命按厂商估算的已用比例计算;写入量越大消耗越快,日常使用通常可用很多年")
Text(tr("寿命按厂商估算的已用比例计算;写入量越大消耗越快,日常使用通常可用很多年"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -114,22 +115,22 @@ private struct HealthCard: View {
}
WeightedRow(weights: [1, 1], spacing: DS.Space.s6) {
VStack(spacing: DS.Space.s2) {
InfoRow(label: "累计写入", text: Format.bytes(health.bytesWritten, base: .decimal))
InfoRow(label: "累计读取", text: Format.bytes(health.bytesRead, base: .decimal))
InfoRow(label: "备用空间", text: "\(health.availableSpare)%(阈值 \(health.availableSpareThreshold)%)")
InfoRow(label: tr("累计写入"), text: Format.bytes(health.bytesWritten, base: .decimal))
InfoRow(label: tr("累计读取"), text: Format.bytes(health.bytesRead, base: .decimal))
InfoRow(label: tr("备用空间"), text: tr("\(health.availableSpare)%(阈值 \(health.availableSpareThreshold)%)"))
if let temperature = health.temperature {
InfoRow(label: "温度", text: Format.temperature(temperature, fahrenheit: model.settings.useFahrenheit))
InfoRow(label: tr("温度"), text: Format.temperature(temperature, fahrenheit: model.settings.useFahrenheit))
}
}
VStack(spacing: DS.Space.s2) {
InfoRow(label: "通电时间", text: "\(health.powerOnHours.formatted()) 小时")
InfoRow(label: "通电次数", text: health.powerCycles.formatted())
InfoRow(label: "异常断电", text: health.unsafeShutdowns.formatted())
InfoRow(label: "介质错误", text: health.mediaErrors.formatted())
InfoRow(label: tr("通电时间"), text: tr("\(health.powerOnHours.formatted()) 小时"))
InfoRow(label: tr("通电次数"), text: health.powerCycles.formatted())
InfoRow(label: tr("异常断电"), text: health.unsafeShutdowns.formatted())
InfoRow(label: tr("介质错误"), text: health.mediaErrors.formatted())
}
}
} else {
Text("正在读取 SMART 信息;外置磁盘或不支持 NVMe SMART 的磁盘不显示")
Text(tr("正在读取 SMART 信息;外置磁盘或不支持 NVMe SMART 的磁盘不显示"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
@@ -142,10 +143,10 @@ private struct HealthCard: View {
}
private func summary(_ health: DiskHealth) -> String {
if health.criticalWarning != 0 { return "磁盘报告了严重警告,建议尽快备份" }
if health.mediaErrors > 0 { return "出现介质错误,建议备份" }
if health.availableSpare < health.availableSpareThreshold { return "备用空间低于阈值,建议备份" }
return health.remainingLife < 20 ? "寿命偏低,注意备份" : "状态良好"
if health.criticalWarning != 0 { return tr("磁盘报告了严重警告,建议尽快备份") }
if health.mediaErrors > 0 { return tr("出现介质错误,建议备份") }
if health.availableSpare < health.availableSpareThreshold { return tr("备用空间低于阈值,建议备份") }
return health.remainingLife < 20 ? tr("寿命偏低,注意备份") : tr("状态良好")
}
}
@@ -161,9 +162,9 @@ private struct DiskProcessesCard: View {
let peak = rows.first?.1 ?? 1
Card {
CardHeader(icon: "list.bullet.rectangle", title: "读写最多的应用", detail: "只含当前用户的进程")
CardHeader(icon: "list.bullet.rectangle", title: tr("读写最多的应用"), detail: tr("只含当前用户的进程"))
if rows.isEmpty {
Text("最近没有应用在读写磁盘").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("最近没有应用在读写磁盘")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(Array(rows), id: \.0.id) { row, total in
VStack(spacing: DS.Space.s1) {
@@ -176,7 +177,7 @@ private struct DiskProcessesCard: View {
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
Text(verbatim: "读 \(Format.menuBarRate(row.disk?.read ?? 0)) · 写 \(Format.menuBarRate(row.disk?.write ?? 0))")
Text(verbatim: tr("读 \(Format.menuBarRate(row.disk?.read ?? 0)) · 写 \(Format.menuBarRate(row.disk?.write ?? 0))"))
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.monospacedDigit()
@@ -1,3 +1,4 @@
import Localization
import Metrics
import SwiftUI
@@ -20,61 +21,61 @@ struct HistoryPage: View {
options: HistoryRecorder.Range.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.sidebarWidth + DS.Space.s16)
Spacer()
Text(verbatim: hoverDate.map { "\($0.formatted(date: .abbreviated, time: .shortened))" }
?? (points.isEmpty ? "" : "每点 \(bucketTitle(history.range))"))
Text(verbatim: hoverDate.map { "\($0.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))" }
?? (points.isEmpty ? "" : tr("每点 \(bucketTitle(history.range))")))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
}
if !settings.historyEnabled {
InfoBanner(icon: "pause.circle", text: "历史记录已关闭,下面是关闭前记录的数据。", tone: .warning) {
Button("开启") { settings.historyEnabled = true }.buttonStyle(DSButtonStyle(kind: .primary))
InfoBanner(icon: "pause.circle", text: tr("历史记录已关闭,下面是关闭前记录的数据。"), tone: .warning) {
Button(tr("开启")) { settings.historyEnabled = true }.buttonStyle(DSButtonStyle(kind: .primary))
}
}
if points.isEmpty {
Card {
Text(history.isQuerying ? "正在读取…" : "这段时间还没有记录。OpenStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。")
Text(history.isQuerying ? tr("正在读取…") : tr("这段时间还没有记录。OpenStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。"))
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
}
} else {
HistoryChartCard(icon: "cpu", title: "CPU", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "平均", color: DS.Palette.primary, value: \.cpu),
.init(label: "峰值", color: DS.Palette.primary.opacity(0.35), value: \.cpuMax, filled: false)],
series: [.init(label: tr("平均"), color: DS.Palette.primary, value: \.cpu),
.init(label: tr("峰值"), color: DS.Palette.primary.opacity(0.35), value: \.cpuMax, filled: false)],
maxValue: 1, format: { Format.percent($0) })
HistoryChartCard(icon: "memorychip", title: "内存", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "已用", color: DS.Palette.primary, value: \.memory)],
HistoryChartCard(icon: "memorychip", title: tr("内存"), points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: tr("已用"), color: DS.Palette.primary, value: \.memory)],
maxValue: 1, format: { Format.percent($0) },
note: criticalNote(points))
HistoryChartCard(icon: "network", title: "网络", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "下载", color: Color(nsColor: DS.NetworkPalette.download), value: \.download),
.init(label: "上传", color: Color(nsColor: DS.NetworkPalette.upload), value: \.upload, filled: false)],
HistoryChartCard(icon: "network", title: tr("网络"), points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: tr("下载"), color: Color(nsColor: DS.NetworkPalette.download), value: \.download),
.init(label: tr("上传"), color: Color(nsColor: DS.NetworkPalette.upload), value: \.upload, filled: false)],
maxValue: nil, format: { Format.menuBarRate($0) })
HistoryChartCard(icon: "square.3.layers.3d", title: "GPU", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "占用", color: DS.Palette.primary, value: \.gpu)],
series: [.init(label: tr("占用"), color: DS.Palette.primary, value: \.gpu)],
maxValue: 1, format: { Format.percent($0) },
emptyNote: "只在菜单栏显示 GPU 或打开 GPU 相关页面时记录")
HistoryChartCard(icon: "thermometer.medium", title: "CPU 温度", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "最高", color: DS.Palette.warning, value: \.temperature)],
emptyNote: tr("只在菜单栏显示 GPU 或打开 GPU 相关页面时记录"))
HistoryChartCard(icon: "thermometer.medium", title: tr("CPU 温度"), points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: tr("最高"), color: DS.Palette.warning, value: \.temperature)],
maxValue: nil, format: { Format.temperature($0, fahrenheit: settings.useFahrenheit) },
emptyNote: "只在菜单栏显示温度、开启过热通知或打开温度页面时记录")
HistoryChartCard(icon: "bolt", title: "整机功耗", points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: "平均", color: DS.Palette.warning, value: \.power)],
emptyNote: tr("只在菜单栏显示温度、开启过热通知或打开温度页面时记录"))
HistoryChartCard(icon: "bolt", title: tr("整机功耗"), points: points, start: start, end: end, hoverDate: $hoverDate,
series: [.init(label: tr("平均"), color: DS.Palette.warning, value: \.power)],
maxValue: nil, format: { Format.watts($0) },
emptyNote: "只在打开温度与风扇页面时记录")
emptyNote: tr("只在打开温度与风扇页面时记录"))
}
SettingsGroup(caption: "历史记录") {
SettingsGroup(caption: tr("历史记录")) {
GroupRow(showsDivider: false) {
SettingRow(title: "记录历史数据", subtitle: "每分钟把主要指标的平均值与峰值写入本机数据库,保留 7 天,不上传") {
DSToggle(isOn: $settings.historyEnabled, label: "记录历史数据")
SettingRow(title: tr("记录历史数据"), subtitle: tr("每分钟把主要指标的平均值与峰值写入本机数据库,保留 7 天,不上传")) {
DSToggle(isOn: $settings.historyEnabled, label: tr("记录历史数据"))
}
}
GroupRow {
SettingRow(title: "清除历史", subtitle: "共 \(history.recordCount.formatted()) 条记录") {
Button("清除…") { confirmingClear = true }
SettingRow(title: tr("清除历史"), subtitle: tr("共 \(history.recordCount.formatted()) 条记录")) {
Button(tr("清除…")) { confirmingClear = true }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(history.recordCount == 0)
}
@@ -89,21 +90,21 @@ struct HistoryPage: View {
if hoverDate == nil { history.load() }
}
}
.confirmationDialog("清除全部历史记录?", isPresented: $confirmingClear, titleVisibility: .visible) {
Button("清除", role: .destructive) { history.clear() }
Button("取消", role: .cancel) {}
.confirmationDialog(tr("清除全部历史记录?"), isPresented: $confirmingClear, titleVisibility: .visible) {
Button(tr("清除"), role: .destructive) { history.clear() }
Button(tr("取消"), role: .cancel) {}
} message: {
Text("已记录的数据会从本机删除,无法恢复。")
Text(tr("已记录的数据会从本机删除,无法恢复。"))
}
}
private func bucketTitle(_ range: HistoryRecorder.Range) -> String {
range.bucket >= 60 * 60 ? "\(range.bucket / 3600) 小时" : "\(range.bucket / 60) 分钟"
range.bucket >= 60 * 60 ? tr("\(range.bucket / 3600) 小时") : tr("\(range.bucket / 60) 分钟")
}
private func criticalNote(_ points: [HistoryPoint]) -> String? {
let critical = points.filter { ($0.pressure ?? 0) >= MemoryPressure.critical.rawValue }.count
return critical > 0 ? "内存压力严重的时段:\(critical) 个(图上红色标记)" : nil
return critical > 0 ? tr("内存压力严重的时段:\(critical) 个(图上红色标记)") : nil
}
}
@@ -142,7 +143,7 @@ private struct HistoryChartCard: View {
}
}
} else if !values.isEmpty {
Text(verbatim: "平均 \(format(values.reduce(0, +) / Double(values.count))) · 峰值 \(format(values.max() ?? 0))\(peakPoint.map { "(\(Self.moment($0.date)))" } ?? "")")
Text(verbatim: tr("平均 \(format(values.reduce(0, +) / Double(values.count))) · 峰值 \(format(values.max() ?? 0))\(peakPoint.map { tr("(\(Self.moment($0.date)))") } ?? "")"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
@@ -156,7 +157,7 @@ private struct HistoryChartCard: View {
Spacer()
Text(verbatim: Self.moment(start.addingTimeInterval(end.timeIntervalSince(start) / 2)))
Spacer()
Text("现在")
Text(tr("现在"))
}
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
@@ -165,7 +166,7 @@ private struct HistoryChartCard: View {
Text(note).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
} else {
Text(emptyNote ?? "这段时间没有记录").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(emptyNote ?? tr("这段时间没有记录")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
@@ -173,8 +174,8 @@ private struct HistoryChartCard: View {
/// 同一天只显示时间,跨天带上日期
static func moment(_ date: Date) -> String {
Calendar.current.isDateInToday(date)
? date.formatted(date: .omitted, time: .shortened)
: date.formatted(.dateTime.month(.abbreviated).day().hour().minute())
? date.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: L10n.locale))
: date.formatted(.dateTime.locale(L10n.locale).month(.abbreviated).day().hour().minute())
}
private var hoverPoint: HistoryPoint? {
@@ -1,3 +1,4 @@
import Localization
import Metrics
import SwiftUI
@@ -38,7 +39,7 @@ private struct HeroCard: View {
.foregroundStyle(keepAwake.isActive ? DS.Palette.onPrimary : DS.Palette.textSecondary)
}
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text("防休眠")
Text(tr("防休眠"))
.dsFont(.lg, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
StatusLine()
@@ -46,7 +47,7 @@ private struct HeroCard: View {
Spacer()
DSToggle(isOn: Binding(get: { keepAwake.isActive },
set: { value in Task { await keepAwake.setActive(value) } }),
label: "防休眠")
label: tr("防休眠"))
}
if let notice = keepAwake.notice {
@@ -73,14 +74,14 @@ private struct StatusLine: View {
private func text(now: Date) -> String {
let keepAwake = model.keepAwake
guard keepAwake.isActive else { return "未开启,Mac 按系统设置休眠" }
guard keepAwake.isActive else { return tr("未开启,Mac 按系统设置休眠") }
var parts = [keepAwake.mode.title]
if keepAwake.lidClosedActive { parts.append("合盖运行") }
if keepAwake.lidClosedActive { parts.append(tr("合盖运行")) }
if let end = keepAwake.endDate {
let minutes = max(1, Int(ceil(end.timeIntervalSince(now) / 60)))
parts.append("剩余 \(Format.duration(minutes: minutes))")
parts.append(tr("剩余 \(Format.duration(minutes: minutes))"))
} else {
parts.append("不限时")
parts.append(tr("不限时"))
}
return parts.joined(separator: " · ")
}
@@ -91,7 +92,7 @@ private struct ModeCard: View {
var body: some View {
Card {
Text("模式").dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(tr("模式")).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
ForEach(Array(KeepAwakeController.Mode.allCases.enumerated()), id: \.element) { index, mode in
if index > 0 { HairlineDivider() }
RadioRow(title: mode.title, subtitle: mode.subtitle, isSelected: model.keepAwake.mode == mode) {
@@ -109,20 +110,20 @@ private struct LidCard: View {
let keepAwake = model.keepAwake
Card {
SettingRow(title: "合盖后继续运行",
subtitle: "合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。") {
SettingRow(title: tr("合盖后继续运行"),
subtitle: tr("合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。")) {
DSToggle(isOn: Binding(get: { keepAwake.lidClosedRequested },
set: { value in Task { await keepAwake.setLidClosed(value) } }),
label: "合盖后继续运行")
label: tr("合盖后继续运行"))
.disabled(!model.helper.isReady)
}
if model.helper.needsAttention {
HelperRequiredBanner(text: "合盖运行需要修改系统睡眠设置,需安装辅助工具(管理员授权一次)。")
HelperRequiredBanner(text: tr("合盖运行需要修改系统睡眠设置,需安装辅助工具(管理员授权一次)。"))
}
InfoBanner(icon: "exclamationmark.triangle",
text: "合盖运行时散热变差,请勿放入包中。使用电池且电量低于 \(model.settings.lidModeBatteryFloor)% 时会自动关闭。",
text: tr("合盖运行时散热变差,请勿放入包中。使用电池且电量低于 \(model.settings.lidModeBatteryFloor)% 时会自动关闭。"),
tone: .warning)
}
}
@@ -133,11 +134,11 @@ private struct DurationCard: View {
var body: some View {
Card {
Text("持续时间").dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(tr("持续时间")).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
SegmentedControl(selection: Binding(get: { model.keepAwake.duration },
set: { model.keepAwake.setDuration($0) }),
options: KeepAwakeController.Duration.allCases.map { ($0, $0.title) })
Text("到时后自动恢复系统默认的睡眠行为。")
Text(tr("到时后自动恢复系统默认的睡眠行为。"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
@@ -1,4 +1,5 @@
import AppKit
import Localization
import Metrics
import SwiftUI
@@ -91,7 +92,7 @@ private struct MainSidebar: View {
HStack(spacing: DS.Space.s1) {
ThemeToggle()
Spacer(minLength: 0)
IconButton(systemName: "power", help: "退出 OpenStats") { model.quit() }
IconButton(systemName: "power", help: tr("退出 OpenStats")) { model.quit() }
}
.padding(.leading, DS.Space.s2)
}
@@ -104,9 +105,9 @@ private struct MainSidebar: View {
private var navigation: some View {
VStack(alignment: .leading, spacing: DS.Space.s1) {
group("监控", PanelTab.monitors)
group("工具", PanelTab.tools)
group("设置", PanelTab.settings)
group(tr("监控"), PanelTab.monitors)
group(tr("工具"), PanelTab.tools)
group(tr("设置"), PanelTab.settings)
}
.sidebarGlider()
// 光条的发光向左溢出几个点,留出空间避免被滚动区域裁掉
@@ -156,13 +157,13 @@ private struct PageHeader: View {
.background(WindowDragArea())
if model.keepAwake.isActive {
Chip(text: "防休眠已开启", icon: "cup.and.saucer.fill", tone: .primary)
Chip(text: tr("防休眠已开启"), icon: "cup.and.saucer.fill", tone: .primary)
}
if tab == .memory { PurgeMemoryButton() }
if let item = tab.menuBarItem {
Text("在菜单栏显示").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Text(tr("在菜单栏显示")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }),
label: "在菜单栏显示\(item.title)")
label: tr("在菜单栏显示\(item.title)"))
}
}
.padding(.horizontal, DS.Space.s3 + DS.Space.s1)
@@ -177,7 +178,7 @@ private struct ThemeToggle: View {
var body: some View {
let isDark = colorScheme == .dark
IconButton(systemName: isDark ? "sun.max" : "moon", help: isDark ? "切换到浅色" : "切换到深色") {
IconButton(systemName: isDark ? "sun.max" : "moon", help: isDark ? tr("切换到浅色") : tr("切换到深色")) {
model.settings.appearance = isDark ? .light : .dark
}
}
@@ -1,4 +1,5 @@
import AppKit
import Localization
import Metrics
import SMC
import SwiftUI
@@ -79,23 +80,23 @@ struct HealthReport {
init(store: MetricsStore) {
var issues: [(penalty: Int, text: String)] = []
if let cpu = store.cpu {
if cpu.total >= 0.9 { issues.append((20, "CPU 负载很高")) } else if cpu.total >= 0.75 { issues.append((10, "CPU 负载偏高")) }
if cpu.total >= 0.9 { issues.append((20, tr("CPU 负载很高"))) } else if cpu.total >= 0.75 { issues.append((10, tr("CPU 负载偏高"))) }
}
switch store.memory?.pressure {
case .critical: issues.append((30, "内存压力严重"))
case .warning: issues.append((15, "内存压力偏高"))
case .critical: issues.append((30, tr("内存压力严重")))
case .warning: issues.append((15, tr("内存压力偏高")))
default: break
}
if let hottest = store.sensors?.temperature(.cpu)?.maximum {
if hottest >= DS.Thermal.hot { issues.append((20, "CPU 温度过高")) } else if hottest >= 85 { issues.append((10, "CPU 温度偏高")) }
if hottest >= DS.Thermal.hot { issues.append((20, tr("CPU 温度过高"))) } else if hottest >= 85 { issues.append((10, tr("CPU 温度偏高"))) }
}
if let disk = store.disk {
if disk.usedFraction >= 0.95 { issues.append((15, "磁盘空间不足")) } else if disk.usedFraction >= 0.9 { issues.append((8, "磁盘空间偏紧")) }
if disk.usedFraction >= 0.95 { issues.append((15, tr("磁盘空间不足"))) } else if disk.usedFraction >= 0.9 { issues.append((8, tr("磁盘空间偏紧"))) }
}
if let health = store.battery?.health, health < 0.8 { issues.append((5, "电池健康度下降")) }
if let health = store.battery?.health, health < 0.8 { issues.append((5, tr("电池健康度下降"))) }
score = max(0, 100 - issues.reduce(0) { $0 + $1.penalty })
summary = issues.max { $0.penalty < $1.penalty }?.text ?? "各项指标正常"
summary = issues.max { $0.penalty < $1.penalty }?.text ?? tr("各项指标正常")
tone = score >= 85 ? .success : score >= 60 ? .warning : .error
}
}
@@ -126,7 +127,7 @@ private struct HealthHeader: View {
Chip(text: Format.bytes(ProcessInfo.processInfo.physicalMemory).replacingOccurrences(of: ".0 ", with: " "))
Chip(text: store.system.osVersion)
if let boot = store.system.bootDate {
Chip(text: "已运行 " + Format.uptime(since: boot))
Chip(text: tr("已运行 ") + Format.uptime(since: boot))
}
Chip(text: shortModel(store.system.modelName))
}
@@ -145,7 +146,7 @@ private struct HealthHeader: View {
let base = name[..<open].trimmingCharacters(in: .whitespaces)
let details = name[open...].dropFirst().split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
if let inch = details.first(where: { $0.hasSuffix("-inch") }) {
return "\(base) \(inch.dropLast("-inch".count)) 英寸"
return tr("\(base) \(inch.dropLast("-inch".count)) 英寸")
}
return base
}
@@ -154,7 +155,7 @@ private struct HealthHeader: View {
// MARK: - 指标卡片
private func loadLevel(_ value: Double) -> String {
value < 0.3 ? "低负载" : value < 0.7 ? "中等负载" : "高负载"
value < 0.3 ? tr("低负载") : value < 0.7 ? tr("中等负载") : tr("高负载")
}
private func splitRate(_ text: String) -> (String, String) {
@@ -177,7 +178,7 @@ private struct CPUTile: View {
value: store.cpu == nil ? "—" : "\(Int((total * 100).rounded()))", unit: "%") {
BarHistoryChart(values: store.cpuTotal.elements, capacity: 20, height: DS.Size.tileChart)
} footer: {
Text(verbatim: "\(loadLevel(total)) · 负载 \(load)/\(store.topology.logicalCores)")
Text(verbatim: tr("\(loadLevel(total)) · 负载 \(load)/\(store.topology.logicalCores)"))
}
}
}
@@ -196,7 +197,7 @@ private struct GPUTile: View {
value: store.gpu == nil ? "—" : "\(Int((utilization * 100).rounded()))", unit: "%") {
LineHistoryChart(values: store.gpuHistory.elements, capacity: 30, color: DS.Palette.secondary, height: DS.Size.tileChart)
} footer: {
Text(verbatim: [loadLevel(utilization), store.gpu?.coreCount.map { "\($0) 核" }].compactMap { $0 }.joined(separator: " · "))
Text(verbatim: [loadLevel(utilization), store.gpu?.coreCount.map { tr("\($0) 核") }].compactMap { $0 }.joined(separator: " · "))
}
}
}
@@ -207,8 +208,8 @@ private struct MemoryTile: View {
var body: some View {
let memory = model.store.memory
MetricTile(icon: "memorychip", title: "内存",
chip: memory.map { "压力 \($0.pressure.title)" },
MetricTile(icon: "memorychip", title: tr("内存"),
chip: memory.map { tr("压力 \($0.pressure.title)") },
chipTone: memory.map { tone($0.pressure) } ?? .neutral,
value: memory.map { "\(Int(($0.usedFraction * 100).rounded()))" } ?? "—", unit: "%") {
LineHistoryChart(values: model.store.memoryHistory.elements, capacity: 30, height: DS.Size.tileChart)
@@ -232,7 +233,7 @@ private struct DiskTile: View {
var body: some View {
let disk = model.store.disk
MetricTile(icon: "internaldrive", title: "磁盘",
MetricTile(icon: "internaldrive", title: tr("磁盘"),
chip: disk.map { Format.bytes($0.total, base: .decimal).replacingOccurrences(of: ".0 ", with: " ") },
value: disk.map { "\(Int(($0.usedFraction * 100).rounded()))" } ?? "—", unit: "%") {
VStack {
@@ -243,7 +244,7 @@ private struct DiskTile: View {
Spacer()
}
} footer: {
Text(verbatim: disk.map { "可用 \(Format.bytes($0.available, base: .decimal))" } ?? "—")
Text(verbatim: disk.map { tr("可用 \(Format.bytes($0.available, base: .decimal))") } ?? "—")
}
}
}
@@ -256,7 +257,7 @@ private struct NetworkTile: View {
let rate = store.network
let total = splitRate(Format.menuBarRate((rate?.downloadBytesPerSecond ?? 0) + (rate?.uploadBytesPerSecond ?? 0)))
MetricTile(icon: "network", title: "网络",
MetricTile(icon: "network", title: tr("网络"),
chip: store.networkInterface.map { shortInterface($0) },
value: rate == nil ? "—" : total.0, unit: rate == nil ? nil : total.1) {
DualLineChart(upload: store.uploadHistory.elements, download: store.downloadHistory.elements,
@@ -273,7 +274,7 @@ private struct NetworkTile: View {
}
private func shortInterface(_ info: NetworkInterfaceInfo) -> String {
info.displayName == "VPN 隧道" ? "VPN" : info.displayName
info.displayName == tr("VPN 隧道") ? "VPN" : info.displayName
}
}
@@ -289,9 +290,9 @@ private struct FanTile: View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "fan").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("风扇").dsFont(.xs, weight: .semibold)
Text(tr("风扇")).dsFont(.xs, weight: .semibold)
Spacer(minLength: DS.Space.s1)
if !fans.isEmpty { Chip(text: "转速 \(Format.percent(average))") }
if !fans.isEmpty { Chip(text: tr("转速 \(Format.percent(average))")) }
}
.foregroundStyle(DS.Palette.textSecondary)
@@ -330,10 +331,10 @@ private struct CoreLoadCard: View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "square.grid.3x3.fill").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("核心负载").dsFont(.xs, weight: .semibold)
Text(tr("核心负载")).dsFont(.xs, weight: .semibold)
Spacer()
LegendItem(color: DS.Palette.primary, label: "用户", value: cpu.map { Format.percent($0.user) } ?? "—")
LegendItem(color: DS.Palette.secondary, label: "系统", value: cpu.map { Format.percent($0.system) } ?? "—")
LegendItem(color: DS.Palette.primary, label: tr("用户"), value: cpu.map { Format.percent($0.user) } ?? "—")
LegendItem(color: DS.Palette.secondary, label: tr("系统"), value: cpu.map { Format.percent($0.system) } ?? "—")
}
.foregroundStyle(DS.Palette.textSecondary)
CoreClusterBars(topology: model.store.topology, perCore: cpu?.perCore ?? [], barHeight: DS.Space.s12 + DS.Space.s4)
@@ -353,10 +354,10 @@ private struct BatteryCard: View {
HStack(spacing: DS.Space.s1) {
Image(systemName: battery.isCharging ? "battery.100.bolt" : "battery.75")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("电池").dsFont(.xs, weight: .semibold)
Text(tr("电池")).dsFont(.xs, weight: .semibold)
Spacer(minLength: DS.Space.s1)
if let health = battery.health {
Chip(text: "健康 \(Format.percent(health))", tone: health < 0.8 ? .warning : .neutral)
Chip(text: tr("健康 \(Format.percent(health))"), tone: health < 0.8 ? .warning : .neutral)
}
}
.foregroundStyle(DS.Palette.textSecondary)
@@ -393,17 +394,17 @@ private struct BatteryCard: View {
}
private func stateText(_ battery: BatteryStatus) -> String {
if battery.isCharging { return "充电中" }
if battery.isPluggedIn { return battery.isFullyCharged ? "已充满" : "电源供电" }
return "电池供电"
if battery.isCharging { return tr("充电中") }
if battery.isPluggedIn { return battery.isFullyCharged ? tr("已充满") : tr("电源供电") }
return tr("电池供电")
}
private func detailText(_ battery: BatteryStatus) -> String {
var parts: [String] = []
if let minutes = battery.minutesRemaining {
parts.append(battery.isCharging ? "\(Format.duration(minutes: minutes))后充满" : "剩余 \(Format.duration(minutes: minutes))")
parts.append(battery.isCharging ? tr("\(Format.duration(minutes: minutes))后充满") : tr("剩余 \(Format.duration(minutes: minutes))"))
}
if let cycles = battery.cycleCount { parts.append("循环 \(cycles) 次") }
if let cycles = battery.cycleCount { parts.append(tr("循环 \(cycles) 次")) }
return parts.joined(separator: " · ")
}
}
@@ -421,10 +422,10 @@ private struct TopProcessesCard: View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "chart.bar.fill").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("高占用进程").dsFont(.xs, weight: .semibold)
Text(tr("高占用进程")).dsFont(.xs, weight: .semibold)
Spacer()
Text("CPU").dsFont(.xs, weight: .medium).frame(width: DS.Size.valueColumn, alignment: .trailing)
Text("内存").dsFont(.xs, weight: .medium).frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(tr("内存")).dsFont(.xs, weight: .medium).frame(width: DS.Size.valueColumn, alignment: .trailing)
Color.clear.frame(width: DS.Size.iconInline)
}
.foregroundStyle(DS.Palette.textSecondary)
@@ -470,19 +471,19 @@ private struct TopProcessesCard: View {
} else {
Menu {
if let path = process.appBundlePath ?? process.executablePath {
Button("在访达中显示") {
Button(tr("在访达中显示")) {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
}
Button("拷贝 PID \(String(process.pid))") {
Button(tr("拷贝 PID \(String(process.pid))")) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(process.pid), forType: .string)
}
if ProcessExplainer.isSupported {
Button("用 Apple 智能解释") { model.explainProcess(.init(process)) }
Button(tr("用 Apple 智能解释")) { model.explainProcess(.init(process)) }
}
Divider()
Button("查看全部进程") { model.settings.panelTab = .processes }
Button(tr("查看全部进程")) { model.settings.panelTab = .processes }
} label: {
icon
}
@@ -512,23 +513,23 @@ private struct QuickActionsCard: View {
Card(padding: DS.Space.s3, spacing: 0) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "switch.2").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("快捷开关").dsFont(.xs, weight: .semibold)
Text(tr("快捷开关")).dsFont(.xs, weight: .semibold)
}
.foregroundStyle(DS.Palette.textSecondary)
.padding(.bottom, DS.Space.s1)
QuickToggleRow(icon: "cup.and.saucer", title: "防休眠",
detail: keepAwake.isActive ? "保持唤醒" : "按系统休眠",
QuickToggleRow(icon: "cup.and.saucer", title: tr("防休眠"),
detail: keepAwake.isActive ? tr("保持唤醒") : tr("按系统休眠"),
isOn: Binding(get: { keepAwake.isActive },
set: { value in Task { await keepAwake.setActive(value) } }))
HairlineDivider()
QuickToggleRow(icon: "laptopcomputer", title: "合盖运行",
detail: keepAwake.lidClosedActive ? "继续运行" : "合盖睡眠",
QuickToggleRow(icon: "laptopcomputer", title: tr("合盖运行"),
detail: keepAwake.lidClosedActive ? tr("继续运行") : tr("合盖睡眠"),
isOn: Binding(get: { keepAwake.lidClosedRequested },
set: { model.requestLidMode($0) }))
HairlineDivider()
QuickToggleRow(icon: "fan", title: "散热模式",
detail: fans.mode == .automatic ? "系统调节" : "\(fans.mode.title)中",
QuickToggleRow(icon: "fan", title: tr("散热模式"),
detail: fans.mode == .automatic ? tr("系统调节") : tr("\(fans.mode.title)中"),
isOn: Binding(get: { fans.mode != .automatic },
set: { model.requestFanMode($0 ? .cooling : .automatic) }))
}
@@ -1,4 +1,5 @@
import AppKit
import Localization
import Metrics
import SwiftUI
@@ -9,9 +10,9 @@ struct ProcessesPage: View {
var title: String {
switch self {
case .all: "全部"
case .mine: "我的"
case .system: "系统"
case .all: tr("全部")
case .mine: tr("我的")
case .system: tr("系统")
}
}
}
@@ -19,7 +20,7 @@ struct ProcessesPage: View {
enum Grouping: String, CaseIterable {
case processes, apps
var title: String { self == .processes ? "按进程" : "按应用" }
var title: String { self == .processes ? tr("按进程") : tr("按应用") }
}
@Environment(AppModel.self) private var model
@@ -51,7 +52,7 @@ struct ProcessesPage: View {
}
HairlineDivider()
if rows.isEmpty {
Text(model.store.processes.isEmpty ? "正在读取进程…" : "没有匹配的进程")
Text(model.store.processes.isEmpty ? tr("正在读取进程…") : tr("没有匹配的进程"))
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity)
@@ -89,15 +90,15 @@ struct ProcessesPage: View {
}
.padding(DS.Space.s3)
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.confirmationDialog(pendingQuit.map { "结束“\($0.title)”?" } ?? "", isPresented: Binding(get: { pendingQuit != nil }, set: { if !$0 { pendingQuit = nil } }),
.confirmationDialog(pendingQuit.map { tr("结束“\($0.title)”?") } ?? "", isPresented: Binding(get: { pendingQuit != nil }, set: { if !$0 { pendingQuit = nil } }),
titleVisibility: .visible, presenting: pendingQuit) { row in
Button("退出") { quit(row, force: false) }
Button("强制退出", role: .destructive) { quit(row, force: true) }
Button("取消", role: .cancel) {}
Button(tr("退出")) { quit(row, force: false) }
Button(tr("强制退出"), role: .destructive) { quit(row, force: true) }
Button(tr("取消"), role: .cancel) {}
} message: { row in
Text(row.count > 1
? "会结束这个应用的 \(row.count) 个进程,未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。"
: "未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。")
? tr("会结束这个应用的 \(row.count) 个进程,未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。")
: tr("未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。"))
}
}
@@ -109,11 +110,11 @@ struct ProcessesPage: View {
Image(systemName: "magnifyingglass")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.textTertiary)
TextField("搜索名称、PID 或用户", text: $search)
TextField(tr("搜索名称、PID 或用户"), text: $search)
.textFieldStyle(.plain)
.dsFont(.sm)
if !search.isEmpty {
MiniIconButton(systemName: "xmark.circle.fill", help: "清除搜索") { search = "" }
MiniIconButton(systemName: "xmark.circle.fill", help: tr("清除搜索")) { search = "" }
}
}
.padding(.horizontal, DS.Space.s2)
@@ -135,18 +136,18 @@ struct ProcessesPage: View {
let counts = model.store.systemCounts
return HStack(spacing: DS.Space.s4) {
if let cpu {
LegendItem(color: DS.Palette.primary, label: "用户", value: Format.percent(cpu.user))
LegendItem(color: DS.Palette.secondary, label: "系统", value: Format.percent(cpu.system))
LegendItem(color: DS.Palette.track, label: "空闲", value: Format.percent(max(0, 1 - cpu.total)))
LegendItem(color: DS.Palette.primary, label: tr("用户"), value: Format.percent(cpu.user))
LegendItem(color: DS.Palette.secondary, label: tr("系统"), value: Format.percent(cpu.system))
LegendItem(color: DS.Palette.track, label: tr("空闲"), value: Format.percent(max(0, 1 - cpu.total)))
}
Spacer(minLength: DS.Space.s2)
if let counts {
Text(verbatim: "进程 \(counts.processes.formatted()) · 线程 \(counts.threads.formatted())")
Text(verbatim: tr("进程 \(counts.processes.formatted()) · 线程 \(counts.threads.formatted())"))
.dsFont(.xs)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
}
Text(verbatim: "显示 \(count) 项").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(verbatim: tr("显示 \(count) 项")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
.padding(.horizontal, DS.Space.s1)
}
@@ -154,20 +155,20 @@ struct ProcessesPage: View {
@ViewBuilder
private func menu(for row: ProcessRowModel) -> some View {
if ProcessExplainer.isSupported {
Button("用 Apple 智能解释") { model.explainProcess(.init(row.representative)) }
Button(tr("用 Apple 智能解释")) { model.explainProcess(.init(row.representative)) }
Divider()
}
if let path = row.bundlePath ?? row.representative.executablePath {
Button("在访达中显示") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) }
Button(tr("在访达中显示")) { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) }
}
if let pid = row.pid {
Button("拷贝 PID") {
Button(tr("拷贝 PID")) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(pid), forType: .string)
}
}
Divider()
Button(row.count > 1 ? "退出应用…" : "结束进程…") { selection = row.id; pendingQuit = row }
Button(row.count > 1 ? tr("退出应用…") : tr("结束进程…")) { selection = row.id; pendingQuit = row }
.disabled(row.quitBlockedReason != nil)
}
@@ -245,7 +246,7 @@ struct ProcessRowModel: Identifiable {
let representative = group.max { $0.memory < $1.memory } ?? group[0]
id = "app-\(key)"
self.title = title
subtitle = group.count > 1 ? "\(group.count) 个进程" : "PID \(representative.pid)"
subtitle = group.count > 1 ? tr("\(group.count) 个进程") : "PID \(representative.pid)"
bundlePath = representative.appBundlePath
cpu = group.reduce(0) { $0 + $1.cpu }
cpuTime = group.reduce(0) { $0 + $1.cpuTime }
@@ -286,9 +287,9 @@ struct ProcessRowModel: Identifiable {
/// 不能从这里结束的原因;nil 表示可以结束
var quitBlockedReason: String? {
if !isOwned { return "系统或其他用户的进程,OpenStats 不提供结束" }
if processes.contains(where: { $0.pid == getpid() }) { return "请从菜单退出 OpenStats" }
if processes.contains(where: { ProcessActions.protectedNames.contains($0.name) }) { return "结束它会注销当前用户,已禁止" }
if !isOwned { return tr("系统或其他用户的进程,OpenStats 不提供结束") }
if processes.contains(where: { $0.pid == getpid() }) { return tr("请从菜单退出 OpenStats") }
if processes.contains(where: { ProcessActions.protectedNames.contains($0.name) }) { return tr("结束它会注销当前用户,已禁止") }
return nil
}
}
@@ -312,14 +313,14 @@ enum ProcessColumn: CaseIterable {
var title: String {
switch self {
case .name: "进程"
case .name: tr("进程")
case .cpu: "CPU"
case .cpuTime: "CPU 时间"
case .memory: "内存"
case .threads: "线程"
case .wakeups: "唤醒"
case .disk: "磁盘"
case .user: "用户"
case .cpuTime: tr("CPU 时间")
case .memory: tr("内存")
case .threads: tr("线程")
case .wakeups: tr("唤醒")
case .disk: tr("磁盘")
case .user: tr("用户")
}
}
@@ -328,18 +329,18 @@ enum ProcessColumn: CaseIterable {
case .name: nil
case .cpu, .memory, .user: DS.Size.valueColumn
case .cpuTime, .disk: DS.Size.valueColumn + DS.Space.s4
case .threads, .wakeups: DS.Size.labelColumn
case .threads, .wakeups: DS.Size.valueColumn
}
}
var help: String {
switch self {
case .cpu: "以单核满载为 100%"
case .cpuTime: "进程启动以来累计占用的 CPU 时间"
case .memory: "自己的进程为实际占用内存,系统进程为常驻内存"
case .threads: "线程数(只能读取自己的进程)"
case .wakeups: "每秒让 CPU 从空闲中唤醒的次数,越高越耗电"
case .disk: "每秒读写磁盘的字节数"
case .cpu: tr("以单核满载为 100%")
case .cpuTime: tr("进程启动以来累计占用的 CPU 时间")
case .memory: tr("自己的进程为实际占用内存,系统进程为常驻内存")
case .threads: tr("线程数(只能读取自己的进程)")
case .wakeups: tr("每秒让 CPU 从空闲中唤醒的次数,越高越耗电")
case .disk: tr("每秒读写磁盘的字节数")
default: ""
}
}
@@ -466,7 +467,7 @@ private struct ProcessInspector: View {
.frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(verbatim: row.title).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
Text(verbatim: row.bundlePath ?? row.representative.executablePath ?? "路径不可读")
Text(verbatim: row.bundlePath ?? row.representative.executablePath ?? tr("路径不可读"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
@@ -480,15 +481,15 @@ private struct ProcessInspector: View {
}
Spacer(minLength: DS.Space.s3)
if ProcessExplainer.isSupported {
Button { onExplain() } label: { Label("Apple 智能解释", systemImage: "sparkles") }
Button { onExplain() } label: { Label(tr("Apple 智能解释"), systemImage: "sparkles") }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
if let path = row.bundlePath ?? row.representative.executablePath {
IconButton(systemName: "folder", help: "在访达中显示") {
IconButton(systemName: "folder", help: tr("在访达中显示")) {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
}
Button(row.count > 1 ? "退出应用…" : "结束进程…") { onQuit() }
Button(row.count > 1 ? tr("退出应用…") : tr("结束进程…")) { onQuit() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(row.quitBlockedReason != nil)
.help(row.quitBlockedReason ?? "")
@@ -504,10 +505,10 @@ private struct ProcessInspector: View {
}
private var details: String {
var parts = [row.pid.map { "PID \($0)" } ?? "\(row.count) 个进程", "用户 \(row.user)"]
if let threads = row.threads { parts.append("线程 \(threads)") }
parts.append("CPU 时间 \(Format.cpuTime(row.cpuTime))")
if let disk = row.disk { parts.append("读 \(Format.menuBarRate(disk.read)) · 写 \(Format.menuBarRate(disk.write))") }
var parts = [row.pid.map { "PID \($0)" } ?? tr("\(row.count) 个进程"), tr("用户 \(row.user)")]
if let threads = row.threads { parts.append(tr("线程 \(threads)")) }
parts.append(tr("CPU 时间 \(Format.cpuTime(row.cpuTime))"))
if let disk = row.disk { parts.append(tr("读 \(Format.menuBarRate(disk.read)) · 写 \(Format.menuBarRate(disk.write))")) }
return parts.joined(separator: " · ")
}
}
@@ -526,10 +527,10 @@ enum ProcessActions {
if let bundle = row.bundlePath,
let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleURL?.path == bundle }) {
let sent = force ? app.forceTerminate() : app.terminate()
return sent ? nil : "应用拒绝退出,可以试试强制退出"
return sent ? nil : tr("应用拒绝退出,可以试试强制退出")
}
for process in row.processes where kill(process.pid, force ? SIGKILL : SIGTERM) != 0 {
return "结束 \(process.name) 失败:\(String(cString: strerror(errno)))"
return tr("结束 \(process.name) 失败:\(String(cString: strerror(errno)))")
}
return nil
}
@@ -547,16 +548,16 @@ private struct ProcessExplanationCard: View {
Image(systemName: "sparkles")
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.primary)
Text("Apple 智能解释").dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(tr("Apple 智能解释")).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: "\(subject.displayName) · PID \(subject.pid)")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
if explainer.phase == .done {
MiniIconButton(systemName: "arrow.clockwise", help: "重新生成") { explainer.explain(subject) }
MiniIconButton(systemName: "arrow.clockwise", help: tr("重新生成")) { explainer.explain(subject) }
}
MiniIconButton(systemName: "xmark", help: "关闭") { explainer.dismiss() }
MiniIconButton(systemName: "xmark", help: tr("关闭")) { explainer.dismiss() }
}
switch explainer.phase {
@@ -566,7 +567,7 @@ private struct ProcessExplanationCard: View {
if explainer.text.isEmpty {
HStack(spacing: DS.Space.s2) {
ProgressView().controlSize(.small)
Text("正在本机生成…").dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
Text(tr("正在本机生成…")).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
}
} else {
Text(explainer.text)
@@ -577,7 +578,7 @@ private struct ProcessExplanationCard: View {
}
}
Text("由 Apple 智能在这台 Mac 上生成,不联网;内容可能不准确,结束进程前请自行确认。")
Text(tr("由 Apple 智能在这台 Mac 上生成,不联网;内容可能不准确,结束进程前请自行确认。"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -1,5 +1,6 @@
import AppKit
import Cleaner
import Localization
import ServiceManagement
import SwiftUI
@@ -34,7 +35,7 @@ final class StartupItemsController {
message = (error, true)
} else {
Log.app.notice("\(enabled ? "启用" : "停用", privacy: .public)启动项 \(item.label, privacy: .public)")
message = ("已\(enabled ? "启用" : "停用")“\(item.label)”", false)
message = (tr("已\(enabled ? tr("启用") : tr("停用"))“\(item.label)”"), false)
}
isLoading = false
refresh()
@@ -50,8 +51,8 @@ struct StartupItemsPage: View {
PageScroll {
InfoBanner(icon: "info.circle",
text: "这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。") {
Button("登录项设置") { SMAppService.openSystemSettingsLoginItems() }
text: tr("这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。")) {
Button(tr("登录项设置")) { SMAppService.openSystemSettingsLoginItems() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
if let message = controller.message {
@@ -67,12 +68,12 @@ struct StartupItemsPage: View {
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
if scope == .user {
IconButton(systemName: "arrow.clockwise", help: "刷新") { controller.refresh() }
IconButton(systemName: "arrow.clockwise", help: tr("刷新")) { controller.refresh() }
}
}
}
if items.isEmpty {
Text(controller.isLoading && controller.items.isEmpty ? "正在读取…" : "没有启动项").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(controller.isLoading && controller.items.isEmpty ? tr("正在读取…") : tr("没有启动项")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
if index > 0 { HairlineDivider() }
@@ -88,8 +89,8 @@ struct StartupItemsPage: View {
private func scopeDetail(_ scope: LaunchItem.Scope, count: Int) -> String {
switch scope {
case .user: "\(count) 项 · 可直接停用"
case .allUsers, .system: "\(count) 项 · 需要管理员权限,只读"
case .user: tr("\(count) 项 · 可直接停用")
case .allUsers, .system: tr("\(count) 项 · 需要管理员权限,只读")
}
}
}
@@ -129,14 +130,14 @@ private struct StartupItemRow: View {
}
}
Spacer(minLength: DS.Space.s2)
MiniIconButton(systemName: "magnifyingglass", help: "在访达中显示") {
MiniIconButton(systemName: "magnifyingglass", help: tr("在访达中显示")) {
NSWorkspace.shared.activateFileViewerSelecting([item.plist])
}
if item.scope == .user {
if isWorking {
ProgressView().controlSize(.small)
} else {
DSToggle(isOn: Binding(get: { !disabled }, set: { setEnabled($0) }), label: "启用 \(title)")
DSToggle(isOn: Binding(get: { !disabled }, set: { setEnabled($0) }), label: tr("启用 \(title)"))
}
}
}
@@ -150,17 +151,17 @@ private struct StartupItemRow: View {
}
private var flags: String {
[item.runAtLoad ? "登录时运行" : nil, item.keepAlive ? "保持运行" : nil].compactMap { $0 }.joined(separator: "、")
[item.runAtLoad ? tr("登录时运行") : nil, item.keepAlive ? tr("保持运行") : nil].compactMap { $0 }.joined(separator: tr("、"))
}
@ViewBuilder
private func badge(disabled: Bool) -> some View {
if disabled {
StatusBadge(text: "已停用")
StatusBadge(text: tr("已停用"))
} else if let pid = status.pid(item) {
StatusBadge(text: "运行中 · PID \(pid)", tone: .success)
StatusBadge(text: tr("运行中 · PID \(pid)"), tone: .success)
} else if status.isLoaded(item) {
StatusBadge(text: "已加载", tone: .primary)
StatusBadge(text: tr("已加载"), tone: .primary)
}
}
}
@@ -1,4 +1,5 @@
import AppKit
import Localization
import Metrics
import SwiftUI
@@ -25,7 +26,7 @@ struct SystemInfoPage: View {
Text(verbatim: localizedModel(system.modelName))
.dsFont(.xl, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: "\(system.osVersion)(\(system.osBuild))")
Text(verbatim: tr("\(system.osVersion)(\(system.osBuild))"))
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
FlowLayout(spacing: DS.Space.s2) {
@@ -35,7 +36,7 @@ struct SystemInfoPage: View {
Chip(text: Format.bytes(disk.total, base: .decimal).replacingOccurrences(of: ".0 ", with: " "))
}
if let boot = system.bootDate {
Chip(text: "已运行 " + Format.uptime(since: boot))
Chip(text: tr("已运行 ") + Format.uptime(since: boot))
}
}
}
@@ -44,67 +45,68 @@ struct SystemInfoPage: View {
}
WeightedRow(weights: [1, 1]) {
InfoCard(icon: "cpu", title: "处理器") {
InfoRow(label: "芯片", text: topology.brand)
InfoRow(label: "核心", text: "\(topology.logicalCores) 核")
InfoCard(icon: "cpu", title: tr("处理器")) {
InfoRow(label: tr("芯片"), text: topology.brand)
InfoRow(label: tr("核心"), text: tr("\(topology.logicalCores) 核"))
ForEach(topology.clusters) { cluster in
InfoRow(label: cluster.name, text: "\(cluster.coreIndices.count) 核")
InfoRow(label: cluster.name, text: tr("\(cluster.coreIndices.count) 核"))
}
}
InfoCard(icon: "memorychip", title: "内存与图形") {
InfoRow(label: "内存", text: Format.bytes(ProcessInfo.processInfo.physicalMemory))
InfoRow(label: "图形", text: store.gpu?.name ?? topology.brand)
InfoRow(label: "图形核心", text: store.gpu?.coreCount.map { "\($0) 核" } ?? "—")
InfoRow(label: "内存类型", text: "统一内存(CPU 与 GPU 共享)")
InfoCard(icon: "memorychip", title: tr("内存与图形")) {
InfoRow(label: tr("内存"), text: Format.bytes(ProcessInfo.processInfo.physicalMemory))
InfoRow(label: tr("图形"), text: store.gpu?.name ?? topology.brand)
InfoRow(label: tr("图形核心"), text: store.gpu?.coreCount.map { tr("\($0) 核") } ?? "—")
InfoRow(label: tr("内存类型"), text: tr("统一内存(CPU 与 GPU 共享)"))
}
}
WeightedRow(weights: [1, 1]) {
InfoCard(icon: "internaldrive", title: "存储") {
InfoCard(icon: "internaldrive", title: tr("存储")) {
if let disk = store.disk {
InfoRow(label: "卷", text: disk.volumeName)
InfoRow(label: "容量", text: Format.bytes(disk.total, base: .decimal))
InfoRow(label: "可用", text: Format.bytes(disk.available, base: .decimal))
InfoRow(label: tr("卷"), text: disk.volumeName)
InfoRow(label: tr("容量"), text: Format.bytes(disk.total, base: .decimal))
InfoRow(label: tr("可用"), text: Format.bytes(disk.available, base: .decimal))
ProgressTrack(fraction: disk.usedFraction,
color: disk.usedFraction > 0.9 ? DS.Palette.warning : DS.Palette.primary)
} else {
Text("正在读取…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("正在读取…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
InfoCard(icon: "battery.75", title: "电池") {
InfoCard(icon: "battery.75", title: tr("电池")) {
if let battery = store.battery {
InfoRow(label: "电量", text: Format.percent(battery.level))
InfoRow(label: "健康度", text: battery.health.map { Format.percent($0) } ?? "—")
InfoRow(label: "循环次数", text: battery.cycleCount.map(String.init) ?? "—")
InfoRow(label: "电源", text: battery.isPluggedIn ? "电源适配器\(battery.adapterWatts.map { " · \($0)W" } ?? "")" : "电池供电")
InfoRow(label: tr("电量"), text: Format.percent(battery.level))
InfoRow(label: tr("健康度"), text: battery.health.map { Format.percent($0) } ?? "—")
InfoRow(label: tr("循环次数"), text: battery.cycleCount.map(String.init) ?? "—")
InfoRow(label: tr("电源"), text: battery.isPluggedIn ? tr("电源适配器\(battery.adapterWatts.map { " · \($0)W" } ?? "")") : tr("电池供电"))
// macOS 26 起系统自带充电上限;新款机型已不开放第三方写入 SMC 充电控制键,不另做一套
if ProcessInfo.processInfo.isOperatingSystemAtLeast(OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0)) {
HStack(spacing: DS.Space.s2) {
Text("充电上限可在系统设置中设为 80%–100%,长期接电源时有助于延缓电池老化")
Text(tr("充电上限可在系统设置中设为 80%–100%,长期接电源时有助于延缓电池老化"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button("电池设置") {
Button(tr("电池设置")) {
if let url = URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.fixedSize()
}
}
} else {
Text("这台 Mac 没有电池").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("这台 Mac 没有电池")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
BluetoothCard()
InfoCard(icon: "display.2", title: "显示器") {
InfoCard(icon: "display.2", title: tr("显示器")) {
let displays = DisplayInfo.all()
if displays.isEmpty {
Text("没有检测到显示器").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("没有检测到显示器")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(Array(displays.enumerated()), id: \.offset) { index, display in
if index > 0 { HairlineDivider() }
@@ -118,15 +120,15 @@ struct SystemInfoPage: View {
Text(verbatim: display.summary).dsFont(.xs).monospacedDigit().foregroundStyle(DS.Palette.textSecondary)
}
Spacer(minLength: 0)
if display.isMain { Chip(text: "主显示器", tone: .primary) }
if display.isMain { Chip(text: tr("主显示器"), tone: .primary) }
}
}
}
InfoCard(icon: "number", title: "标识") {
InfoRow(label: "机型标识符") { CopyableText(text: system.modelIdentifier.isEmpty ? "—" : system.modelIdentifier) }
InfoRow(label: "系统版号") { CopyableText(text: system.osBuild.isEmpty ? "—" : system.osBuild) }
InfoRow(label: "序列号") {
InfoCard(icon: "number", title: tr("标识")) {
InfoRow(label: tr("机型标识符")) { CopyableText(text: system.modelIdentifier.isEmpty ? "—" : system.modelIdentifier) }
InfoRow(label: tr("系统版号")) { CopyableText(text: system.osBuild.isEmpty ? "—" : system.osBuild) }
InfoRow(label: tr("序列号")) {
HStack(spacing: DS.Space.s1) {
if revealSerial, !isSnapshot, let serial = system.serialNumber {
CopyableText(text: serial)
@@ -134,14 +136,14 @@ struct SystemInfoPage: View {
Text(verbatim: system.serialNumber.map { String(repeating: "•", count: min(12, $0.count)) } ?? "—")
}
if system.serialNumber != nil, !isSnapshot {
MiniIconButton(systemName: revealSerial ? "eye.slash" : "eye", help: revealSerial ? "隐藏序列号" : "显示序列号") {
MiniIconButton(systemName: revealSerial ? "eye.slash" : "eye", help: revealSerial ? tr("隐藏序列号") : tr("显示序列号")) {
revealSerial.toggle()
}
}
}
}
if let boot = system.bootDate {
InfoRow(label: "启动于", text: boot.formatted(date: .abbreviated, time: .shortened))
InfoRow(label: tr("启动于"), text: boot.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))
}
}
}
@@ -155,10 +157,10 @@ struct SystemInfoPage: View {
.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
var size = ""
if let index = details.firstIndex(where: { $0.hasSuffix("-inch") }) {
size = " \(details[index].dropLast("-inch".count)) 英寸"
size = tr(" \(details[index].dropLast("-inch".count)) 英寸")
details.remove(at: index)
}
return details.isEmpty ? base + size : "\(base)\(size)(\(details.joined(separator: ",")))"
return details.isEmpty ? base + size : tr("\(base)\(size)(\(details.joined(separator: tr(","))))")
}
private func chipName(_ brand: String) -> String {
@@ -198,11 +200,11 @@ struct DisplayInfo {
let millimeters = CGDisplayScreenSize(id)
if millimeters.width > 0 {
let inches = (millimeters.width * millimeters.width + millimeters.height * millimeters.height).squareRoot() / 25.4
parts.append("\(Int(inches.rounded())) 英寸")
parts.append(tr("\(Int(inches.rounded())) 英寸"))
}
if let native = nativeResolution(id) { parts.append("\(native.width)×\(native.height)") }
let points = screen.frame.size
parts.append("显示为 \(Int(points.width))×\(Int(points.height))")
parts.append(tr("显示为 \(Int(points.width))×\(Int(points.height))"))
if screen.maximumFramesPerSecond > 0 { parts.append("\(screen.maximumFramesPerSecond)Hz") }
return DisplayInfo(name: screen.localizedName, isBuiltIn: CGDisplayIsBuiltin(id) != 0,
isMain: CGDisplayIsMain(id) != 0, summary: parts.joined(separator: " · "))
@@ -226,10 +228,10 @@ private struct BluetoothCard: View {
@State private var devices: [BluetoothDevice]?
var body: some View {
InfoCard(icon: "dot.radiowaves.left.and.right", title: "蓝牙设备") {
InfoCard(icon: "dot.radiowaves.left.and.right", title: tr("蓝牙设备")) {
if let devices {
if devices.isEmpty {
Text("没有已连接的蓝牙设备").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("没有已连接的蓝牙设备")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(devices) { device in
HStack(spacing: DS.Space.s2) {
@@ -243,7 +245,7 @@ private struct BluetoothCard: View {
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
if device.batteries.isEmpty {
Text("不提供电量").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("不提供电量")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(device.batteries, id: \.label) { battery in
HStack(spacing: DS.Space.s1) {
@@ -263,7 +265,7 @@ private struct BluetoothCard: View {
}
}
} else {
Text("正在读取…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("正在读取…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
.task {
@@ -1,3 +1,4 @@
import Localization
import Metrics
import SMC
import SwiftUI
@@ -26,8 +27,8 @@ private struct TemperatureCard: View {
let sensorCount = summaries.reduce(0) { $0 + $1.sensorCount }
Card {
CardHeader(icon: "thermometer.medium", title: "温度",
detail: summaries.isEmpty ? "读取中" : "\(sensorCount) 个传感器")
CardHeader(icon: "thermometer.medium", title: tr("温度"),
detail: summaries.isEmpty ? tr("读取中") : tr("\(sensorCount) 个传感器"))
if summaries.isEmpty {
PlaceholderLine()
} else {
@@ -59,7 +60,7 @@ private struct TemperatureRow: View {
.frame(width: DS.Size.valueColumn, alignment: .trailing)
}
if summary.sensorCount > 1 {
Text("平均 \(Format.temperature(summary.average, fahrenheit: fahrenheit)) · 最高 \(Format.temperature(summary.maximum, fahrenheit: fahrenheit))")
Text(tr("平均 \(Format.temperature(summary.average, fahrenheit: fahrenheit)) · 最高 \(Format.temperature(summary.maximum, fahrenheit: fahrenheit))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.padding(.leading, DS.Size.labelColumn + DS.Space.s3)
@@ -74,7 +75,7 @@ private struct PowerCard: View {
var body: some View {
let power = model.store.power
Card {
CardHeader(icon: "bolt", title: "功耗", detail: power?.system.map { "整机 \(Format.watts($0))" } ?? "读取中")
CardHeader(icon: "bolt", title: tr("功耗"), detail: power?.system.map { tr("整机 \(Format.watts($0))") } ?? tr("读取中"))
PowerRows(power: power, history: model.store.powerHistory.elements)
}
}
@@ -88,17 +89,17 @@ private struct FanCard: View {
let controller = model.fans
Card {
CardHeader(icon: "fan", title: "风扇") {
CardHeader(icon: "fan", title: tr("风扇")) {
if !fans.isEmpty {
StatusBadge(text: fans.contains(where: \.isManual)
? (controller.mode == .automatic ? "其他程序控制" : "OpenStats 控制")
: "系统自动",
? (controller.mode == .automatic ? tr("其他程序控制") : tr("OpenStats 控制"))
: tr("系统自动"),
tone: fans.contains(where: \.isManual) ? .primary : .neutral)
}
}
if fans.isEmpty {
Text(model.store.sensors == nil ? "正在读取风扇…" : "此设备没有可调节的风扇")
Text(model.store.sensors == nil ? tr("正在读取风扇…") : tr("此设备没有可调节的风扇"))
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
} else {
@@ -108,7 +109,7 @@ private struct FanCard: View {
HairlineDivider()
Text("调速模式")
Text(tr("调速模式"))
.dsFont(.sm, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
@@ -120,7 +121,7 @@ private struct FanCard: View {
}
if model.helper.needsAttention {
HelperRequiredBanner(text: "调节风扇需要安装辅助工具,仅需管理员授权一次。")
HelperRequiredBanner(text: tr("调节风扇需要安装辅助工具,仅需管理员授权一次。"))
}
if let notice = controller.notice {
@@ -129,7 +130,7 @@ private struct FanCard: View {
tone: controller.noticeIsError ? .error : .success)
}
Text(verbatim: "自定义模式下 CPU 达到 \(model.settings.fanSafetyTemperature)°C 会自动交还系统控制;退出应用时风扇恢复自动。")
Text(verbatim: tr("自定义模式下 CPU 达到 \(model.settings.fanSafetyTemperature)°C 会自动交还系统控制;退出应用时风扇恢复自动。"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -143,8 +144,8 @@ private struct FanCard: View {
}
private func name(for fan: FanState, count: Int) -> String {
guard count == 2 else { return "风扇 \(fan.id + 1)" }
return fan.id == 0 ? "左侧风扇" : "右侧风扇"
guard count == 2 else { return tr("风扇 \(fan.id + 1)") }
return fan.id == 0 ? tr("左侧风扇") : tr("右侧风扇")
}
}
@@ -163,7 +164,7 @@ private struct FanRow: View {
.foregroundStyle(DS.Palette.textPrimary)
}
ProgressTrack(fraction: fan.fraction, color: DS.Palette.secondary)
Text(verbatim: "最低 \(Int(fan.minimum)) · 最高 \(Int(fan.maximum)) · 目标 \(Int(fan.target)) RPM")
Text(verbatim: tr("最低 \(Int(fan.minimum)) · 最高 \(Int(fan.maximum)) · 目标 \(Int(fan.target)) RPM"))
.dsFont(.xs)
.monospacedDigit()
.foregroundStyle(DS.Palette.textTertiary)
@@ -182,9 +183,9 @@ private struct CustomLevelControl: View {
VStack(alignment: .leading, spacing: DS.Space.s2) {
HStack {
Text("目标转速").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Text(tr("目标转速")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer()
Text("\(Format.percent(controller.customLevel)) · 约 \(Format.rpm(rpm))")
Text(tr("\(Format.percent(controller.customLevel)) · 约 \(Format.rpm(rpm))"))
.dsFont(.xs, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
@@ -193,9 +194,9 @@ private struct CustomLevelControl: View {
Task { await model.fans.commitCustomLevel() }
}
HStack {
Text("安静").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("安静")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Spacer()
Text("最强").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("最强")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
@@ -209,14 +210,14 @@ struct HelperRequiredBanner: View {
InfoBanner(icon: "lock.shield", text: bannerText, tone: .warning) {
switch model.helper.status {
case .notInstalled:
Button("安装") { model.helper.install() }
Button(tr("安装")) { model.helper.install() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(model.helper.isWorking)
case .requiresApproval:
Button("去批准") { model.helper.openLoginItemsSettings() }
Button(tr("去批准")) { model.helper.openLoginItemsSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
case .enabled where model.helper.isOutdated:
Button("重新安装") { Task { await model.helper.reinstall() } }
Button(tr("重新安装")) { Task { await model.helper.reinstall() } }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(model.helper.isWorking)
case .enabled, .unavailable:
@@ -227,7 +228,7 @@ struct HelperRequiredBanner: View {
private var bannerText: String {
switch model.helper.status {
case .requiresApproval: "请在“系统设置 › 通用 › 登录项”中允许 OpenStats 的后台项目。"
case .requiresApproval: tr("请在“系统设置 › 通用 › 登录项”中允许 OpenStats 的后台项目。")
case .unavailable(let reason): reason
case .enabled where model.helper.isOutdated: HelperClient.outdatedMessage
default: text
@@ -1,4 +1,5 @@
import Cleaner
import Localization
import Metrics
import SwiftUI
import UniformTypeIdentifiers
@@ -38,11 +39,11 @@ struct UninstallerPage: View {
}
return true
}
.confirmationDialog(uninstaller.selected.map { "卸载“\($0.name)”?" } ?? "", isPresented: $confirming, titleVisibility: .visible) {
Button("移到废纸篓", role: .destructive) { uninstaller.uninstall() }
Button("取消", role: .cancel) {}
.confirmationDialog(uninstaller.selected.map { tr("卸载“\($0.name)”?") } ?? "", isPresented: $confirming, titleVisibility: .visible) {
Button(tr("移到废纸篓"), role: .destructive) { uninstaller.uninstall() }
Button(tr("取消"), role: .cancel) {}
} message: {
Text("应用与勾选的 \(max(0, uninstaller.chosen.count - 1)) 项残留会移到废纸篓,约 \(Format.bytes(uninstaller.chosenSize, base: .decimal))。清空废纸篓前都可以放回。")
Text(tr("应用与勾选的 \(max(0, uninstaller.chosen.count - 1)) 项残留会移到废纸篓,约 \(Format.bytes(uninstaller.chosenSize, base: .decimal))。清空废纸篓前都可以放回。"))
}
}
}
@@ -62,17 +63,17 @@ private struct AppListColumn: View {
Image(systemName: "magnifyingglass")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.textTertiary)
TextField("搜索应用", text: $search)
TextField(tr("搜索应用"), text: $search)
.textFieldStyle(.plain)
.dsFont(.sm)
IconButton(systemName: "arrow.clockwise", help: "重新扫描") { uninstaller.loadApps() }
IconButton(systemName: "arrow.clockwise", help: tr("重新扫描")) { uninstaller.loadApps() }
}
.padding(.leading, DS.Space.s2)
.frame(height: DS.Size.controlHeight)
.background(DS.Palette.elevated, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.neutral300, lineWidth: DS.Size.stroke))
Text(uninstaller.isLoading ? "正在扫描应用…" : "\(apps.count) 个应用,系统自带的不列出")
Text(uninstaller.isLoading ? tr("正在扫描应用…") : tr("\(apps.count) 个应用,系统自带的不列出"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
@@ -110,7 +111,7 @@ private struct AppListRow: View {
.dsFont(.sm, weight: isSelected ? .semibold : .regular)
.foregroundStyle(isSelected ? DS.Palette.primary : DS.Palette.textPrimary)
.lineLimit(1)
Text(verbatim: size.map { Format.bytes($0, base: .decimal) } ?? "计算中")
Text(verbatim: size.map { Format.bytes($0, base: .decimal) } ?? tr("计算中"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
@@ -143,7 +144,7 @@ private struct AppDetailCard: View {
.frame(width: DS.Space.s12, height: DS.Space.s12)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(verbatim: app.name).dsFont(.lg, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: [app.version.map { "版本 \($0)" }, app.bundleIdentifier].compactMap { $0 }.joined(separator: " · "))
Text(verbatim: [app.version.map { tr("版本 \($0)") }, app.bundleIdentifier].compactMap { $0 }.joined(separator: " · "))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
@@ -152,17 +153,17 @@ private struct AppDetailCard: View {
}
if running {
InfoBanner(icon: "exclamationmark.triangle", text: "\(app.name) 正在运行,卸载前需要先退出。", tone: .warning) {
Button("退出应用") { uninstaller.quit(app) }
InfoBanner(icon: "exclamationmark.triangle", text: tr("\(app.name) 正在运行,卸载前需要先退出。"), tone: .warning) {
Button(tr("退出应用")) { uninstaller.quit(app) }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
HairlineDivider()
HStack {
Text("将移到废纸篓").dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(tr("将移到废纸篓")).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Spacer()
Text(verbatim: uninstaller.isScanning ? "正在查找残留…" : "已选 \(uninstaller.chosen.count) 项 · \(Format.bytes(uninstaller.chosenSize, base: .decimal))")
Text(verbatim: uninstaller.isScanning ? tr("正在查找残留…") : tr("已选 \(uninstaller.chosen.count) 项 · \(Format.bytes(uninstaller.chosenSize, base: .decimal))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
@@ -187,7 +188,7 @@ private struct AppDetailCard: View {
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
MiniIconButton(systemName: "magnifyingglass", help: "在访达中显示") {
MiniIconButton(systemName: "magnifyingglass", help: tr("在访达中显示")) {
NSWorkspace.shared.activateFileViewerSelecting([item.url])
}
}
@@ -198,14 +199,14 @@ private struct AppDetailCard: View {
HairlineDivider()
HStack(spacing: DS.Space.s3) {
DSToggle(isOn: $uninstaller.removeFromDock, label: "从程序坞移除图标")
Text("同时从程序坞移除图标").dsFont(.sm).foregroundStyle(DS.Palette.textPrimary)
DSToggle(isOn: $uninstaller.removeFromDock, label: tr("从程序坞移除图标"))
Text(tr("同时从程序坞移除图标")).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary)
Spacer()
Button(uninstaller.isRemoving ? "正在移除…" : "卸载") { confirm() }
Button(uninstaller.isRemoving ? tr("正在移除…") : tr("卸载")) { confirm() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(running || uninstaller.isScanning || uninstaller.isRemoving)
}
Text("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动")
Text(tr("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -221,10 +222,10 @@ private struct DropHint: View {
Image(systemName: "arrow.down.app")
.font(.system(size: DS.TextSize.xxl.rawValue))
.foregroundStyle(isTargeted ? DS.Palette.primary : DS.Palette.textTertiary)
Text("从左侧选择应用,或把应用拖到这里")
Text(tr("从左侧选择应用,或把应用拖到这里"))
.dsFont(.sm, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
Text("会一并找出它留在资源库里的缓存、偏好设置、容器与登录启动项,全部移到废纸篓,可以放回")
Text(tr("会一并找出它留在资源库里的缓存、偏好设置、容器与登录启动项,全部移到废纸篓,可以放回"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
@@ -1,4 +1,5 @@
import AppKit
import Localization
import Metrics
import SMC
import SwiftUI
@@ -38,7 +39,7 @@ struct CPUPopover: View {
CoreHeatmap(topology: store.topology, history: store.coreHistory.elements,
columns: isDetailPage ? 60 : 36,
rowHeight: isDetailPage ? DS.Space.s2 : DS.Space.s1 - DS.Size.stroke)
Text("每行一个核心,每列一次采样,最新的在右边")
Text(tr("每行一个核心,每列一次采样,最新的在右边"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
@@ -47,23 +48,23 @@ struct CPUPopover: View {
ForEach(store.topology.clusters) { cluster in
let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0
let frequency = store.power?.clusterFrequency[cluster.id].map { " · \(Format.frequency(megahertz: $0))" } ?? ""
ShareRow(label: "\(cluster.name) · \(cluster.coreIndices.count) 核\(frequency)", value: Format.percent(average),
ShareRow(label: tr("\(cluster.name) · \(cluster.coreIndices.count) 核\(frequency)"), value: Format.percent(average),
fraction: average, color: DS.Palette.cluster(cluster.id))
}
if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) {
InfoRow(label: "最忙的核心", text: busiest)
InfoRow(label: tr("最忙的核心"), text: busiest)
}
}
case .cpuLoadAverage:
SectionCard(title: section.title, trailing: { Text(loadTrend(cpu?.loadAverage ?? [])) }) {
let averages = cpu?.loadAverage ?? []
ForEach(Array(["1 分钟", "5 分钟", "15 分钟"].enumerated()), id: \.offset) { index, label in
ForEach(Array([tr("1 分钟"), tr("5 分钟"), tr("15 分钟")].enumerated()), id: \.offset) { index, label in
let value = averages[safe: index] ?? 0
ShareRow(label: label,
value: "每核 \((value / cores).formatted(.number.precision(.fractionLength(2))))",
value: tr("每核 \((value / cores).formatted(.number.precision(.fractionLength(2))))"),
fraction: value / cores, color: loadTone(value / cores).color)
}
Text("平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU")
Text(tr("平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -88,7 +89,7 @@ struct CPUPopover: View {
private func loadTrend(_ averages: [Double]) -> String {
guard averages.count >= 3, averages[2] > 0 else { return "" }
let change = averages[0] / averages[2]
return change > 1.15 ? "负载在上升" : change < 0.85 ? "负载在下降" : "负载平稳"
return change > 1.15 ? tr("负载在上升") : change < 0.85 ? tr("负载在下降") : tr("负载平稳")
}
}
@@ -116,16 +117,16 @@ private struct CPUHero: View {
Spacer(minLength: DS.Space.s2)
if let temperature {
VStack(alignment: .trailing, spacing: 0) {
Text("温度余量").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("温度余量")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(verbatim: "\(Int(max(0, 100 - temperature.maximum).rounded()))°C")
.dsFont(.base, weight: .semibold)
.monospacedDigit()
.foregroundStyle(Tone.forTemperature(temperature.maximum).color)
Text(verbatim: "当前 \(Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit))")
Text(verbatim: tr("当前 \(Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
.help("离 100°C 还差多少度;越接近 0,越可能因过热降频")
.help(tr("离 100°C 还差多少度;越接近 0,越可能因过热降频"))
}
}
LineHistoryChart(values: history, height: DS.Space.s8)
@@ -133,25 +134,25 @@ private struct CPUHero: View {
WaterlineBar(segments: [(cpu.user, DS.Palette.primary), (cpu.system, DS.Palette.secondary)],
total: 1, height: DS.Space.s2)
HStack(spacing: DS.Space.s3) {
LegendItem(color: DS.Palette.primary, label: "用户", value: Format.percent(cpu.user))
LegendItem(color: DS.Palette.secondary, label: "系统", value: Format.percent(cpu.system))
LegendItem(color: DS.Palette.track, label: "空闲", value: Format.percent(max(0, 1 - cpu.total)))
LegendItem(color: DS.Palette.primary, label: tr("用户"), value: Format.percent(cpu.user))
LegendItem(color: DS.Palette.secondary, label: tr("系统"), value: Format.percent(cpu.system))
LegendItem(color: DS.Palette.track, label: tr("空闲"), value: Format.percent(max(0, 1 - cpu.total)))
}
}
}
}
private func status(_ total: Double) -> String {
total >= 0.85 ? "满载" : total >= 0.6 ? "繁忙" : total >= 0.25 ? "适中" : "空闲"
total >= 0.85 ? tr("满载") : total >= 0.6 ? tr("繁忙") : total >= 0.25 ? tr("适中") : tr("空闲")
}
/// 与大约 30 秒前(第 15 个采样之前)相比的变化
private func trend(_ history: [Double]) -> String {
guard history.count > 15, let now = history.last else { return "正在收集走势…" }
guard history.count > 15, let now = history.last else { return tr("正在收集走势…") }
let before = history[history.count - 16]
let delta = Int(((now - before) * 100).rounded())
if abs(delta) < 3 { return "与 30 秒前持平" }
return delta > 0 ? "比 30 秒前高 \(delta)%" : "比 30 秒前低 \(-delta)%"
if abs(delta) < 3 { return tr("与 30 秒前持平") }
return delta > 0 ? tr("比 30 秒前高 \(delta)%") : tr("比 30 秒前低 \(-delta)%")
}
}
@@ -175,10 +176,10 @@ struct MemoryPopover: View {
if let memory {
let parts: [(String, UInt64, Color)] = [
("App", memory.app, DS.Palette.primary),
("联动", memory.wired, DS.Palette.secondary),
("压缩", memory.compressed, DS.Palette.warning),
("缓存", memory.cached, DS.Palette.primarySoft),
("空闲", memory.free, DS.Palette.track),
(tr("联动"), memory.wired, DS.Palette.secondary),
(tr("压缩"), memory.compressed, DS.Palette.warning),
(tr("缓存"), memory.cached, DS.Palette.primarySoft),
(tr("空闲"), memory.free, DS.Palette.track),
]
WaterlineBar(segments: parts.map { (Double($0.1), $0.2) }, total: Double(memory.total))
LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible())], spacing: DS.Space.s2) {
@@ -187,6 +188,8 @@ struct MemoryPopover: View {
RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(part.2)
.frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
Text(part.0).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: DS.Space.s1)
Text(verbatim: Format.bytes(part.1)).dsFont(.xs, weight: .medium).monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
@@ -198,20 +201,20 @@ struct MemoryPopover: View {
case .memoryCompression:
SectionCard(title: section.title) {
if let memory {
InfoRow(label: "压缩为你省下") {
InfoRow(label: tr("压缩为你省下")) {
Text(verbatim: Format.bytes(memory.compressionSavings)).foregroundStyle(DS.Palette.success)
}
InfoRow(label: "压缩比", text: memory.compressionRatio.map { "\($0.formatted(.number.precision(.fractionLength(1))))×" } ?? "—")
InfoRow(label: "交换区", text: memory.swapTotal > 0
? "\(Format.bytes(memory.swapUsed)) / \(Format.bytes(memory.swapTotal))" : "未使用")
InfoRow(label: "换入 / 换出", text: store.swapRate.map { "\(Format.menuBarRate($0.swapIn)) / \(Format.menuBarRate($0.swapOut))" } ?? "—")
InfoRow(label: tr("压缩比"), text: memory.compressionRatio.map { "\($0.formatted(.number.precision(.fractionLength(1))))×" } ?? "—")
InfoRow(label: tr("交换区"), text: memory.swapTotal > 0
? "\(Format.bytes(memory.swapUsed)) / \(Format.bytes(memory.swapTotal))" : tr("未使用"))
InfoRow(label: tr("换入 / 换出"), text: store.swapRate.map { "\(Format.menuBarRate($0.swapIn)) / \(Format.menuBarRate($0.swapOut))" } ?? "—")
if let rate = store.swapRate, rate.swapOut > 1024 * 1024 {
InfoBanner(icon: "exclamationmark.triangle.fill", text: "系统正在把内存写到磁盘,可能会变慢。可以关掉占用大的应用。", tone: .warning)
InfoBanner(icon: "exclamationmark.triangle.fill", text: tr("系统正在把内存写到磁盘,可能会变慢。可以关掉占用大的应用。"), tone: .warning)
}
}
}
case .memoryApps:
SectionCard(title: section.title, trailing: { Text("内存") }) {
SectionCard(title: section.title, trailing: { Text(tr("内存")) }) {
AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.memory > $1.memory },
rowCount: isDetailPage ? 10 : 6, metric: .memory, total: memory?.used)
}
@@ -233,13 +236,13 @@ private struct MemoryHero: View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
VStack(alignment: .leading, spacing: 0) {
Text("还可用").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("还可用")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
HeroValue(value: memory.map { availableNumber($0.available) } ?? "—", unit: "GB", size: .xxl)
}
Spacer(minLength: DS.Space.s2)
VStack(alignment: .trailing, spacing: DS.Space.s1) {
StatusBadge(text: "压力\(memory?.pressure.title ?? "—")", tone: tone(memory?.pressure))
Text(verbatim: memory.map { "已用 \(Format.bytes($0.used)) · \(Format.percent($0.usedFraction))" } ?? "")
StatusBadge(text: tr("压力\(memory?.pressure.title ?? "—")"), tone: tone(memory?.pressure))
Text(verbatim: memory.map { tr("已用 \(Format.bytes($0.used)) · \(Format.percent($0.usedFraction))") } ?? "")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
@@ -247,9 +250,9 @@ private struct MemoryHero: View {
}
PressureStrip(history: store.pressureHistory.elements)
HStack {
Text("压力走势").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("压力走势")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Spacer()
Text("最近 60 秒").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("最近 60 秒")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
@@ -315,7 +318,7 @@ private struct AppUsageList: View {
// 横条以列表里最大的一项为满格,比较谁占得多
let peak = visible.map { metric == .cpu ? $0.cpu : Double($0.memory) }.max() ?? 1
if visible.isEmpty {
Text("正在统计…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("正在统计…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(visible) { app in
let value = metric == .cpu ? app.cpu : Double(app.memory)
@@ -329,7 +332,9 @@ private struct AppUsageList: View {
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
if app.processCount > 1 {
Text(verbatim: "\(app.processCount) 个进程").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(verbatim: tr("\(app.processCount) 个进程")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
.fixedSize()
}
Spacer(minLength: DS.Space.s2)
Text(verbatim: valueText(app))
@@ -392,7 +397,7 @@ private struct ExplainableRow<Content: View>: View {
content
.contentShape(Rectangle())
.contextMenu(isSnapshot ? nil : ContextMenu {
Button("用 Apple 智能解释") { model.explainProcess(subject) }
Button(tr("用 Apple 智能解释")) { model.explainProcess(subject) }
})
}
}
@@ -419,13 +424,13 @@ struct PurgeMemoryButton: View {
} else {
Image(systemName: result == nil ? "wind" : result!.isError ? "exclamationmark.triangle" : "checkmark")
}
Text(running ? "正在释放…" : result?.text ?? "释放内存").lineLimit(1)
Text(running ? tr("正在释放…") : result?.text ?? tr("释放内存")).lineLimit(1).fixedSize()
}
.foregroundStyle(result?.isError == true ? DS.Palette.error : result != nil ? DS.Palette.success : DS.Palette.textPrimary)
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(maintenance.running != nil)
.help("清理可回收的缓存内存(需要管理员授权或辅助工具)")
.help(tr("清理可回收的缓存内存(需要管理员授权或辅助工具)"))
}
}
@@ -454,7 +459,7 @@ struct GPUPopover: View {
.dsFont(.sm, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Text(verbatim: gpu?.coreCount.map { "\($0) 核图形处理器" } ?? "图形处理器")
Text(verbatim: gpu?.coreCount.map { tr("\($0) 核图形处理器") } ?? tr("图形处理器"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
@@ -469,15 +474,15 @@ struct GPUPopover: View {
ForEach(MenuBarItem.gpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .gpuHistory:
SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) {
SectionCard(title: section.title, trailing: { Text(tr("最近 60 秒")) }) {
LineHistoryChart(values: store.gpuHistory.elements, color: DS.Palette.secondary, height: detailChartHeight(isDetailPage))
}
case .gpuDetails:
SectionCard(title: section.title) {
InfoRow(label: "型号", text: gpu?.name ?? "—")
InfoRow(label: "核心数", text: gpu?.coreCount.map(String.init) ?? "—")
InfoRow(label: "平均温度", text: temperature.map { Format.temperature($0.average, fahrenheit: settings.useFahrenheit) } ?? "—")
InfoRow(label: "最高温度", text: temperature.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—")
InfoRow(label: tr("型号"), text: gpu?.name ?? "—")
InfoRow(label: tr("核心数"), text: gpu?.coreCount.map(String.init) ?? "—")
InfoRow(label: tr("平均温度"), text: temperature.map { Format.temperature($0.average, fahrenheit: settings.useFahrenheit) } ?? "—")
InfoRow(label: tr("最高温度"), text: temperature.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—")
}
default:
EmptyView()
@@ -502,12 +507,12 @@ struct ThermalPopover: View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text("CPU 最高").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Text(tr("CPU 最高")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
HeroValue(value: cpu.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—")
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text("风扇").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Text(tr("风扇")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
HeroValue(value: store.fastestFan.map { Int($0.current).formatted() } ?? "—", unit: fans.isEmpty ? nil : "RPM")
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -517,10 +522,10 @@ struct ThermalPopover: View {
ForEach(item.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .thermalSensors:
SectionCard(title: section.title, trailing: { Text("最高 / 平均") }) {
SectionCard(title: section.title, trailing: { Text(tr("最高 / 平均")) }) {
let temperatures = store.sensors?.temperatures ?? []
if temperatures.isEmpty {
Text("正在读取传感器…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("正在读取传感器…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(temperatures) { summary in
VStack(spacing: DS.Space.s1) {
@@ -536,11 +541,11 @@ struct ThermalPopover: View {
case .thermalFans:
SectionCard(title: section.title, trailing: { Text(fanStatusText(fans: fans, mode: model.fans.mode)) }) {
if fans.isEmpty {
Text("未检测到风扇").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("未检测到风扇")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
} else {
ForEach(fans) { fan in
VStack(spacing: DS.Space.s1) {
InfoRow(label: "风扇 \(fan.id + 1)", text: Format.rpm(fan.current))
InfoRow(label: tr("风扇 \(fan.id + 1)"), text: Format.rpm(fan.current))
ProgressTrack(fraction: fan.maximum > 0 ? fan.current / fan.maximum : 0, height: DS.Space.s1)
}
}
@@ -567,9 +572,9 @@ struct ThermalPopover: View {
/// 固件只记录“手动 / 自动”,无法区分是谁设置的:OpenStats 未下发时即为其他程序(如 Stats、Mole)
@MainActor
func fanStatusText(fans: [FanState], mode: FanController.Mode) -> String {
if fans.isEmpty { return "未检测到风扇" }
guard fans.contains(where: \.isManual) else { return "由 macOS 调节" }
return mode == .automatic ? "其他程序手动控制" : "OpenStats 控制中"
if fans.isEmpty { return tr("未检测到风扇") }
guard fans.contains(where: \.isManual) else { return tr("由 macOS 调节") }
return mode == .automatic ? tr("其他程序手动控制") : tr("OpenStats 控制中")
}
/// 功耗明细:整机、电源输入、电池、GPU,附整机功耗走势
@@ -584,19 +589,19 @@ struct PowerRows: View {
LineHistoryChart(values: history, maxValue: max(history.max() ?? 0, 10) * 1.2,
color: DS.Palette.warning, height: compact ? DS.Size.tileChart : DS.Size.chartHeight)
}
if let system = power.system { InfoRow(label: "整机", text: Format.watts(system)) }
if let adapter = power.adapter { InfoRow(label: "电源输入", text: Format.watts(adapter)) }
if let system = power.system { InfoRow(label: tr("整机"), text: Format.watts(system)) }
if let adapter = power.adapter { InfoRow(label: tr("电源输入"), text: Format.watts(adapter)) }
if let battery = power.battery {
// PPBR 为正表示电池在放电供电,接通电源时是充电功率
InfoRow(label: power.adapter == nil ? "电池放电" : "电池充电", text: Format.watts(abs(battery)))
InfoRow(label: power.adapter == nil ? tr("电池放电") : tr("电池充电"), text: Format.watts(abs(battery)))
}
if let gpu = power.gpu { InfoRow(label: "GPU", text: Format.watts(gpu)) }
Text("由 SMC 与系统能耗统计读取;新款芯片不提供可靠的 CPU 单独功耗")
Text(tr("由 SMC 与系统能耗统计读取;新款芯片不提供可靠的 CPU 单独功耗"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("这台 Mac 不提供功耗读数").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(tr("这台 Mac 不提供功耗读数")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
@@ -1,5 +1,6 @@
import AppKit
import HelperShared
import Localization
import Metrics
import SwiftUI
@@ -14,9 +15,9 @@ struct NetworkPopover: View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) {
RateHero(title: "下载", bytesPerSecond: rate?.downloadBytesPerSecond, color: DS.NetworkPalette.download)
RateHero(title: tr("下载"), bytesPerSecond: rate?.downloadBytesPerSecond, color: DS.NetworkPalette.download)
Rectangle().fill(DS.Palette.border).frame(width: DS.Size.stroke, height: DS.Space.s8)
RateHero(title: "上传", bytesPerSecond: rate?.uploadBytesPerSecond, color: DS.NetworkPalette.upload)
RateHero(title: tr("上传"), bytesPerSecond: rate?.uploadBytesPerSecond, color: DS.NetworkPalette.upload)
}
}
@@ -66,7 +67,7 @@ private struct TrafficHistorySection: View {
// 峰值放在标题行,图表上不压任何文字
SectionCard(title: PopoverSection.networkHistory.title, trailing: {
HStack(spacing: DS.Space.s1) {
Text("60 秒峰值")
Text(tr("60 秒峰值"))
peak("↑", upload, color: DS.NetworkPalette.upload)
peak("↓", download, color: DS.NetworkPalette.download)
}
@@ -98,22 +99,22 @@ private struct ProbeSection: View {
SectionCard(title: PopoverSection.networkProbe.title, trailing: {
if settings.probeEnabled {
Text(verbatim: "\(network.probeAddress ?? "—") · 每 \(settings.probeSeconds) 秒")
Text(verbatim: tr("\(network.probeAddress ?? "—") · 每 \(settings.probeSeconds) 秒"))
}
}) {
if settings.probeEnabled {
ProbeGrid(samples: network.probes.elements, columns: isDetailPage ? 40 : 20, rows: 3)
HStack(spacing: DS.Space.s3) {
stat("延迟", network.latency.map(milliseconds) ?? "—")
stat("抖动", network.jitter.map(milliseconds) ?? "—")
stat("丢包", network.lossRate.map { Format.percent($0) } ?? "—",
stat(tr("延迟"), network.latency.map(milliseconds) ?? "—")
stat(tr("抖动"), network.jitter.map(milliseconds) ?? "—")
stat(tr("丢包"), network.lossRate.map { Format.percent($0) } ?? "—",
tone: (network.lossRate ?? 0) > 0.05 ? .error : .neutral)
}
} else {
HStack {
Text("定时 ping 一个地址,记录网络是否通畅").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Text(tr("定时 ping 一个地址,记录网络是否通畅")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer(minLength: DS.Space.s2)
Button("开启") { settings.probeEnabled = true }
Button(tr("开启")) { settings.probeEnabled = true }
.buttonStyle(DSButtonStyle(kind: .ghost))
}
}
@@ -148,31 +149,31 @@ private struct InterfaceSection: View {
SectionCard(title: PopoverSection.networkInterface.title) {
if let physical {
InfoRow(label: "接口", text: "\(physical.displayName)(\(physical.bsdName))")
InfoRow(label: "状态") {
StatusBadge(text: physical.isUp ? "已连接" : "未连接", tone: physical.isUp ? .success : .error)
InfoRow(label: tr("接口"), text: tr("\(physical.displayName)(\(physical.bsdName))"))
InfoRow(label: tr("状态")) {
StatusBadge(text: physical.isUp ? tr("已连接") : tr("未连接"), tone: physical.isUp ? .success : .error)
}
InfoRow(label: "物理地址") { CopyableText(text: physical.hardwareAddress ?? "—") }
InfoRow(label: tr("物理地址")) { CopyableText(text: physical.hardwareAddress ?? "—") }
if let wifi = physical.wifi {
if let ssid = wifi.ssid { InfoRow(label: "网络名称", text: ssid) }
InfoRow(label: "信号强度", text: "\(wifi.rssi) dBm · \(signalQuality(wifi.rssi))")
InfoRow(label: "传输速率", text: "\(Int(wifi.transmitRate)) Mbps")
if let ssid = wifi.ssid { InfoRow(label: tr("网络名称"), text: ssid) }
InfoRow(label: tr("信号强度"), text: "\(wifi.rssi) dBm · \(signalQuality(wifi.rssi))")
InfoRow(label: tr("传输速率"), text: "\(Int(wifi.transmitRate)) Mbps")
}
} else {
Text(details == nil ? "正在读取…" : "未连接网络").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(details == nil ? tr("正在读取…") : tr("未连接网络")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
if let tunnel = details?.tunnel {
InfoRow(label: "VPN / 代理", text: "\(tunnel.name)(\(tunnel.bsdName))")
InfoRow(label: tr("VPN / 代理"), text: tr("\(tunnel.name)(\(tunnel.bsdName))"))
}
if let totals {
InfoRow(label: "开机后下载", text: Format.bytes(totals.totalDownloaded, base: .decimal))
InfoRow(label: "开机后上传", text: Format.bytes(totals.totalUploaded, base: .decimal))
InfoRow(label: tr("开机后下载"), text: Format.bytes(totals.totalDownloaded, base: .decimal))
InfoRow(label: tr("开机后上传"), text: Format.bytes(totals.totalUploaded, base: .decimal))
}
}
}
private func signalQuality(_ rssi: Int) -> String {
rssi >= -50 ? "极好" : rssi >= -60 ? "良好" : rssi >= -70 ? "一般" : "较弱"
rssi >= -50 ? tr("极好") : rssi >= -60 ? tr("良好") : rssi >= -70 ? tr("一般") : tr("较弱")
}
}
@@ -189,20 +190,20 @@ private struct AddressSection: View {
SectionCard(title: PopoverSection.networkAddresses.title, trailing: {
if lookup {
MiniIconButton(systemName: "arrow.clockwise", help: "重新查询公网 IP") {
MiniIconButton(systemName: "arrow.clockwise", help: tr("重新查询公网 IP")) {
network.lookUpPublicAddresses()
}
.disabled(network.isLookingUpPublic)
}
}) {
InfoRow(label: "本地 IPv4") { CopyableText(text: physical?.ipv4.first ?? "—") }
InfoRow(label: "本地 IPv6") { CopyableText(text: physical?.ipv6.first ?? "—") }
InfoRow(label: "路由器") { CopyableText(text: physical?.router ?? "—") }
InfoRow(label: tr("本地 IPv4")) { CopyableText(text: physical?.ipv4.first ?? "—") }
InfoRow(label: tr("本地 IPv6")) { CopyableText(text: physical?.ipv6.first ?? "—") }
InfoRow(label: tr("路由器")) { CopyableText(text: physical?.router ?? "—") }
if lookup {
InfoRow(label: "公网 IPv4") { publicValue(publicAddresses?.ipv4, loading: network.isLookingUpPublic) }
InfoRow(label: "公网 IPv6") { publicValue(publicAddresses?.ipv6, loading: network.isLookingUpPublic) }
InfoRow(label: tr("公网 IPv4")) { publicValue(publicAddresses?.ipv4, loading: network.isLookingUpPublic) }
InfoRow(label: tr("公网 IPv6")) { publicValue(publicAddresses?.ipv6, loading: network.isLookingUpPublic) }
if let publicAddresses, let code = publicAddresses.countryCode {
InfoRow(label: "归属地") {
InfoRow(label: tr("归属地")) {
HStack(spacing: DS.Space.s2) {
FlagImage(countryCode: code)
Text(verbatim: location(publicAddresses, code: code))
@@ -215,13 +216,13 @@ private struct AddressSection: View {
InfoRow(label: "ASN") { CopyableText(text: asn) }
}
if let organization = publicAddresses?.organization {
InfoRow(label: "网络运营方") { Text(verbatim: organization).lineLimit(1).truncationMode(.middle) }
InfoRow(label: tr("网络运营方")) { Text(verbatim: organization).lineLimit(1).truncationMode(.middle) }
}
if let publicAddresses, publicAddresses.countryCode != nil || publicAddresses.asn != nil {
InfoRow(label: "数据来源", text: publicAddresses.source == .localDatabase ? "本地 GeoLite2(MaxMind)" : "ipinfo.io 在线查询")
InfoRow(label: tr("数据来源"), text: publicAddresses.source == .localDatabase ? tr("本地 GeoLite2(MaxMind)") : tr("ipinfo.io 在线查询"))
}
} else {
InfoRow(label: "公网 IP", text: "查询已关闭")
InfoRow(label: tr("公网 IP"), text: tr("查询已关闭"))
}
}
}
@@ -229,14 +230,14 @@ private struct AddressSection: View {
@ViewBuilder
private func publicValue(_ value: String?, loading: Bool) -> some View {
if loading && value == nil {
Text("查询中…").foregroundStyle(DS.Palette.textTertiary)
Text(tr("查询中…")).foregroundStyle(DS.Palette.textTertiary)
} else {
CopyableText(text: value ?? "—")
}
}
private func location(_ addresses: PublicAddresses, code: String) -> String {
let country = Locale(identifier: "zh-Hans").localizedString(forRegionCode: code) ?? code
let country = Locale(identifier: L10n.isEnglish ? "en" : "zh-Hans").localizedString(forRegionCode: code) ?? code
return [country, addresses.city].compactMap { $0 }.joined(separator: " · ")
}
}
@@ -258,13 +259,13 @@ private struct DNSSection: View {
SectionCard(title: PopoverSection.networkDNS.title, trailing: {
if let physical { Text(verbatim: physical.serviceName) }
}) {
InfoRow(label: "正在使用") {
InfoRow(label: tr("正在使用")) {
Text(verbatim: network.details.map { $0.dnsServers.isEmpty ? "—" : $0.dnsServers.joined(separator: "\n") } ?? "—")
}
InfoRow(label: "配置方式", text: manual.isEmpty ? "自动(由路由器分配)" : matched?.title ?? "手动")
InfoRow(label: tr("配置方式"), text: manual.isEmpty ? tr("自动(由路由器分配)") : matched?.title ?? tr("手动"))
if let tunnel = network.details?.tunnel {
InfoBanner(icon: "info.circle", text: "流量经过 \(tunnel.name),系统 DNS 可能由它接管,修改后不一定生效。", tone: .neutral)
InfoBanner(icon: "info.circle", text: tr("流量经过 \(tunnel.name),系统 DNS 可能由它接管,修改后不一定生效。"), tone: .neutral)
}
if let physical {
@@ -275,7 +276,7 @@ private struct DNSSection: View {
apply(preset.servers, service: physical.serviceName)
}
}
ChipButton(title: "手动", isSelected: editingManual || (!manual.isEmpty && matched == nil)) {
ChipButton(title: tr("手动"), isSelected: editingManual || (!manual.isEmpty && matched == nil)) {
manualText = manual.joined(separator: ", ")
editingManual.toggle()
}
@@ -284,7 +285,7 @@ private struct DNSSection: View {
if editingManual {
HStack(spacing: DS.Space.s2) {
TextField("例如 1.1.1.1, 8.8.8.8", text: $manualText)
TextField(tr("例如 1.1.1.1, 8.8.8.8"), text: $manualText)
.textFieldStyle(.plain)
.dsFont(.xs)
.padding(.horizontal, DS.Space.s2)
@@ -292,7 +293,7 @@ private struct DNSSection: View {
.background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.neutral300, lineWidth: DS.Size.stroke))
.onSubmit { applyManual(service: physical.serviceName) }
Button("应用") { applyManual(service: physical.serviceName) }
Button(tr("应用")) { applyManual(service: physical.serviceName) }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(DNSConfiguration.parse(manualText)?.isEmpty != false)
}
@@ -300,7 +301,7 @@ private struct DNSSection: View {
}
HStack(spacing: DS.Space.s2) {
Button(maintenance.running == .flushDNS ? "正在刷新…" : "刷新 DNS 缓存") {
Button(maintenance.running == .flushDNS ? tr("正在刷新…") : tr("刷新 DNS 缓存")) {
Task { await maintenance.run(.flushDNS) }
}
.buttonStyle(DSButtonStyle(kind: .secondary))
@@ -309,7 +310,7 @@ private struct DNSSection: View {
}
if maintenance.isApplyingDNS {
Text("正在修改 DNS…").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Text(tr("正在修改 DNS…")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
} else if let outcome = maintenance.dnsOutcome ?? maintenance.outcomes[.flushDNS] {
Text(outcome.text)
.dsFont(.xs)
@@ -343,11 +344,11 @@ private struct NetworkProcessesSection: View {
SectionCard(title: PopoverSection.networkProcesses.title, trailing: {
HStack(spacing: 0) {
Text("下载").frame(width: DS.Size.valueColumn, alignment: .trailing)
Text("上传").frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(tr("下载")).frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(tr("上传")).frame(width: DS.Size.valueColumn, alignment: .trailing)
}
}) {
ProcessList(count: processes.count, rowCount: 8, emptyText: "正在统计各进程流量…") { index in
ProcessList(count: processes.count, rowCount: 8, emptyText: tr("正在统计各进程流量…")) { index in
let process = processes[index]
HStack(spacing: 0) {
ProcessNameLabel(icon: AppIconCache.shared.image(bundlePath: process.appBundlePath), name: process.localizedName)
@@ -1,4 +1,5 @@
import AppKit
import Localization
import Metrics
import SwiftUI
@@ -50,8 +51,8 @@ private struct PopoverHeader: View {
.foregroundStyle(DS.Palette.textPrimary)
Spacer(minLength: DS.Space.s2)
if item == .memory { PurgeMemoryButton() }
IconButton(systemName: "macwindow", help: "打开 OpenStats 主窗口") { model.openMainWindow(nil) }
IconButton(systemName: "gearshape", help: "设置") { model.openSettings() }
IconButton(systemName: "macwindow", help: tr("打开 OpenStats 主窗口")) { model.openMainWindow(nil) }
IconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() }
}
}
}
@@ -131,7 +132,7 @@ struct CopyableText: View {
copied = false
}
} label: {
Text(verbatim: copied ? "已拷贝" : text)
Text(verbatim: copied ? tr("已拷贝") : text)
.foregroundStyle(copied ? DS.Palette.success : hovering ? DS.Palette.primary : DS.Palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
@@ -139,7 +140,7 @@ struct CopyableText: View {
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.help("点击拷贝")
.help(tr("点击拷贝"))
.disabled(text == "—")
}
}
@@ -191,7 +192,7 @@ struct HeroValue: View {
struct ProcessList<Row: View>: View {
let count: Int
var rowCount = 5
var emptyText = "正在统计…"
var emptyText = tr("正在统计…")
@ViewBuilder var row: (Int) -> Row
var body: some View {
@@ -1,5 +1,6 @@
import AppKit
import HelperShared
import Localization
import Metrics
import SwiftUI
@@ -59,28 +60,43 @@ struct GeneralSettings: View {
SettingsGroup {
GroupRow(showsDivider: false) {
SettingRow(title: "外观", subtitle: "主窗口与弹窗的配色;菜单栏始终跟随系统") {
SettingRow(title: tr("外观"), subtitle: tr("主窗口与弹窗的配色;菜单栏始终跟随系统")) {
SegmentedControl(selection: $settings.appearance,
options: AppearanceMode.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
}
}
GroupRow {
SettingRow(title: "登录时启动", subtitle: model.launchAtLoginError ?? "开机后自动在菜单栏显示 OpenStats") {
SettingRow(title: tr("登录时启动"), subtitle: model.launchAtLoginError ?? tr("开机后自动在菜单栏显示 OpenStats")) {
DSToggle(isOn: Binding(get: { model.launchAtLoginEnabled },
set: { model.setLaunchAtLogin($0) }),
label: "登录时启动")
label: tr("登录时启动"))
}
}
GroupRow {
SettingRow(title: "刷新频率", subtitle: "只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒)") {
SettingRow(title: tr("刷新频率"), subtitle: tr("只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒)")) {
SegmentedControl(selection: $settings.refreshSeconds,
options: AppSettings.refreshOptions.map { ($0, "\($0) 秒") })
options: AppSettings.refreshOptions.map { ($0, tr("\($0) 秒")) })
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
}
}
GroupRow {
SettingRow(title: "温度单位") {
SettingRow(title: "语言 / Language",
subtitle: settings.language.resolved == L10n.language ? nil : tr("重新启动 OpenStats 后生效")) {
HStack(spacing: DS.Space.s2) {
if settings.language.resolved != L10n.language {
Button(tr("立即重启")) { model.relaunch() }
.buttonStyle(DSButtonStyle(kind: .primary))
.fixedSize()
}
SegmentedControl(selection: $settings.language,
options: AppLanguage.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
}
}
}
GroupRow {
SettingRow(title: tr("温度单位")) {
SegmentedControl(selection: $settings.useFahrenheit, options: [(false, "°C"), (true, "°F")])
.frame(width: DS.Size.sidebarWidth / 2 + DS.Space.s6)
}
@@ -101,13 +117,13 @@ struct NotificationSettings: View {
let alerts = model.alerts
if alerts.authorization == .denied {
InfoBanner(icon: "bell.slash", text: "OpenStats 的通知被关闭了,打开的提醒不会显示。", tone: .warning) {
Button("打开通知设置") { alerts.openNotificationSettings() }
InfoBanner(icon: "bell.slash", text: tr("OpenStats 的通知被关闭了,打开的提醒不会显示。"), tone: .warning) {
Button(tr("打开通知设置")) { alerts.openNotificationSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
}
SettingsGroup(caption: "发生这些状况时发送系统通知,点通知打开对应页面") {
SettingsGroup(caption: tr("发生这些状况时发送系统通知,点通知打开对应页面")) {
ForEach(Array(AlertKind.allCases.enumerated()), id: \.element) { index, kind in
GroupRow(showsDivider: index > 0) {
SettingRow(title: kind.title, subtitle: kind.detail, icon: kind.symbol) {
@@ -120,7 +136,7 @@ struct NotificationSettings: View {
}
if kind == .cpuTemperature, settings.enabledAlerts.contains(.cpuTemperature) {
GroupRow {
SettingRow(title: "过热温度") {
SettingRow(title: tr("过热温度")) {
SegmentedControl(selection: $settings.alertCPUTemperature,
options: AppSettings.alertTemperatureOptions.map {
($0, Format.temperature(Double($0), fahrenheit: settings.useFahrenheit))
@@ -134,8 +150,8 @@ struct NotificationSettings: View {
SettingsGroup {
GroupRow(showsDivider: false) {
SettingRow(title: "发送测试通知", subtitle: alerts.authorization == .allowed ? "确认通知能正常显示" : "首次发送时系统会询问是否允许通知") {
Button("发送") { alerts.sendTest() }
SettingRow(title: tr("发送测试通知"), subtitle: alerts.authorization == .allowed ? tr("确认通知能正常显示") : tr("首次发送时系统会询问是否允许通知")) {
Button(tr("发送")) { alerts.sendTest() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
@@ -152,11 +168,11 @@ struct DiagnosticsSettings: View {
var body: some View {
let diagnostics = model.diagnostics
SettingsGroup(caption: "反馈问题") {
SettingsGroup(caption: tr("反馈问题")) {
GroupRow(showsDivider: false) {
SettingRow(title: "导出诊断信息",
subtitle: "打包版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志和崩溃报告,反馈问题时附上;会去掉序列号、IP 与硬件地址") {
Button(diagnostics.phase == .collecting ? "正在导出…" : "导出…") { diagnostics.export(model: model) }
SettingRow(title: tr("导出诊断信息"),
subtitle: tr("打包版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志和崩溃报告,反馈问题时附上;会去掉序列号、IP 与硬件地址")) {
Button(diagnostics.phase == .collecting ? tr("正在导出…") : tr("导出…")) { diagnostics.export(model: model) }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(diagnostics.phase == .collecting)
}
@@ -164,8 +180,8 @@ struct DiagnosticsSettings: View {
switch diagnostics.phase {
case .finished(let url):
GroupRow {
InfoBanner(icon: "checkmark.circle.fill", text: "已导出 \(url.lastPathComponent)", tone: .success) {
Button("在访达中显示") { diagnostics.reveal() }
InfoBanner(icon: "checkmark.circle.fill", text: tr("已导出 \(url.lastPathComponent)"), tone: .success) {
Button(tr("在访达中显示")) { diagnostics.reveal() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
@@ -185,11 +201,11 @@ struct HotKeySettings: View {
var body: some View {
let settings = model.settings
SettingsGroup(caption: "全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃)") {
SettingsGroup(caption: tr("全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃)")) {
ForEach(Array(HotKeyAction.allCases.enumerated()), id: \.element) { index, action in
GroupRow(showsDivider: index > 0) {
SettingRow(title: action.title,
subtitle: model.hotKeyConflicts.contains(action) ? "这个快捷键已被其他应用或系统占用,请换一个" : nil) {
subtitle: model.hotKeyConflicts.contains(action) ? tr("这个快捷键已被其他应用或系统占用,请换一个") : nil) {
ShortcutRecorder(hotKey: Binding(get: { settings.hotKeys[action] },
set: { settings.hotKeys[action] = $0 }))
}
@@ -210,14 +226,14 @@ struct ShortcutRecorder: View {
Button {
recording ? stop() : start()
} label: {
Text(verbatim: recording ? "按下快捷键…" : hotKey?.display ?? "录制快捷键")
Text(verbatim: recording ? tr("按下快捷键…") : hotKey?.display ?? tr("录制快捷键"))
.dsFont(.sm, weight: hotKey == nil || recording ? .regular : .semibold)
.monospacedDigit()
.frame(minWidth: DS.Space.s16 + DS.Space.s12)
}
.buttonStyle(DSButtonStyle(kind: recording ? .primary : .secondary))
if hotKey != nil && !recording {
MiniIconButton(systemName: "xmark.circle.fill", help: "清除快捷键") { hotKey = nil }
MiniIconButton(systemName: "xmark.circle.fill", help: tr("清除快捷键")) { hotKey = nil }
}
}
.onDisappear { stop() }
@@ -260,12 +276,12 @@ struct MenuBarSettings: View {
MenuBarPreview()
SettingsGroup(caption: "布局") {
SettingsGroup(caption: tr("布局")) {
GroupRow(showsDivider: false) {
SettingRow(title: "菜单栏图标",
SettingRow(title: tr("菜单栏图标"),
subtitle: settings.menuBarLayout == .separate
? "每个指标一个图标,点击弹出该项详情"
: "所有指标合成一个图标,点击打开主窗口") {
? tr("每个指标一个图标,点击弹出该项详情")
: tr("所有指标合成一个图标,点击打开主窗口")) {
SegmentedControl(selection: $settings.menuBarLayout,
options: MenuBarLayout.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
@@ -273,7 +289,7 @@ struct MenuBarSettings: View {
}
}
SettingsGroup(caption: "风格") {
SettingsGroup(caption: tr("风格")) {
LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible(), spacing: DS.Space.s3)],
spacing: DS.Space.s3) {
ForEach(MenuBarStyle.allCases) { style in
@@ -285,7 +301,7 @@ struct MenuBarSettings: View {
.padding(DS.Space.s3)
}
SettingsGroup(caption: "显示项目") {
SettingsGroup(caption: tr("显示项目")) {
ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in
GroupRow(showsDivider: index > 0) {
VStack(alignment: .leading, spacing: DS.Space.s3) {
@@ -307,10 +323,10 @@ struct MenuBarSettings: View {
}
}
SettingsGroup(caption: "其他") {
SettingsGroup(caption: tr("其他")) {
GroupRow(showsDivider: false) {
SettingRow(title: "高负载时着色", subtitle: "占用超过 85% 时数值与图形显示为红色") {
DSToggle(isOn: $settings.colorizeHighLoad, label: "高负载时着色")
SettingRow(title: tr("高负载时着色"), subtitle: tr("占用超过 85% 时数值与图形显示为红色")) {
DSToggle(isOn: $settings.colorizeHighLoad, label: tr("高负载时着色"))
}
}
}
@@ -325,9 +341,9 @@ struct MenuBarSettings: View {
options: NetworkMenuStyle.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
} else {
Picker("风格", selection: Binding(get: { settings.styleOverrides[item] },
Picker(tr("风格"), selection: Binding(get: { settings.styleOverrides[item] },
set: { settings.setStyleOverride($0, for: item) })) {
Text("跟随整体").tag(MenuBarStyle?.none)
Text(tr("跟随整体")).tag(MenuBarStyle?.none)
Divider()
ForEach(MenuBarStyle.allCases) { style in
Text(style.title).tag(MenuBarStyle?.some(style))
@@ -348,7 +364,7 @@ private struct PopoverSectionPicker: View {
var body: some View {
let settings = model.settings
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s3) {
Text("弹窗显示")
Text(tr("弹窗显示"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.fixedSize()
@@ -453,7 +469,7 @@ private struct MenuBarPreview: View {
var body: some View {
let image = MenuBarRenderer.image(for: model)
VStack(alignment: .leading, spacing: DS.Space.s2) {
Text("当前效果(实时数据)").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
Text(tr("当前效果(实时数据)")).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
HStack(spacing: DS.Space.s3) {
preview(image: image, dark: false)
preview(image: image, dark: true)
@@ -470,7 +486,7 @@ private struct MenuBarPreview: View {
.frame(height: DS.Size.controlHeight + DS.Space.s2)
.background(dark ? Color(nsColor: NSColor(hex: 0x1F2937)) : Color(nsColor: NSColor(hex: 0xE5E7EB)),
in: RoundedRectangle(cornerRadius: DS.Radius.md))
.accessibilityLabel(dark ? "深色菜单栏预览" : "浅色菜单栏预览")
.accessibilityLabel(dark ? tr("深色菜单栏预览") : tr("浅色菜单栏预览"))
}
}
@@ -482,23 +498,23 @@ struct NetworkSettings: View {
var body: some View {
@Bindable var settings = model.settings
SettingsGroup(caption: "连接探测") {
SettingsGroup(caption: tr("连接探测")) {
GroupRow(showsDivider: false) {
SettingRow(title: "定时探测网络", subtitle: "用 ping 测量延迟与丢包,在网络详情里以格子显示") {
DSToggle(isOn: $settings.probeEnabled, label: "定时探测网络")
SettingRow(title: tr("定时探测网络"), subtitle: tr("用 ping 测量延迟与丢包,在网络详情里以格子显示")) {
DSToggle(isOn: $settings.probeEnabled, label: tr("定时探测网络"))
}
}
GroupRow {
SettingRow(title: "探测间隔", subtitle: "打开网络详情时使用") {
SettingRow(title: tr("探测间隔"), subtitle: tr("打开网络详情时使用")) {
SegmentedControl(selection: $settings.probeSeconds,
options: AppSettings.probeOptions.map { ($0, "\($0) 秒") })
options: AppSettings.probeOptions.map { ($0, tr("\($0) 秒")) })
.frame(width: DS.Size.sidebarWidth)
}
}
.disabled(!settings.probeEnabled)
GroupRow {
SettingRow(title: "探测目标", subtitle: "国内网络建议选阿里云或腾讯;选路由器只检测本地连接") {
Picker("探测目标", selection: $settings.probeTarget) {
SettingRow(title: tr("探测目标"), subtitle: tr("国内网络建议选阿里云或腾讯;选路由器只检测本地连接")) {
Picker(tr("探测目标"), selection: $settings.probeTarget) {
ForEach(ProbeTarget.allCases) { target in
Text(target.title).tag(target)
}
@@ -510,28 +526,28 @@ struct NetworkSettings: View {
}
.disabled(!settings.probeEnabled)
GroupRow {
SettingRow(title: "后台低频探测",
subtitle: "详情关闭时,菜单栏显示网络项期间每 \(NetworkController.backgroundProbeSeconds) 秒探测一次,打开详情就能看到最近的连接情况;关闭后只在打开详情时探测,更省电") {
DSToggle(isOn: $settings.probeInBackground, label: "后台低频探测")
SettingRow(title: tr("后台低频探测"),
subtitle: tr("详情关闭时,菜单栏显示网络项期间每 \(NetworkController.backgroundProbeSeconds) 秒探测一次,打开详情就能看到最近的连接情况;关闭后只在打开详情时探测,更省电")) {
DSToggle(isOn: $settings.probeInBackground, label: tr("后台低频探测"))
}
}
.disabled(!settings.probeEnabled)
}
SettingsGroup(caption: "公网 IP") {
SettingsGroup(caption: tr("公网 IP")) {
GroupRow(showsDivider: false) {
SettingRow(title: "查询公网 IP",
SettingRow(title: tr("查询公网 IP"),
subtitle: model.geo.isAvailable
? "打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求"
: "打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询") {
DSToggle(isOn: $settings.publicIPLookup, label: "查询公网 IP")
? tr("打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求")
: tr("打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询")) {
DSToggle(isOn: $settings.publicIPLookup, label: tr("查询公网 IP"))
}
}
}
GeoDatabaseSettings()
InfoBanner(icon: "lock.shield", text: "修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。", tone: .neutral)
InfoBanner(icon: "lock.shield", text: tr("修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。"), tone: .neutral)
}
}
@@ -543,43 +559,43 @@ private struct GeoDatabaseSettings: View {
@Bindable var settings = model.settings
let geo = model.geo
SettingsGroup(caption: "IP 归属地数据库") {
SettingsGroup(caption: tr("IP 归属地数据库")) {
GroupRow(showsDivider: false) {
SettingRow(title: geo.isAvailable ? "本地 MaxMind GeoLite2" : "尚未下载",
SettingRow(title: geo.isAvailable ? tr("本地 MaxMind GeoLite2") : tr("尚未下载"),
subtitle: geo.isAvailable
? "国家、城市与 ASN 在本机查询,不经过任何在线服务"
: "下载后归属地查询完全离线;数据库由 OpenStats 官网每周同步 MaxMind 的最新版本") {
StatusBadge(text: geo.isAvailable ? "离线查询" : "在线查询", tone: geo.isAvailable ? .success : .neutral)
? tr("国家、城市与 ASN 在本机查询,不经过任何在线服务")
: tr("下载后归属地查询完全离线;数据库由 OpenStats 官网每周同步 MaxMind 的最新版本")) {
StatusBadge(text: geo.isAvailable ? tr("离线查询") : tr("在线查询"), tone: geo.isAvailable ? .success : .neutral)
}
}
ForEach(geo.installed.values.sorted { $0.edition < $1.edition }, id: \GeoDatabaseController.Installed.edition) { database in
GroupRow {
SettingRow(title: title(database.edition), subtitle: "版本 \(database.build) · \(Format.bytes(UInt64(database.size), base: .decimal))") {
SettingRow(title: title(database.edition), subtitle: tr("版本 \(database.build) · \(Format.bytes(UInt64(database.size), base: .decimal))")) {
Text(database.edition).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
GroupRow {
SettingRow(title: "包含城市数据", subtitle: "显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB)") {
DSToggle(isOn: $settings.geoIncludeCity, label: "包含城市数据")
SettingRow(title: tr("包含城市数据"), subtitle: tr("显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB)")) {
DSToggle(isOn: $settings.geoIncludeCity, label: tr("包含城市数据"))
}
}
GroupRow {
SettingRow(title: "自动更新", subtitle: geo.lastChecked.map { "每 3 天检查一次 · 上次检查 \($0.formatted(date: .abbreviated, time: .shortened))" } ?? "每 3 天检查一次") {
DSToggle(isOn: $settings.geoAutoUpdate, label: "自动更新")
SettingRow(title: tr("自动更新"), subtitle: geo.lastChecked.map { tr("每 3 天检查一次 · 上次检查 \($0.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))") } ?? tr("每 3 天检查一次")) {
DSToggle(isOn: $settings.geoAutoUpdate, label: tr("自动更新"))
}
}
GroupRow {
HStack(spacing: DS.Space.s2) {
Button(geo.isUpdating ? "正在更新…" : geo.isAvailable ? "立即检查更新" : "下载数据库") {
Button(geo.isUpdating ? tr("正在更新…") : geo.isAvailable ? tr("立即检查更新") : tr("下载数据库")) {
Task { await geo.update() }
}
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(geo.isUpdating)
Button("从文件导入…") { importFile() }
Button(tr("从文件导入…")) { importFile() }
.buttonStyle(DSButtonStyle(kind: .secondary))
if geo.isAvailable {
Button("在访达中显示") { NSWorkspace.shared.activateFileViewerSelecting([geo.directory]) }
Button(tr("在访达中显示")) { NSWorkspace.shared.activateFileViewerSelecting([geo.directory]) }
.buttonStyle(DSButtonStyle(kind: .ghost))
}
Spacer()
@@ -598,9 +614,9 @@ private struct GeoDatabaseSettings: View {
}
private func title(_ edition: String) -> String {
if edition.hasSuffix("-City") { return "城市库" }
if edition.hasSuffix("-Country") { return "国家库" }
if edition.hasSuffix("-ASN") { return "ASN 库" }
if edition.hasSuffix("-City") { return tr("城市库") }
if edition.hasSuffix("-Country") { return tr("国家库") }
if edition.hasSuffix("-ASN") { return tr("ASN 库") }
return edition
}
@@ -608,7 +624,7 @@ private struct GeoDatabaseSettings: View {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.init(filenameExtension: "mmdb") ?? .data]
panel.allowsMultipleSelection = true
panel.message = "选择 GeoLite2 City / Country / ASN 的 .mmdb 文件"
panel.message = tr("选择 GeoLite2 City / Country / ASN 的 .mmdb 文件")
guard panel.runModal() == .OK else { return }
for url in panel.urls { model.geo.importDatabase(from: url) }
}
@@ -623,9 +639,9 @@ struct FanSafetySettings: View {
var body: some View {
@Bindable var settings = model.settings
SettingsGroup(caption: "风扇设置") {
SettingsGroup(caption: tr("风扇设置")) {
GroupRow(showsDivider: false) {
SettingRow(title: "安全温度", subtitle: "自定义转速时,CPU 达到该温度自动恢复系统控制") {
SettingRow(title: tr("安全温度"), subtitle: tr("自定义转速时,CPU 达到该温度自动恢复系统控制")) {
SegmentedControl(selection: $settings.fanSafetyTemperature,
options: AppSettings.fanSafetyOptions.map { ($0, "\($0)°C") })
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
@@ -642,9 +658,9 @@ struct LidBatterySettings: View {
var body: some View {
@Bindable var settings = model.settings
SettingsGroup(caption: "合盖运行设置") {
SettingsGroup(caption: tr("合盖运行设置")) {
GroupRow(showsDivider: false) {
SettingRow(title: "电量下限", subtitle: "使用电池且电量低于该值时,自动关闭合盖运行") {
SettingRow(title: tr("电量下限"), subtitle: tr("使用电池且电量低于该值时,自动关闭合盖运行")) {
SegmentedControl(selection: $settings.lidModeBatteryFloor,
options: AppSettings.batteryFloorOptions.map { ($0, "\($0)%") })
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
@@ -664,37 +680,37 @@ struct HelperSettings: View {
SettingsGroup {
GroupRow(showsDivider: false) {
SettingRow(title: "OpenStats 辅助工具", subtitle: "以系统权限运行的后台服务,只接受本应用的请求", icon: "lock.shield") {
SettingRow(title: tr("OpenStats 辅助工具"), subtitle: tr("以系统权限运行的后台服务,只接受本应用的请求"), icon: "lock.shield") {
StatusBadge(text: helper.status.title, tone: tone(helper.status))
}
}
GroupRow {
VStack(alignment: .leading, spacing: DS.Space.s2) {
Text("它只做这几件事").dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
CapabilityLine(text: "设置风扇目标转速,或恢复系统自动控制")
CapabilityLine(text: "开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep)")
CapabilityLine(text: "刷新 DNS 缓存、释放内存、为网络服务设置 DNS 服务器")
CapabilityLine(text: "应用退出或断开连接时,自动恢复风扇与睡眠设置")
Text(tr("它只做这几件事")).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
CapabilityLine(text: tr("设置风扇目标转速,或恢复系统自动控制"))
CapabilityLine(text: tr("开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep)"))
CapabilityLine(text: tr("刷新 DNS 缓存、释放内存、为网络服务设置 DNS 服务器"))
CapabilityLine(text: tr("应用退出或断开连接时,自动恢复风扇与睡眠设置"))
}
}
GroupRow {
HStack(spacing: DS.Space.s2) {
switch helper.status {
case .notInstalled:
Button("安装辅助工具") { helper.install() }
Button(tr("安装辅助工具")) { helper.install() }
.buttonStyle(DSButtonStyle(kind: .primary))
case .requiresApproval:
Button("打开登录项设置") { helper.openLoginItemsSettings() }
Button(tr("打开登录项设置")) { helper.openLoginItemsSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
Button("卸载") { Task { await helper.uninstall() } }
Button(tr("卸载")) { Task { await helper.uninstall() } }
.buttonStyle(DSButtonStyle(kind: .secondary))
case .enabled:
Button("卸载辅助工具") { Task { await helper.uninstall() } }
Button(tr("卸载辅助工具")) { Task { await helper.uninstall() } }
.buttonStyle(DSButtonStyle(kind: .secondary))
case .unavailable:
EmptyView()
}
Button("刷新状态") { helper.refreshStatus() }
Button(tr("刷新状态")) { helper.refreshStatus() }
.buttonStyle(DSButtonStyle(kind: .ghost))
Spacer()
}
@@ -707,7 +723,7 @@ struct HelperSettings: View {
}
if helper.isReady && helper.isOutdated {
InfoBanner(icon: "arrow.triangle.2.circlepath", text: HelperClient.outdatedMessage, tone: .warning) {
Button("重新安装") { Task { await helper.reinstall() } }
Button(tr("重新安装")) { Task { await helper.reinstall() } }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(helper.isWorking)
}
@@ -716,9 +732,9 @@ struct HelperSettings: View {
InfoBanner(icon: "exclamationmark.triangle.fill", text: error, tone: .error)
}
if let team = CodeSigningInfo.currentTeamIdentifier() {
InfoBanner(icon: "checkmark.seal", text: "已使用 Developer ID 签名(团队 \(team)),辅助工具只接受同一团队签名的 OpenStats。", tone: .success)
InfoBanner(icon: "checkmark.seal", text: tr("已使用 Developer ID 签名(团队 \(team)),辅助工具只接受同一团队签名的 OpenStats。"), tone: .success)
} else {
InfoBanner(icon: "info.circle", text: "当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。", tone: .neutral)
InfoBanner(icon: "info.circle", text: tr("当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。"), tone: .neutral)
}
}
@@ -749,7 +765,7 @@ private struct CapabilityLine: View {
struct AboutSettings: View {
var body: some View {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "开发版"
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? tr("开发版")
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
SettingsGroup {
@@ -758,7 +774,7 @@ struct AboutSettings: View {
AppGlyph(size: DS.Space.s12)
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text("OpenStats").dsFont(.lg, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: "版本 \(version)\(build.map { "(\($0))" } ?? "")")
Text(verbatim: tr("版本 \(version)\(build.map { tr("(\($0))") } ?? "")"))
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
}
@@ -766,7 +782,7 @@ struct AboutSettings: View {
}
}
GroupRow {
Text("轻量的 macOS 菜单栏系统监控:CPU、GPU、内存、网络、温度、风扇、防休眠与清理。")
Text(tr("轻量的 macOS 菜单栏系统监控:CPU、GPU、内存、网络、温度、风扇、防休眠与清理。"))
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
}
@@ -775,10 +791,10 @@ struct AboutSettings: View {
UpdateSettings()
DiagnosticsSettings()
SettingsGroup(caption: "致谢") {
SettingsGroup(caption: tr("致谢")) {
GroupRow(showsDivider: false) {
SettingRow(title: "exelban/stats", subtitle: "SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License") {
Button("查看") {
SettingRow(title: "exelban/stats", subtitle: tr("SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License")) {
Button(tr("查看")) {
if let url = URL(string: "https://github.com/exelban/stats") { NSWorkspace.shared.open(url) }
}
.buttonStyle(DSButtonStyle(kind: .secondary))
@@ -1,4 +1,5 @@
import AppKit
import Localization
import SwiftUI
import Updates
@@ -14,7 +15,7 @@ struct ReleaseSummary: View {
VStack(alignment: .leading, spacing: DS.Space.s3) {
if showsHeader { header }
Text("更新内容").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
Text(tr("更新内容")).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
notes
}
}
@@ -23,10 +24,10 @@ struct ReleaseSummary: View {
HStack(spacing: DS.Space.s3) {
AppGlyph(size: DS.Space.s12)
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(verbatim: "OpenStats \(release.version) 已发布")
Text(verbatim: tr("OpenStats \(release.version) 已发布"))
.dsFont(.lg, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: "当前版本 \(currentVersion) · \(release.date) · \(ByteCountFormatter.string(fromByteCount: release.size, countStyle: .file))")
Text(verbatim: tr("当前版本 \(currentVersion) · \(release.date) · \(ByteCountFormatter.string(fromByteCount: release.size, countStyle: .file))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
@@ -47,7 +48,7 @@ struct ReleaseSummary: View {
}
if let changelog = release.changelog {
Button { NSWorkspace.shared.open(changelog) } label: {
Text("查看完整更新日志").dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.primary)
Text(tr("查看完整更新日志")).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.primary)
}
.buttonStyle(.plain)
}
@@ -77,7 +78,7 @@ struct UpdateProgress: View {
case .downloading(let fraction):
VStack(alignment: .leading, spacing: DS.Space.s2) {
HStack {
Text("正在下载").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Text(tr("正在下载")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer()
Text(verbatim: "\(Int((fraction * 100).rounded()))%").dsFont(.xs, weight: .medium).monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
@@ -85,13 +86,13 @@ struct UpdateProgress: View {
ProgressTrack(fraction: fraction)
}
case .verifying:
InfoBanner(icon: "checkmark.shield", text: "正在核对校验值、开发者签名与 Apple 公证…")
InfoBanner(icon: "checkmark.shield", text: tr("正在核对校验值、开发者签名与 Apple 公证…"))
case .installing:
InfoBanner(icon: "arrow.triangle.2.circlepath", text: "正在安装,完成后 OpenStats 会自动重启。")
InfoBanner(icon: "arrow.triangle.2.circlepath", text: tr("正在安装,完成后 OpenStats 会自动重启。"))
case .failed(let message):
InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) {
if updates.release != nil {
Button("手动下载") { updates.openManualDownload() }
Button(tr("手动下载")) { updates.openManualDownload() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
@@ -116,16 +117,16 @@ struct UpdatePromptView: View {
HStack(spacing: DS.Space.s2) {
if case .downloading = updates.phase {
Spacer()
Button("取消") { updates.cancel() }.buttonStyle(DSButtonStyle(kind: .secondary))
Button(tr("取消")) { updates.cancel() }.buttonStyle(DSButtonStyle(kind: .secondary))
} else {
Button("跳过此版本") {
Button(tr("跳过此版本")) {
updates.skipCurrentRelease()
close()
}
.buttonStyle(DSButtonStyle(kind: .ghost))
Spacer()
Button("以后再说", action: close).buttonStyle(DSButtonStyle(kind: .secondary))
Button("一键安装") { updates.install() }
Button(tr("以后再说"), action: close).buttonStyle(DSButtonStyle(kind: .secondary))
Button(tr("一键安装")) { updates.install() }
.buttonStyle(DSButtonStyle(kind: .primary))
.keyboardShortcut(.defaultAction)
}
@@ -148,17 +149,17 @@ struct UpdateSettings: View {
@Bindable var settings = model.settings
let updates = model.updates
SettingsGroup(caption: "软件更新") {
SettingsGroup(caption: tr("软件更新")) {
GroupRow(showsDivider: false) {
SettingRow(title: statusTitle, subtitle: statusSubtitle) {
Button(updates.phase == .checking ? "正在检查…" : "检查更新") { updates.check(userInitiated: true) }
Button(updates.phase == .checking ? tr("正在检查…") : tr("检查更新")) { updates.check(userInitiated: true) }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(updates.isBusy)
}
}
GroupRow {
SettingRow(title: "自动检查更新", subtitle: "启动时和之后每天检查一次,发现新版本时显示更新摘要") {
DSToggle(isOn: $settings.autoCheckUpdates, label: "自动检查更新")
SettingRow(title: tr("自动检查更新"), subtitle: tr("启动时和之后每天检查一次,发现新版本时显示更新摘要")) {
DSToggle(isOn: $settings.autoCheckUpdates, label: tr("自动检查更新"))
}
}
if let release = updates.release {
@@ -169,9 +170,9 @@ struct UpdateSettings: View {
HStack {
Spacer()
if case .downloading = updates.phase {
Button("取消") { updates.cancel() }.buttonStyle(DSButtonStyle(kind: .secondary))
Button(tr("取消")) { updates.cancel() }.buttonStyle(DSButtonStyle(kind: .secondary))
} else {
Button("一键安装") { updates.install() }
Button(tr("一键安装")) { updates.install() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(updates.isBusy)
}
@@ -187,17 +188,17 @@ struct UpdateSettings: View {
private var statusTitle: String {
let updates = model.updates
switch updates.phase {
case .checking: return "正在检查更新"
case .upToDate: return "已是最新版本"
case .failed where updates.release == nil: return "检查更新失败"
default: return updates.release.map { "发现新版本 \($0.version)" } ?? "当前版本 \(updates.currentVersion)"
case .checking: return tr("正在检查更新")
case .upToDate: return tr("已是最新版本")
case .failed where updates.release == nil: return tr("检查更新失败")
default: return updates.release.map { tr("发现新版本 \($0.version)") } ?? tr("当前版本 \(updates.currentVersion)")
}
}
private var statusSubtitle: String {
let checked = model.updates.lastChecked.map { "上次检查:\($0.formatted(.relative(presentation: .named)))" } ?? "尚未检查"
let checked = model.updates.lastChecked.map { tr("上次检查:\($0.formatted(.relative(presentation: .named).locale(L10n.locale)))") } ?? tr("尚未检查")
guard let release = model.updates.release else { return checked }
let size = ByteCountFormatter.string(fromByteCount: release.size, countStyle: .file)
return "当前 \(model.updates.currentVersion) · 发布于 \(release.date) · \(size) · \(checked)"
return tr("当前 \(model.updates.currentVersion) · 发布于 \(release.date) · \(size) · \(checked)")
}
}
@@ -1,4 +1,5 @@
import AppKit
import Localization
import Metrics
import SMC
import SwiftUI
@@ -65,7 +66,7 @@ enum SnapshotRenderer {
version: "0.3.0", build: "3", date: "2026-09-20", minimumSystem: "14.0",
url: URL(string: "https://getopenstats.com/download/OpenStats-0.3.0.zip")!, sha256: String(repeating: "0", count: 64),
size: 9_600_000, dmg: nil,
notes: ["在线升级:发现新版本时显示更新摘要,一键安装并自动重启", "连接探测只在需要时运行,更省电", "进程页刷新频率调整为每 2 秒"],
notes: [tr("在线升级:发现新版本时显示更新摘要,一键安装并自动重启"), tr("连接探测只在需要时运行,更省电"), tr("进程页刷新频率调整为每 2 秒")],
changelog: URL(string: "https://getopenstats.com/#changelog")))
for (appearanceName, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] {
@@ -96,7 +97,7 @@ enum SnapshotRenderer {
}
writePNG(padded, scale: 2, to: outputDirectory.appendingPathComponent("menubar-\(dark ? "dark" : "light").png"))
}
print("截图已输出到 \(outputDirectory.path)")
print(tr("截图已输出到 \(outputDirectory.path)"))
}
/// 用真实的 NSHostingView 放进离屏窗口截图,渲染路径与 App 内一致
@@ -1,5 +1,6 @@
import AppKit
import Foundation
import Localization
import Metrics
import Network
import Observation
@@ -13,23 +14,23 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
var title: String {
switch self {
case .cpuTemperature: "CPU 过热"
case .memoryPressure: "内存压力严重"
case .diskSpace: "磁盘空间不足"
case .networkDown: "网络断开"
case .batteryHealth: "电池健康度下降"
case .bluetoothBattery: "蓝牙设备电量低"
case .cpuTemperature: tr("CPU 过热")
case .memoryPressure: tr("内存压力严重")
case .diskSpace: tr("磁盘空间不足")
case .networkDown: tr("网络断开")
case .batteryHealth: tr("电池健康度下降")
case .bluetoothBattery: tr("蓝牙设备电量低")
}
}
var detail: String {
switch self {
case .cpuTemperature: "CPU 温度持续 1 分钟高于设定值"
case .memoryPressure: "内存压力持续 30 秒处于严重,系统开始大量换页"
case .diskSpace: "启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次"
case .networkDown: "网络连接中断超过 20 秒,恢复后再提示一次"
case .batteryHealth: "电池最大容量低于 80%,每 30 天最多提醒一次"
case .bluetoothBattery: "已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次"
case .cpuTemperature: tr("CPU 温度持续 1 分钟高于设定值")
case .memoryPressure: tr("内存压力持续 30 秒处于严重,系统开始大量换页")
case .diskSpace: tr("启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次")
case .networkDown: tr("网络连接中断超过 20 秒,恢复后再提示一次")
case .batteryHealth: tr("电池最大容量低于 80%,每 30 天最多提醒一次")
case .bluetoothBattery: tr("已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次")
}
}
@@ -176,11 +177,11 @@ public final class AlertController: NSObject {
let cpu = store.sensors?.temperatures.first(where: { $0.group == .cpu })?.maximum {
let limit = Double(settings.alertCPUTemperature)
fireIfNeeded(.cpuTemperature, isActive: cpu >= limit, now: now,
body: "CPU 温度 \(Format.temperature(cpu, fahrenheit: settings.useFahrenheit)),已持续超过 1 分钟。可以检查高占用进程,或在“温度与风扇”里提高风扇转速。")
body: tr("CPU 温度 \(Format.temperature(cpu, fahrenheit: settings.useFahrenheit)),已持续超过 1 分钟。可以检查高占用进程,或在“温度与风扇”里提高风扇转速。"))
}
if enabled.contains(.memoryPressure), let memory = store.memory {
fireIfNeeded(.memoryPressure, isActive: memory.pressure == .critical, now: now,
body: "可用内存只剩 \(Format.bytes(memory.available)),系统正在压缩和交换内存。关闭不用的应用可以缓解。")
body: tr("可用内存只剩 \(Format.bytes(memory.available)),系统正在压缩和交换内存。关闭不用的应用可以缓解。"))
}
}
@@ -189,14 +190,14 @@ public final class AlertController: NSObject {
if enabled.contains(.diskSpace), let disk = DiskSampler.sample() {
let low = disk.total > 0 && (Double(disk.available) / Double(disk.total) < 0.1 || disk.available < 10_000_000_000)
fireIfNeeded(.diskSpace, isActive: low, now: now,
body: "“\(disk.volumeName)”只剩 \(Format.bytes(disk.available, base: .decimal)) 可用。可以用 OpenStats 的清理功能释放缓存。")
body: tr("“\(disk.volumeName)”只剩 \(Format.bytes(disk.available, base: .decimal)) 可用。可以用 OpenStats 的清理功能释放缓存。"))
}
if enabled.contains(.batteryHealth), let health = BatterySampler.sample()?.health {
// 冷却时间跨越重启,记在偏好设置里
let last = defaults.object(forKey: Keys.batteryNotified) as? Date
if health < 0.8, last.map({ now.timeIntervalSince($0) >= AlertKind.batteryHealth.cooldown }) ?? true {
defaults.set(now, forKey: Keys.batteryNotified)
send(.batteryHealth, body: "电池最大容量为 \(Format.percent(health)),续航会明显缩短。可以在“系统设置 › 电池”查看是否建议维修。")
send(.batteryHealth, body: tr("电池最大容量为 \(Format.percent(health)),续航会明显缩短。可以在“系统设置 › 电池”查看是否建议维修。"))
}
}
}
@@ -212,7 +213,7 @@ public final class AlertController: NSObject {
if let last = bluetoothNotified[device.id], now.timeIntervalSince(last) < AlertKind.bluetoothBattery.cooldown { continue }
bluetoothNotified[device.id] = now
let part = device.batteries.count > 1 ? "\(lowest.label)" : ""
send(.bluetoothBattery, title: "\(device.name)电量低", body: "\(device.name)\(part)只剩 \(lowest.percent)%,记得充电。")
send(.bluetoothBattery, title: tr("\(device.name)电量低"), body: tr("\(device.name)\(part)只剩 \(lowest.percent)%,记得充电。"))
}
}
@@ -238,7 +239,7 @@ public final class AlertController: NSObject {
if satisfied {
if networkNotified {
networkNotified = false
send(.networkDown, title: "网络已恢复", body: "网络连接已经恢复。")
send(.networkDown, title: tr("网络已恢复"), body: tr("网络连接已经恢复。"))
}
return
}
@@ -248,7 +249,7 @@ public final class AlertController: NSObject {
guard !Task.isCancelled, let self else { return }
self.networkNotified = true
Log.network.notice("网络断开超过 20 秒")
self.send(.networkDown, body: "网络连接已中断超过 20 秒。可以打开网络详情查看接口与路由器状态。")
self.send(.networkDown, body: tr("网络连接已中断超过 20 秒。可以打开网络详情查看接口与路由器状态。"))
}
}
@@ -290,7 +291,7 @@ public final class AlertController: NSObject {
requestAuthorization { [weak self] in self?.sendTest() }
return
}
send(.cpuTemperature, title: "OpenStats 通知测试", body: "通知可以正常显示。发生你打开的状况时,会像这样提醒你。")
send(.cpuTemperature, title: tr("OpenStats 通知测试"), body: tr("通知可以正常显示。发生你打开的状况时,会像这样提醒你。"))
}
private func send(_ kind: AlertKind, title: String? = nil, body: String) {
@@ -3,6 +3,7 @@ import Metrics
import Observation
import ServiceManagement
import SMC
import Updates
/// 应用状态总入口,注入到所有 SwiftUI 视图
@MainActor
@@ -96,6 +97,12 @@ public final class AppModel {
openPopover == .network || (isMainWindowVisible && settings.panelTab == .network)
}
/// 等当前进程退出后重新打开应用(切换语言后使用)
func relaunch() {
try? UpdateInstaller.relaunch(Bundle.main.bundleURL)
quit()
}
func refreshLaunchAtLogin() {
launchAtLoginEnabled = SMAppService.mainApp.status == .enabled
}
@@ -1,4 +1,5 @@
import Foundation
import Localization
import Observation
public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
@@ -9,22 +10,22 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
var title: String {
switch self {
case .cpu: "CPU"
case .memory: "内存"
case .network: "网络"
case .memory: tr("内存")
case .network: tr("网络")
case .gpu: "GPU"
case .temperature: "CPU 温度"
case .fan: "风扇转速"
case .temperature: tr("CPU 温度")
case .fan: tr("风扇转速")
}
}
var subtitle: String {
switch self {
case .cpu: "总占用"
case .memory: "已用内存占比"
case .network: "上传与下载速度、IP 地址、DNS"
case .gpu: "GPU 占用"
case .temperature: "CPU 核心最高温度"
case .fan: "转速最高的风扇"
case .cpu: tr("总占用")
case .memory: tr("已用内存占比")
case .network: tr("上传与下载速度、IP 地址、DNS")
case .gpu: tr("GPU 占用")
case .temperature: tr("CPU 核心最高温度")
case .fan: tr("转速最高的风扇")
}
}
@@ -44,11 +45,11 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
var popoverTitle: String {
switch self {
case .cpu: "CPU"
case .memory: "内存"
case .network: "网络"
case .memory: tr("内存")
case .network: tr("网络")
case .gpu: "GPU"
case .temperature: "温度"
case .fan: "风扇"
case .temperature: tr("温度")
case .fan: tr("风扇")
}
}
@@ -91,23 +92,23 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
var title: String {
switch self {
case .cpuHeatmap: "核心热力图"
case .cpuClusters: "核心分工"
case .cpuLoadAverage: "排队程度"
case .cpuApps, .memoryApps: "按应用汇总"
case .networkProcesses: "高占用进程"
case .memoryWaterline: "内存水位"
case .memoryCompression: "压缩与交换"
case .networkHistory: "流量历史"
case .networkProbe: "连接探测"
case .networkInterface: "接口"
case .networkAddresses: "IP 地址"
case .cpuHeatmap: tr("核心热力图")
case .cpuClusters: tr("核心分工")
case .cpuLoadAverage: tr("排队程度")
case .cpuApps, .memoryApps: tr("按应用汇总")
case .networkProcesses: tr("高占用进程")
case .memoryWaterline: tr("内存水位")
case .memoryCompression: tr("压缩与交换")
case .networkHistory: tr("流量历史")
case .networkProbe: tr("连接探测")
case .networkInterface: tr("接口")
case .networkAddresses: tr("IP 地址")
case .networkDNS: "DNS"
case .gpuHistory: "使用历史"
case .gpuDetails: "显卡信息"
case .thermalSensors: "温度"
case .thermalFans: "风扇"
case .thermalPower: "功耗"
case .gpuHistory: tr("使用历史")
case .gpuDetails: tr("显卡信息")
case .thermalSensors: tr("温度")
case .thermalFans: tr("风扇")
case .thermalPower: tr("功耗")
}
}
}
@@ -123,8 +124,8 @@ public enum MenuBarLayout: String, CaseIterable, Identifiable, Sendable {
var title: String {
switch self {
case .separate: "每项独立"
case .combined: "合并为一个"
case .separate: tr("每项独立")
case .combined: tr("合并为一个")
}
}
}
@@ -137,11 +138,11 @@ public enum ProbeTarget: String, CaseIterable, Identifiable, Sendable {
var title: String {
switch self {
case .cloudflare: "Cloudflare(1.1.1.1)"
case .google: "Google(8.8.8.8)"
case .aliyun: "阿里云(223.5.5.5)"
case .tencent: "腾讯(119.29.29.29)"
case .gateway: "路由器(网关)"
case .cloudflare: tr("Cloudflare(1.1.1.1)")
case .google: tr("Google(8.8.8.8)")
case .aliyun: tr("阿里云(223.5.5.5)")
case .tencent: tr("腾讯(119.29.29.29)")
case .gateway: tr("路由器(网关)")
}
}
@@ -165,27 +166,27 @@ public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
var title: String {
switch self {
case .stacked: "双行文字"
case .inline: "单行文字"
case .icon: "图标"
case .ring: "圆环"
case .pie: "饼图"
case .history: "柱状历史"
case .meter: "电量条"
case .dot: "状态圆点"
case .stacked: tr("双行文字")
case .inline: tr("单行文字")
case .icon: tr("图标")
case .ring: tr("圆环")
case .pie: tr("饼图")
case .history: tr("柱状历史")
case .meter: tr("电量条")
case .dot: tr("状态圆点")
}
}
var detail: String {
switch self {
case .stacked: "小标签在上、数值在下,最紧凑"
case .inline: "标签与数值同一行,最易读"
case .icon: "用系统图标代替文字标签"
case .ring: "圆环表示当前占用比例"
case .pie: "饼图表示当前占用比例"
case .history: "10 根柱子是最近 10 次采样(约 20 秒)的变化"
case .meter: "竖向电量条表示当前占用比例"
case .dot: "绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)"
case .stacked: tr("小标签在上、数值在下,最紧凑")
case .inline: tr("标签与数值同一行,最易读")
case .icon: tr("用系统图标代替文字标签")
case .ring: tr("圆环表示当前占用比例")
case .pie: tr("饼图表示当前占用比例")
case .history: tr("10 根柱子是最近 10 次采样(约 20 秒)的变化")
case .meter: tr("竖向电量条表示当前占用比例")
case .dot: tr("绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)")
}
}
}
@@ -197,9 +198,22 @@ public enum NetworkMenuStyle: String, CaseIterable, Identifiable, Sendable {
var title: String {
switch self {
case .dots: "双行圆点"
case .arrows: "双行箭头"
case .inline: "单行"
case .dots: tr("双行圆点")
case .arrows: tr("双行箭头")
case .inline: tr("单行")
}
}
}
extension AppLanguage: Identifiable {
public var id: String { rawValue }
/// 语言名称用各自的文字写,不随界面语言翻译
var title: String {
switch self {
case .system: tr("跟随系统")
case .chinese: "简体中文"
case .english: "English"
}
}
}
@@ -211,9 +225,9 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
var title: String {
switch self {
case .system: "跟随系统"
case .light: "浅色"
case .dark: "深色"
case .system: tr("跟随系统")
case .light: tr("浅色")
case .dark: tr("深色")
}
}
}
@@ -232,29 +246,29 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
var isSettings: Bool { Self.settings.contains(self) }
/// 页面顶栏的标题:设置页带上分组名
var headerTitle: String { isSettings ? "设置 · \(title)" : title }
var headerTitle: String { isSettings ? tr("设置 · \(title)") : title }
var title: String {
switch self {
case .overview: "仪表盘"
case .system: "本机信息"
case .history: "历史"
case .overview: tr("仪表盘")
case .system: tr("本机信息")
case .history: tr("历史")
case .cpu: "CPU"
case .gpu: "GPU"
case .memory: "内存"
case .disk: "磁盘"
case .network: "网络"
case .thermal: "温度与风扇"
case .processes: "进程"
case .keepAwake: "防休眠"
case .cleaner: "清理"
case .uninstaller: "卸载应用"
case .startupItems: "启动项"
case .settingsGeneral: "通用"
case .settingsMenuBar: "菜单栏"
case .settingsNotifications: "通知"
case .settingsHelper: "辅助工具"
case .settingsAbout: "关于"
case .memory: tr("内存")
case .disk: tr("磁盘")
case .network: tr("网络")
case .thermal: tr("温度与风扇")
case .processes: tr("进程")
case .keepAwake: tr("防休眠")
case .cleaner: tr("清理")
case .uninstaller: tr("卸载应用")
case .startupItems: tr("启动项")
case .settingsGeneral: tr("通用")
case .settingsMenuBar: tr("菜单栏")
case .settingsNotifications: tr("通知")
case .settingsHelper: tr("辅助工具")
case .settingsAbout: tr("关于")
}
}
@@ -390,6 +404,14 @@ public final class AppSettings {
public var alertCPUTemperature: Int {
didSet { defaults.set(alertCPUTemperature, forKey: Keys.alertCPUTemperature) }
}
/// 界面语言,重启后生效
public var language: AppLanguage {
didSet {
defaults.set(language.rawValue, forKey: Keys.language)
language.applyToProcessLocale(defaults: defaults)
}
}
nonisolated public static let languageKey = "language"
/// 全局快捷键
public var hotKeys: [HotKeyAction: HotKey] {
didSet {
@@ -450,6 +472,7 @@ public final class AppSettings {
geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true
autoCheckUpdates = defaults.object(forKey: Keys.autoCheckUpdates) as? Bool ?? true
historyEnabled = defaults.object(forKey: Keys.historyEnabled) as? Bool ?? true
language = defaults.string(forKey: Keys.language).flatMap(AppLanguage.init(rawValue:)) ?? .system
let storedHotKeys = defaults.data(forKey: Keys.hotKeys).flatMap { try? JSONDecoder().decode([String: HotKey].self, from: $0) } ?? [:]
hotKeys = Dictionary(uniqueKeysWithValues: storedHotKeys.compactMap { key, value in
HotKeyAction(rawValue: key).map { ($0, value) }
@@ -510,6 +533,7 @@ public final class AppSettings {
static let autoCheckUpdates = "autoCheckUpdates"
static let historyEnabled = "historyEnabled"
static let hotKeys = "hotKeys"
static let language = "language"
static let enabledAlerts = "enabledAlerts"
static let alertCPUTemperature = "alertCPUTemperature"
}
@@ -1,6 +1,7 @@
import AppKit
import Foundation
import HelperShared
import Localization
import Metrics
import Observation
import os
@@ -33,7 +34,7 @@ final class DiagnosticsExporter {
func export(model: AppModel) {
guard phase != .collecting else { return }
let panel = NSSavePanel()
panel.nameFieldStringValue = "OpenStats-诊断-\(Self.fileDate()).zip"
panel.nameFieldStringValue = tr("OpenStats-诊断-\(Self.fileDate()).zip")
panel.allowedContentTypes = [.zip]
panel.directoryURL = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first
NSApp.activate()
@@ -47,7 +48,7 @@ final class DiagnosticsExporter {
Log.app.notice("已导出诊断信息")
phase = .finished(destination)
} catch {
phase = .failed("导出失败:\(error.localizedDescription)")
phase = .failed(tr("导出失败:\(error.localizedDescription)"))
}
}
}
@@ -68,59 +69,59 @@ final class DiagnosticsExporter {
func section(_ title: String) { lines.append(""); lines.append("## \(title)") }
func row(_ key: String, _ value: Any?) { lines.append("\(key): \(value.map { "\($0)" } ?? "—")") }
lines.append("# OpenStats 诊断信息")
row("导出时间", ISO8601DateFormatter().string(from: Date()))
lines.append(tr("# OpenStats 诊断信息"))
row(tr("导出时间"), ISO8601DateFormatter().string(from: Date()))
section("应用")
row("版本", bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString"))
row("构建", bundle.object(forInfoDictionaryKey: "CFBundleVersion"))
row("位置", bundle.bundleURL.path.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
row("签名团队", CodeSigningInfo.currentTeamIdentifier() ?? "未签名(开发构建)")
row("已运行", Format.uptime(since: launchDate))
section(tr("应用"))
row(tr("版本"), bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString"))
row(tr("构建"), bundle.object(forInfoDictionaryKey: "CFBundleVersion"))
row(tr("位置"), bundle.bundleURL.path.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
row(tr("签名团队"), CodeSigningInfo.currentTeamIdentifier() ?? tr("未签名(开发构建)"))
row(tr("已运行"), Format.uptime(since: launchDate))
section("系统")
row("机型", "\(store.system.modelName)(\(store.system.modelIdentifier))")
row("macOS", "\(store.system.osVersion)(\(store.system.osBuild))")
row("处理器核心", process.activeProcessorCount)
row("内存", Format.bytes(process.physicalMemory))
row("开机以来", Format.uptime(since: Date().addingTimeInterval(-process.systemUptime)))
row("语言", Locale.preferredLanguages.prefix(3).joined(separator: ", "))
row("温度传感器", store.sensors.map { "\($0.temperatures.count) 组" })
row("风扇", store.sensors.map { "\($0.fans.count) 个" })
row("电池", store.battery.map { "\(Format.percent($0.level)),\($0.isPluggedIn ? "接通电源" : "使用电池"),健康 \($0.health.map(Format.percent) ?? "—")" } ?? "无")
section(tr("系统"))
row(tr("机型"), tr("\(store.system.modelName)(\(store.system.modelIdentifier))"))
row("macOS", tr("\(store.system.osVersion)(\(store.system.osBuild))"))
row(tr("处理器核心"), process.activeProcessorCount)
row(tr("内存"), Format.bytes(process.physicalMemory))
row(tr("开机以来"), Format.uptime(since: Date().addingTimeInterval(-process.systemUptime)))
row(tr("语言"), Locale.preferredLanguages.prefix(3).joined(separator: ", "))
row(tr("温度传感器"), store.sensors.map { tr("\($0.temperatures.count) 组") })
row(tr("风扇"), store.sensors.map { tr("\($0.fans.count) 个") })
row(tr("电池"), store.battery.map { tr("\(Format.percent($0.level)),\($0.isPluggedIn ? tr("接通电源") : tr("使用电池")),健康 \($0.health.map(Format.percent) ?? "—")") } ?? tr("无"))
section("辅助工具")
row("状态", model.helper.status.title)
if case .unavailable(let reason) = model.helper.status { row("原因", reason) }
row("协议版本", "应用 \(HelperConstants.protocolVersion) / 辅助工具 \(remoteVersion.map(String.init) ?? "未连接")")
row("最近错误", model.helper.lastError)
section(tr("辅助工具"))
row(tr("状态"), model.helper.status.title)
if case .unavailable(let reason) = model.helper.status { row(tr("原因"), reason) }
row(tr("协议版本"), tr("应用 \(HelperConstants.protocolVersion) / 辅助工具 \(remoteVersion.map(String.init) ?? tr("未连接"))"))
row(tr("最近错误"), model.helper.lastError)
section("状态")
row("风扇模式", model.fans.mode.title)
row("防休眠", model.keepAwake.isActive ? model.keepAwake.mode.title : "关闭")
row("合盖运行", model.keepAwake.lidClosedActive ? "开启" : "关闭")
row("登录时启动", model.launchAtLoginEnabled ? "开启" : "关闭")
row("在线升级", "上次检查 \(model.updates.lastChecked.map { ISO8601DateFormatter().string(from: $0) } ?? "从未"),最新 \(model.updates.release?.version ?? "当前版本")")
section(tr("状态"))
row(tr("风扇模式"), model.fans.mode.title)
row(tr("防休眠"), model.keepAwake.isActive ? model.keepAwake.mode.title : tr("关闭"))
row(tr("合盖运行"), model.keepAwake.lidClosedActive ? tr("开启") : tr("关闭"))
row(tr("登录时启动"), model.launchAtLoginEnabled ? tr("开启") : tr("关闭"))
row(tr("在线升级"), tr("上次检查 \(model.updates.lastChecked.map { ISO8601DateFormatter().string(from: $0) } ?? tr("从未")),最新 \(model.updates.release?.version ?? tr("当前版本"))"))
let geo = model.geo.installed.values.sorted { $0.edition < $1.edition }.map { "\($0.edition) \($0.build)" }
row("归属地数据库", geo.isEmpty ? "未安装" : geo.joined(separator: ","))
row(tr("归属地数据库"), geo.isEmpty ? tr("未安装") : geo.joined(separator: tr(",")))
section("设置")
row("菜单栏项目", settings.orderedMenuBarItems.map(\.rawValue).joined(separator: ", "))
row("菜单栏布局", settings.menuBarLayout.rawValue)
row("菜单栏风格", settings.menuBarStyle.rawValue)
row("刷新间隔", "\(settings.refreshSeconds) 秒")
row("外观", settings.appearance.rawValue)
row("连接探测", settings.probeEnabled ? "\(settings.probeTarget.rawValue),\(settings.probeSeconds) 秒,后台\(settings.probeInBackground ? "开启" : "关闭")" : "关闭")
row("公网 IP 查询", settings.publicIPLookup ? "开启" : "关闭")
row("风扇安全温度", "\(settings.fanSafetyTemperature)°C")
row("合盖电量下限", "\(settings.lidModeBatteryFloor)%")
section(tr("设置"))
row(tr("菜单栏项目"), settings.orderedMenuBarItems.map(\.rawValue).joined(separator: ", "))
row(tr("菜单栏布局"), settings.menuBarLayout.rawValue)
row(tr("菜单栏风格"), settings.menuBarStyle.rawValue)
row(tr("刷新间隔"), tr("\(settings.refreshSeconds) 秒"))
row(tr("外观"), settings.appearance.rawValue)
row(tr("连接探测"), settings.probeEnabled ? tr("\(settings.probeTarget.rawValue),\(settings.probeSeconds) 秒,后台\(settings.probeInBackground ? tr("开启") : tr("关闭"))") : tr("关闭"))
row(tr("公网 IP 查询"), settings.publicIPLookup ? tr("开启") : tr("关闭"))
row(tr("风扇安全温度"), "\(settings.fanSafetyTemperature)°C")
row(tr("合盖电量下限"), "\(settings.lidModeBatteryFloor)%")
return lines.joined(separator: "\n") + "\n"
}
nonisolated static func writeArchive(summary: String, to destination: URL) throws {
let manager = FileManager.default
let work = manager.temporaryDirectory.appendingPathComponent("OpenStats-diagnostics-\(UUID().uuidString)")
let folder = work.appendingPathComponent("OpenStats-诊断-\(fileDate())")
let folder = work.appendingPathComponent(tr("OpenStats-诊断-\(fileDate())"))
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
defer { try? manager.removeItem(at: work) }
@@ -1,4 +1,5 @@
import Foundation
import Localization
import Metrics
import Observation
import SMC
@@ -13,10 +14,10 @@ public final class FanController {
var title: String {
switch self {
case .automatic: "自动"
case .cooling: "降温"
case .maximum: "强冷"
case .custom: "自定义"
case .automatic: tr("自动")
case .cooling: tr("降温")
case .maximum: tr("强冷")
case .custom: tr("自定义")
}
}
@@ -72,7 +73,7 @@ public final class FanController {
Task {
mode = .automatic
_ = await apply()
show("CPU 温度达到 \(Format.temperature(hottest, fahrenheit: settings.useFahrenheit)),已恢复系统自动控制", isError: false)
show(tr("CPU 温度达到 \(Format.temperature(hottest, fahrenheit: settings.useFahrenheit)),已恢复系统自动控制"), isError: false)
}
}
@@ -87,7 +88,7 @@ public final class FanController {
private func apply() async -> String? {
let fans = store.sensors?.fans ?? []
guard !fans.isEmpty else { return "未检测到可调节的风扇" }
guard !fans.isEmpty else { return tr("未检测到可调节的风扇") }
isApplying = true
defer { isApplying = false }
@@ -1,5 +1,6 @@
import CryptoKit
import Foundation
import Localization
import Metrics
import Observation
@@ -24,7 +25,7 @@ public final class GeoDatabaseController {
@ObservationIgnored private var asnDatabase: MaxMindDatabase?
static let manifestURL = URL(string: "https://getopenstats.com/geoip/manifest.json")!
static let attribution = "本产品包含 MaxMind 创建的 GeoLite2 数据,可从 https://www.maxmind.com 获取。"
static let attribution = tr("本产品包含 MaxMind 创建的 GeoLite2 数据,可从 https://www.maxmind.com 获取。")
private static let checkInterval: TimeInterval = 3 * 24 * 3600
private static let lastCheckedKey = "geoDatabaseLastChecked"
@@ -65,13 +66,13 @@ public final class GeoDatabaseController {
do {
let (data, response) = try await URLSession.shared.data(from: Self.manifestURL)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw Failure("服务器上还没有数据库,稍后再试")
throw Failure(tr("服务器上还没有数据库,稍后再试"))
}
let manifest = try JSONDecoder().decode(Manifest.self, from: data)
var changed = 0
for edition in wanted {
guard let entry = manifest.databases.first(where: { $0.edition == edition }) else {
throw Failure("服务器清单里没有 \(edition)")
throw Failure(tr("服务器清单里没有 \(edition)"))
}
if installed[edition]?.sha256 == entry.sha256 { continue }
try await download(entry)
@@ -86,25 +87,25 @@ public final class GeoDatabaseController {
reload()
lastChecked = Date()
UserDefaults.standard.set(lastChecked, forKey: Self.lastCheckedKey)
message = (changed > 0 ? "已更新 \(changed) 个数据库" : "已经是最新", false)
message = (changed > 0 ? tr("已更新 \(changed) 个数据库") : tr("已经是最新"), false)
} catch let failure as Failure {
message = (failure.text, true)
} catch {
message = ("更新失败:\(error.localizedDescription)", true)
message = (tr("更新失败:\(error.localizedDescription)"), true)
Log.network.error("归属地数据库更新失败:\(error.localizedDescription, privacy: .public)")
}
}
/// 下载到临时文件,sha256 与清单一致、且能按库类型打开后才替换
private func download(_ entry: Manifest.Entry) async throws {
guard let url = URL(string: entry.file, relativeTo: Self.manifestURL) else { throw Failure("清单里的文件地址无效") }
guard let url = URL(string: entry.file, relativeTo: Self.manifestURL) else { throw Failure(tr("清单里的文件地址无效")) }
let (temporary, response) = try await URLSession.shared.download(from: url)
defer { try? FileManager.default.removeItem(at: temporary) }
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw Failure("下载 \(entry.edition) 失败") }
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw Failure(tr("下载 \(entry.edition) 失败")) }
let digest = try await Task.detached { try Self.sha256(of: temporary) }.value
guard digest == entry.sha256 else { throw Failure("\(entry.edition) 校验不一致,已丢弃") }
guard digest == entry.sha256 else { throw Failure(tr("\(entry.edition) 校验不一致,已丢弃")) }
let database = try MaxMindDatabase(url: temporary)
guard database.databaseType == entry.edition else { throw Failure("\(entry.edition) 文件类型不对") }
guard database.databaseType == entry.edition else { throw Failure(tr("\(entry.edition) 文件类型不对")) }
try install(temporary, edition: entry.edition, build: entry.build, sha256: digest, size: entry.size)
}
@@ -114,7 +115,7 @@ public final class GeoDatabaseController {
let database = try MaxMindDatabase(url: url)
let type = database.databaseType
guard type.hasSuffix("-City") || type.hasSuffix("-Country") || type.hasSuffix("-ASN") else {
throw Failure("不是城市、国家或 ASN 数据库(\(type))")
throw Failure(tr("不是城市、国家或 ASN 数据库(\(type))"))
}
let digest = try Self.sha256(of: url)
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) ?? 0
@@ -122,11 +123,11 @@ public final class GeoDatabaseController {
try install(url, edition: type, build: build, sha256: digest, size: size, copy: true)
try saveIndex()
reload()
message = ("已导入 \(type)", false)
message = (tr("已导入 \(type)"), false)
} catch let failure as Failure {
message = (failure.text, true)
} catch {
message = ("导入失败:\(error.localizedDescription)", true)
message = (tr("导入失败:\(error.localizedDescription)"), true)
}
}
@@ -1,5 +1,6 @@
import Foundation
import HelperShared
import Localization
import Observation
import ServiceManagement
@@ -15,10 +16,10 @@ public final class HelperClient {
var title: String {
switch self {
case .notInstalled: "未安装"
case .requiresApproval: "等待批准"
case .enabled: "已启用"
case .unavailable: "不可用"
case .notInstalled: tr("未安装")
case .requiresApproval: tr("等待批准")
case .enabled: tr("已启用")
case .unavailable: tr("不可用")
}
}
}
@@ -39,7 +40,7 @@ public final class HelperClient {
public var isReady: Bool { status == .enabled }
static let outdatedMessage = "辅助工具版本比应用旧,部分功能可能无法使用,请重新安装一次(需要管理员授权)。"
static let outdatedMessage = tr("辅助工具版本比应用旧,部分功能可能无法使用,请重新安装一次(需要管理员授权)。")
/// 未安装、待批准或版本过旧,需要用户处理
var needsAttention: Bool { !isReady || isOutdated }
@@ -55,8 +56,8 @@ public final class HelperClient {
.appendingPathComponent("Contents/Library/LaunchDaemons/\(HelperConstants.launchdPlistName)")
status = FileManager.default.fileExists(atPath: plist.path)
? .notInstalled
: .unavailable("应用包内未找到辅助工具,请使用完整构建的 OpenStats.app")
@unknown default: status = .unavailable("未知状态")
: .unavailable(tr("应用包内未找到辅助工具,请使用完整构建的 OpenStats.app"))
@unknown default: status = .unavailable(tr("未知状态"))
}
}
@@ -67,7 +68,7 @@ public final class HelperClient {
do {
try service.register()
} catch {
lastError = "安装失败:\(error.localizedDescription)"
lastError = tr("安装失败:\(error.localizedDescription)")
Log.helper.error("安装失败:\(error.localizedDescription, privacy: .public)")
}
refreshStatus()
@@ -90,7 +91,7 @@ public final class HelperClient {
}
}
} catch {
lastError = "卸载失败:\(error.localizedDescription)"
lastError = tr("卸载失败:\(error.localizedDescription)")
Log.helper.error("卸载失败:\(error.localizedDescription, privacy: .public)")
}
refreshStatus()
@@ -194,16 +195,16 @@ public final class HelperClient {
private func call(_ body: (OpenStatsHelperProtocol, @escaping @Sendable (String?) -> Void) -> Void) async -> String? {
refreshStatus()
guard isReady else { return "辅助工具未启用" }
guard isReady else { return tr("辅助工具未启用") }
let connection = ensureConnection()
return await withCheckedContinuation { (continuation: CheckedContinuation<String?, Never>) in
let once = ResumeOnce(continuation)
let proxy = connection.remoteObjectProxyWithErrorHandler { error in
Log.helper.error("XPC 调用失败:\(error.localizedDescription, privacy: .public)")
once.resume("无法连接辅助工具:\(error.localizedDescription)")
once.resume(tr("无法连接辅助工具:\(error.localizedDescription)"))
} as? OpenStatsHelperProtocol
guard let proxy else {
once.resume("无法连接辅助工具")
once.resume(tr("无法连接辅助工具"))
return
}
body(proxy) { once.resume($0) }
@@ -1,4 +1,5 @@
import Foundation
import Localization
import Metrics
import Observation
@@ -13,9 +14,9 @@ public final class HistoryRecorder {
var title: String {
switch self {
case .hour: "1 小时"
case .day: "24 小时"
case .week: "7 天"
case .hour: tr("1 小时")
case .day: tr("24 小时")
case .week: tr("7 天")
}
}
@@ -1,5 +1,6 @@
import Foundation
import IOKit.pwr_mgt
import Localization
import Metrics
import Observation
@@ -13,15 +14,15 @@ public final class KeepAwakeController {
var title: String {
switch self {
case .display: "屏幕保持常亮"
case .system: "仅系统不休眠"
case .display: tr("屏幕保持常亮")
case .system: tr("仅系统不休眠")
}
}
var subtitle: String {
switch self {
case .display: "屏幕和系统都不会因闲置而关闭或休眠"
case .system: "屏幕可按设置关闭,下载、编译等后台任务继续运行"
case .display: tr("屏幕和系统都不会因闲置而关闭或休眠")
case .system: tr("屏幕可按设置关闭,下载、编译等后台任务继续运行")
}
}
@@ -41,11 +42,11 @@ public final class KeepAwakeController {
var title: String {
switch self {
case .minutes15: "15 分钟"
case .hour1: "1 小时"
case .hours2: "2 小时"
case .hours5: "5 小时"
case .unlimited: "不限"
case .minutes15: tr("15 分钟")
case .hour1: tr("1 小时")
case .hours2: tr("2 小时")
case .hours5: tr("5 小时")
case .unlimited: tr("不限")
}
}
}
@@ -106,7 +107,7 @@ public final class KeepAwakeController {
releaseAssertion()
if lidClosedActive {
if let error = await helper.setSleepDisabled(false) {
show("恢复系统睡眠失败:\(error)", isError: true)
show(tr("恢复系统睡眠失败:\(error)"), isError: true)
return
}
lidClosedActive = false
@@ -127,7 +128,7 @@ public final class KeepAwakeController {
Task {
lidClosedRequested = false
await applyLidMode()
show("电量低于 \(settings.lidModeBatteryFloor)%,已关闭合盖运行", isError: false)
show(tr("电量低于 \(settings.lidModeBatteryFloor)%,已关闭合盖运行"), isError: false)
}
}
@@ -152,12 +153,12 @@ public final class KeepAwakeController {
var id: IOPMAssertionID = 0
let result = IOPMAssertionCreateWithName(mode.assertionType as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
"OpenStats 防休眠" as CFString,
tr("OpenStats 防休眠") as CFString,
&id)
if result == kIOReturnSuccess {
assertionID = id
} else {
show("无法创建电源断言(\(result))", isError: true)
show(tr("无法创建电源断言(\(result))"), isError: true)
}
}
@@ -178,7 +179,7 @@ public final class KeepAwakeController {
expiryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(end.timeIntervalSinceNow))
guard !Task.isCancelled else { return }
await self?.stop(reason: "已到设定时间,防休眠已关闭")
await self?.stop(reason: tr("已到设定时间,防休眠已关闭"))
}
}
@@ -1,5 +1,6 @@
import Foundation
import HelperShared
import Localization
import Metrics
import Observation
@@ -30,7 +31,7 @@ public final class MaintenanceController {
let before = MemorySampler().sample()
helper.refreshStatus()
let prompt = command == .flushDNS ? "OpenStats 需要管理员权限来刷新 DNS 缓存。" : "OpenStats 需要管理员权限来释放内存。"
let prompt = command == .flushDNS ? tr("OpenStats 需要管理员权限来刷新 DNS 缓存。") : tr("OpenStats 需要管理员权限来释放内存。")
let error = helper.isReady
? await helper.run(command)
: await Self.runWithAdministratorPrompt(shell: command.shellCommand, prompt: prompt)
@@ -42,11 +43,11 @@ public final class MaintenanceController {
}
switch command {
case .flushDNS:
outcomes[command] = Outcome(text: "DNS 缓存已刷新", isError: false)
outcomes[command] = Outcome(text: tr("DNS 缓存已刷新"), isError: false)
case .purgeMemory:
let after = MemorySampler().sample()
let freed = (before?.cached ?? 0) > (after?.cached ?? 0) ? before!.cached - after!.cached : 0
outcomes[command] = Outcome(text: freed > 0 ? "已释放 \(Format.bytes(freed)) 缓存内存" : "内存已整理", isError: false)
outcomes[command] = Outcome(text: freed > 0 ? tr("已释放 \(Format.bytes(freed)) 缓存内存") : tr("内存已整理"), isError: false)
}
}
@@ -55,7 +56,7 @@ public final class MaintenanceController {
guard !isApplyingDNS else { return }
guard DNSConfiguration.isValidServiceName(service), servers.count <= DNSConfiguration.maxServers,
servers.allSatisfy(DNSConfiguration.isValidAddress) else {
dnsOutcome = Outcome(text: "DNS 地址格式不正确", isError: true)
dnsOutcome = Outcome(text: tr("DNS 地址格式不正确"), isError: true)
return
}
isApplyingDNS = true
@@ -69,17 +70,17 @@ public final class MaintenanceController {
// killall 在 mDNSResponder 恰好重启时可能返回非 0,不视为失败
let shell = DNSConfiguration.shellCommand(service: service, servers: servers)
+ " && /usr/bin/dscacheutil -flushcache && (/usr/bin/killall -HUP mDNSResponder || true)"
error = await Self.runWithAdministratorPrompt(shell: shell, prompt: "OpenStats 需要管理员权限来修改“\(service)”的 DNS。")
error = await Self.runWithAdministratorPrompt(shell: shell, prompt: tr("OpenStats 需要管理员权限来修改“\(service)”的 DNS。"))
}
if let error {
Log.network.error("设置 DNS 失败:\(error, privacy: .public)")
dnsOutcome = Outcome(text: error, isError: error != Self.cancelled)
} else {
dnsOutcome = Outcome(text: servers.isEmpty ? "已恢复自动获取 DNS" : "DNS 已设置为 \(servers.joined(separator: "、"))", isError: false)
dnsOutcome = Outcome(text: servers.isEmpty ? tr("已恢复自动获取 DNS") : tr("DNS 已设置为 \(servers.joined(separator: tr("、")))"), isError: false)
}
}
nonisolated private static let cancelled = "已取消"
nonisolated private static let cancelled = tr("已取消")
/// 未安装辅助工具时的回退:通过 AppleScript 请求一次性管理员授权执行命令。
/// 命令只由固定路径与已校验的参数拼成,这里再做 AppleScript 字符串转义
@@ -1,4 +1,5 @@
import Foundation
import Localization
import Metrics
import Observation
import Security
@@ -80,15 +81,19 @@ public final class ProcessExplainer {
phase = .done
} catch {
guard !Task.isCancelled else { return }
phase = .failed("Apple 智能没有给出回答:\(error.localizedDescription)")
phase = .failed(tr("Apple 智能没有给出回答:\(error.localizedDescription)"))
}
return
}
#endif
phase = .failed("需要 macOS 26 及以上,并在系统设置中开启 Apple 智能。")
phase = .failed(tr("需要 macOS 26 及以上,并在系统设置中开启 Apple 智能。"))
}
private static let instructions = """
private static var instructions: String {
L10n.isEnglish ? englishInstructions : chineseInstructions
}
private static let chineseInstructions = """
你是熟悉 macOS 的系统工程师。用户会给出 Mac 上一个进程的事实信息(名称、路径、所属应用、签名方、资源占用)。
请只根据这些事实和你确定知道的知识,用简体中文回答三点,每点一两句:
1. 它是什么:属于哪个应用或系统组件,负责什么。
@@ -97,14 +102,23 @@ public final class ProcessExplainer {
不确定时明确说“不确定”,不要编造功能或来源。不使用 Markdown 标题,总共不超过 180 字。
"""
private static let englishInstructions = """
You are a systems engineer who knows macOS well. The user gives facts about a process on a Mac (name, path, owning app, signer, resource usage).
Using only these facts and what you know for certain, answer three points in English, one or two sentences each:
1. What it is: which app or system component it belongs to and what it does.
2. Whether its usage is normal, judging from the CPU and memory numbers given.
3. Whether it can be quit, and what happens if it is.
If unsure, say you're not sure; never invent features or origins. No Markdown headings; under 120 words in total.
"""
#if canImport(FoundationModels)
@available(macOS 26.0, *)
private static func message(for reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
switch reason {
case .deviceNotEligible: "这台 Mac 不支持 Apple 智能。"
case .appleIntelligenceNotEnabled: "请先在“系统设置 → Apple 智能与 Siri”中开启 Apple 智能。"
case .modelNotReady: "Apple 智能模型还在下载或准备中,请稍后再试。"
@unknown default: "Apple 智能暂时不可用。"
case .deviceNotEligible: tr("这台 Mac 不支持 Apple 智能。")
case .appleIntelligenceNotEnabled: tr("请先在“系统设置 → Apple 智能与 Siri”中开启 Apple 智能。")
case .modelNotReady: tr("Apple 智能模型还在下载或准备中,请稍后再试。")
@unknown default: tr("Apple 智能暂时不可用。")
}
}
#endif
@@ -113,34 +127,34 @@ public final class ProcessExplainer {
/// 交给模型的事实:路径位置、所属应用、签名方都能明显减少模型胡猜
nonisolated static func facts(for subject: Subject) -> String {
var lines = ["进程名:\(subject.name)"]
if subject.displayName != subject.name { lines.append("显示名称:\(subject.displayName)") }
var lines = [tr("进程名:\(subject.name)")]
if subject.displayName != subject.name { lines.append(tr("显示名称:\(subject.displayName)")) }
let path = subject.executablePath ?? executablePath(pid: subject.pid)
if let path {
lines.append("可执行文件:\(path)")
lines.append("位置:\(location(of: path))")
if let signer = signer(of: path) { lines.append("签名方:\(signer)") }
lines.append(tr("可执行文件:\(path)"))
lines.append(tr("位置:\(location(of: path))"))
if let signer = signer(of: path) { lines.append(tr("签名方:\(signer)")) }
}
if let bundlePath = subject.appBundlePath, let bundle = Bundle(path: bundlePath) {
let name = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
?? URL(fileURLWithPath: bundlePath).deletingPathExtension().lastPathComponent
lines.append("所属应用:\(name)(\(bundle.bundleIdentifier ?? "无 Bundle ID"))")
lines.append(tr("所属应用:\(name)(\(bundle.bundleIdentifier ?? tr("无 Bundle ID")))"))
}
if let cpu = subject.cpu { lines.append("CPU:\(Format.percent(cpu))(以单核满载为 100%)") }
if let memory = subject.memory { lines.append("内存:\(Format.bytes(memory))") }
if let rate = subject.networkRate { lines.append("网络:\(Format.menuBarRate(rate))") }
if let cpu = subject.cpu { lines.append(tr("CPU:\(Format.percent(cpu))(以单核满载为 100%)")) }
if let memory = subject.memory { lines.append(tr("内存:\(Format.bytes(memory))")) }
if let rate = subject.networkRate { lines.append(tr("网络:\(Format.menuBarRate(rate))")) }
return lines.joined(separator: "\n")
}
nonisolated private static func location(of path: String) -> String {
if path.hasPrefix("/System/") || path.hasPrefix("/usr/") || path.hasPrefix("/sbin/") || path.hasPrefix("/bin/") {
return "macOS 系统目录(系统组件)"
return tr("macOS 系统目录(系统组件)")
}
if path.hasPrefix("/Applications/") || path.contains("/Applications/") { return "应用程序文件夹" }
if path.hasPrefix("/Library/") { return "系统级资源库(第三方驱动、辅助工具或后台服务)" }
if path.contains("/Library/") { return "用户资源库" }
return "其他位置"
if path.hasPrefix("/Applications/") || path.contains("/Applications/") { return tr("应用程序文件夹") }
if path.hasPrefix("/Library/") { return tr("系统级资源库(第三方驱动、辅助工具或后台服务)") }
if path.contains("/Library/") { return tr("用户资源库") }
return tr("其他位置")
}
nonisolated private static func signer(of path: String) -> String? {
@@ -149,7 +163,7 @@ public final class ProcessExplainer {
var requirement: SecRequirement?
if SecRequirementCreateWithString("anchor apple" as CFString, [], &requirement) == errSecSuccess, let requirement,
SecStaticCodeCheckValidity(code, [], requirement) == errSecSuccess {
return "Apple(系统自带)"
return tr("Apple(系统自带)")
}
var information: CFDictionary?
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
@@ -157,9 +171,9 @@ public final class ProcessExplainer {
if let team = dictionary[kSecCodeInfoTeamIdentifier as String] as? String {
let certificates = dictionary[kSecCodeInfoCertificates as String] as? [SecCertificate]
let summary = certificates?.first.flatMap { SecCertificateCopySubjectSummary($0) as String? }
return [summary, "团队 \(team)"].compactMap { $0 }.joined(separator: ",")
return [summary, tr("团队 \(team)")].compactMap { $0 }.joined(separator: tr(","))
}
return "未签名或临时签名"
return tr("未签名或临时签名")
}
nonisolated private static func executablePath(pid: Int32) -> String? {
@@ -1,6 +1,7 @@
import AppKit
import Cleaner
import Foundation
import Localization
import Metrics
import Observation
@@ -70,7 +71,7 @@ public final class UninstallerController {
/// 拖进来的 .app
func select(url: URL) {
guard let app = AppUninstaller.app(at: url) else {
outcome = ("不是应用程序", true)
outcome = (tr("不是应用程序"), true)
return
}
do {
@@ -117,14 +118,14 @@ public final class UninstallerController {
guard let self else { return }
self.isRemoving = false
if movedCount == 0 {
self.outcome = ("没有移动任何文件\(message.map { ":\($0)" } ?? "")", true)
self.outcome = (tr("没有移动任何文件\(message.map { tr(":\($0)") } ?? "")"), true)
return
}
var dock = false
if self.removeFromDock { dock = Self.removeDockTile(for: app.url) }
Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),移到废纸篓 \(movedCount) 项")
let partial = movedCount < urls.count ? ",\(urls.count - movedCount) 项未能移动" : ""
self.outcome = ("已将 \(app.name) 与 \(movedCount - 1) 项残留移到废纸篓,约 \(Format.bytes(freed, base: .decimal))\(dock ? ",已从程序坞移除" : "")\(partial)。需要时可以在废纸篓里放回。",
let partial = movedCount < urls.count ? tr(",\(urls.count - movedCount) 项未能移动") : ""
self.outcome = (tr("已将 \(app.name) 与 \(movedCount - 1) 项残留移到废纸篓,约 \(Format.bytes(freed, base: .decimal))\(dock ? tr(",已从程序坞移除") : "")\(partial)。需要时可以在废纸篓里放回。"),
message != nil)
self.selected = nil
self.leftovers = []
@@ -1,5 +1,6 @@
import AppKit
import Foundation
import Localization
import Observation
import Updates
@@ -58,8 +59,8 @@ public final class UpdateController {
/// 开发构建没有 Developer ID 签名,无法校验新版的签名团队,只能手动安装
var installBlockedReason: String? {
guard let bundleID = Bundle.main.bundleIdentifier, bundleID == "com.openstats.app" else { return "开发构建不支持在线升级" }
guard UpdateInstaller.teamIdentifier(of: Bundle.main.bundleURL) != nil else { return "开发构建不支持在线升级,请下载安装包" }
guard let bundleID = Bundle.main.bundleIdentifier, bundleID == "com.openstats.app" else { return tr("开发构建不支持在线升级") }
guard UpdateInstaller.teamIdentifier(of: Bundle.main.bundleURL) != nil else { return tr("开发构建不支持在线升级,请下载安装包") }
return nil
}
@@ -104,9 +105,9 @@ public final class UpdateController {
do {
let (data, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
return .failure(.download("服务器返回 \(http.statusCode)"))
return .failure(.download(tr("服务器返回 \(http.statusCode)")))
}
guard let release = UpdateFeed.parse(data) else { return .failure(.download("版本清单格式不正确")) }
guard let release = UpdateFeed.parse(data) else { return .failure(.download(tr("版本清单格式不正确"))) }
return .success(release)
} catch {
return .failure(.download(error.localizedDescription))
@@ -132,7 +133,7 @@ public final class UpdateController {
return
}
guard UpdateFeed.systemSatisfies(release.minimumSystem) else {
phase = .failed("新版本需要 macOS \(release.minimumSystem) 或更高版本")
phase = .failed(tr("新版本需要 macOS \(release.minimumSystem) 或更高版本"))
return
}
let app = Bundle.main.bundleURL
@@ -201,11 +202,11 @@ public final class UpdateController {
// 旧版改名备份 → 新版移入;移入失败时还原旧版并以失败退出;成功后沿用旧版的属主
let shell = "/bin/mv -f \(a) \(b) && { /bin/mv -f \(c) \(a) || { /bin/mv -f \(b) \(a); exit 1; }; }"
+ " && /usr/sbin/chown -R \"$(/usr/bin/stat -f %u:%g \(b))\" \(a)"
if let error = await MaintenanceController.runWithAdministratorPrompt(shell: shell, prompt: "OpenStats 需要授权以安装新版本。") {
if let error = await MaintenanceController.runWithAdministratorPrompt(shell: shell, prompt: tr("OpenStats 需要授权以安装新版本。")) {
throw UpdateError.install(error)
}
guard FileManager.default.fileExists(atPath: app.appendingPathComponent("Contents/Info.plist").path) else {
throw UpdateError.install("替换后未找到应用")
throw UpdateError.install(tr("替换后未找到应用"))
}
}
@@ -8,6 +8,7 @@
import Foundation
import IOKit
import Localization
public struct SMCKey: Hashable, Sendable, CustomStringConvertible {
public let code: UInt32
@@ -39,11 +40,11 @@ public enum SMCError: Error, Sendable, CustomStringConvertible {
public var description: String {
switch self {
case .serviceNotFound: "未找到 AppleSMC 服务"
case .openFailed(let kr): "打开 SMC 失败 (\(Self.message(kr)))"
case .callFailed(let key, let kr): "SMC [\(key)] 调用失败 (\(Self.message(kr)))"
case .firmwareRejected(let key, let code): "SMC [\(key)] 被固件拒绝 (0x\(String(code, radix: 16)))"
case .unsupportedType(let key, let type): "SMC [\(key)] 不支持的数据类型 \(type)"
case .serviceNotFound: tr("未找到 AppleSMC 服务")
case .openFailed(let kr): tr("打开 SMC 失败 (\(Self.message(kr)))")
case .callFailed(let key, let kr): tr("SMC [\(key)] 调用失败 (\(Self.message(kr)))")
case .firmwareRejected(let key, let code): tr("SMC [\(key)] 被固件拒绝 (0x\(String(code, radix: 16)))")
case .unsupportedType(let key, let type): tr("SMC [\(key)] 不支持的数据类型 \(type)")
}
}
@@ -1,4 +1,5 @@
import Foundation
import Localization
public enum TemperatureGroup: String, Sendable, CaseIterable, Codable {
case cpu, gpu, memory, battery, palmRest
@@ -7,9 +8,9 @@ public enum TemperatureGroup: String, Sendable, CaseIterable, Codable {
switch self {
case .cpu: "CPU"
case .gpu: "GPU"
case .memory: "内存"
case .battery: "电池"
case .palmRest: "掌托"
case .memory: tr("内存")
case .battery: tr("电池")
case .palmRest: tr("掌托")
}
}
}
@@ -1,5 +1,6 @@
import CryptoKit
import Foundation
import Localization
import Security
public enum UpdateError: Error, LocalizedError, Equatable {
@@ -13,13 +14,13 @@ public enum UpdateError: Error, LocalizedError, Equatable {
public var errorDescription: String? {
switch self {
case .download(let reason): "下载失败:\(reason)"
case .checksumMismatch: "安装包校验值与版本清单不一致,已放弃安装"
case .archive(let reason): "解压失败:\(reason)"
case .invalidBundle(let reason): "安装包内容不正确:\(reason)"
case .signature(let reason): "签名校验未通过:\(reason)"
case .gatekeeper: "新版本未通过 Apple 公证检查,已放弃安装"
case .install(let reason): "替换应用失败:\(reason)"
case .download(let reason): tr("下载失败:\(reason)")
case .checksumMismatch: tr("安装包校验值与版本清单不一致,已放弃安装")
case .archive(let reason): tr("解压失败:\(reason)")
case .invalidBundle(let reason): tr("安装包内容不正确:\(reason)")
case .signature(let reason): tr("签名校验未通过:\(reason)")
case .gatekeeper: tr("新版本未通过 Apple 公证检查,已放弃安装")
case .install(let reason): tr("替换应用失败:\(reason)")
}
}
}
@@ -56,7 +57,7 @@ public enum UpdateInstaller {
guard result.status == 0 else { throw UpdateError.archive(result.output) }
let apps = (try? FileManager.default.contentsOfDirectory(at: output, includingPropertiesForKeys: nil))?
.filter { $0.pathExtension == "app" } ?? []
guard apps.count == 1, let app = apps.first else { throw UpdateError.archive("压缩包里应当只有一个 .app") }
guard apps.count == 1, let app = apps.first else { throw UpdateError.archive(tr("压缩包里应当只有一个 .app")) }
return app
}
@@ -64,28 +65,28 @@ public enum UpdateInstaller {
/// 包名、版本号、签名团队必须与预期一致,并通过严格签名校验与 Gatekeeper(公证)检查
public static func verify(_ app: URL, bundleIdentifier: String, version: String, teamIdentifier: String) throws {
guard let bundle = Bundle(url: app) else { throw UpdateError.invalidBundle("无法读取") }
guard let bundle = Bundle(url: app) else { throw UpdateError.invalidBundle(tr("无法读取")) }
guard bundle.bundleIdentifier == bundleIdentifier else {
throw UpdateError.invalidBundle("包名是 \(bundle.bundleIdentifier ?? "空")")
throw UpdateError.invalidBundle(tr("包名是 \(bundle.bundleIdentifier ?? tr("空"))"))
}
let bundleVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
guard bundleVersion == version else {
throw UpdateError.invalidBundle("版本是 \(bundleVersion ?? "空"),清单写的是 \(version)")
throw UpdateError.invalidBundle(tr("版本是 \(bundleVersion ?? tr("空")),清单写的是 \(version)"))
}
var staticCode: SecStaticCode?
guard SecStaticCodeCreateWithPath(app as CFURL, [], &staticCode) == errSecSuccess, let code = staticCode else {
throw UpdateError.signature("无法读取签名")
throw UpdateError.signature(tr("无法读取签名"))
}
let requirementText = "anchor apple generic and identifier \"\(bundleIdentifier)\" and certificate leaf[subject.OU] = \"\(teamIdentifier)\""
var requirement: SecRequirement?
guard SecRequirementCreateWithString(requirementText as CFString, [], &requirement) == errSecSuccess else {
throw UpdateError.signature("无法创建签名要求")
throw UpdateError.signature(tr("无法创建签名要求"))
}
let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode | kSecCSStrictValidate)
let status = SecStaticCodeCheckValidity(code, flags, requirement)
guard status == errSecSuccess else {
throw UpdateError.signature("不是团队 \(teamIdentifier) 签名的完整应用(\(status))")
throw UpdateError.signature(tr("不是团队 \(teamIdentifier) 签名的完整应用(\(status))"))
}
let assessment = run("/usr/sbin/spctl", ["--assess", "--type", "execute", "-vv", app.path])
@@ -198,7 +199,7 @@ private final class Downloader: NSObject, URLSessionDownloadDelegate, @unchecked
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
if let response = downloadTask.response as? HTTPURLResponse, !(200..<300).contains(response.statusCode) {
moveError = UpdateError.download("服务器返回 \(response.statusCode)")
moveError = UpdateError.download(tr("服务器返回 \(response.statusCode)"))
return
}
do {
@@ -0,0 +1,19 @@
import Testing
@testable import Localization
@Suite(.serialized) struct LocalizationTests {
@Test func translatesExactAndTemplates() {
L10n.configure(.english)
defer { L10n.configure(.chinese) }
#expect(tr("内存") == "Memory")
#expect(tr("12 个进程") == "12 processes")
#expect(tr("OpenStats") == "OpenStats")
L10n.configure(.chinese)
#expect(tr("内存") == "内存")
}
@Test func resolvesSystemLanguage() {
#expect(AppLanguage.chinese.resolved == .chinese)
#expect(AppLanguage.english.resolved == .english)
}
}
Binary file not shown.
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""把 Swift 源码里的中文字符串字面量包进 tr(...),并输出需要翻译的文案模板。
Scripts/l10n_wrap.py --apply # 改写源码
Scripts/l10n_wrap.py --list > keys.txt # 只列出模板(插值写成 {})
跳过注释、多行字符串、原始字符串、日志调用(Logger 需要字面量)、case 模式与 enum 原始值,
以及已经包在 tr(...) 里的字面量。插值里嵌套的中文字面量也会各自包起来。
"""
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent / "Packages/OpenStatsKit/Sources"
MODULES = ["OpenStatsUI", "Metrics", "Cleaner", "HelperShared", "Updates", "SMC"]
CJK = re.compile(r"[一-鿿 -〿＀-￯]")
LOG_CALL = re.compile(r"(\bLog\.\w+|\blog|\blogger)\.(notice|error|info|debug|warning|fault|trace|critical)\($")
CASE_PATTERN = re.compile(r"^\s*case\s*$|^\s*case\s+\w+\s*=\s*$|,\s*$(?<=case\s)")
keys = set()
def parse_interpolation(s, i):
"""s[i] 是 '\\(' 之后的第一个字符,返回 (内部代码, 结束位置 ')' 的下标)"""
depth = 1
j = i
while j < len(s):
c = s[j]
if c == '"':
_, j = parse_string(s, j, transform=False)
continue
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return s[i:j], j
j += 1
raise ValueError("unterminated interpolation")
def parse_string(s, i, transform=True):
"""s[i] == '"',返回 (改写后的字面量, 下一个位置);同时算出不含插值的文字与模板"""
j = i + 1
out = ['"']
text = []
template = []
while j < len(s):
c = s[j]
if c == "\\":
if s[j + 1] == "(":
inner, end = parse_interpolation(s, j + 2)
out.append("\\(" + (transform_code(inner) if transform else inner) + ")")
template.append("{}")
j = end + 1
continue
out.append(s[j:j + 2])
template.append(s[j:j + 2].replace('\\"', '"').replace("\\n", "\\n"))
j += 2
continue
if c == '"':
out.append('"')
return ("".join(out), "".join(text), "".join(template)), j + 1
if c == "\n":
raise ValueError("newline in string")
out.append(c)
text.append(c)
template.append(c)
j += 1
raise ValueError("unterminated string")
def transform_code(s):
out = []
i = 0
n = len(s)
while i < n:
if s.startswith("//", i):
end = s.find("\n", i)
end = n if end < 0 else end
out.append(s[i:end])
i = end
elif s.startswith("/*", i):
end = s.find("*/", i + 2)
end = n if end < 0 else end + 2
out.append(s[i:end])
i = end
elif s.startswith('"""', i):
end = s.find('"""', i + 3)
end = n if end < 0 else end + 3
out.append(s[i:end])
i = end
elif s.startswith('#"', i):
end = s.find('"#', i + 2)
end = n if end < 0 else end + 2
out.append(s[i:end])
i = end
elif s[i] == '"':
prefix = "".join(out)
line = prefix[prefix.rfind("\n") + 1:]
stripped = prefix.rstrip()
skip = bool(LOG_CALL.search(line.rstrip())) or bool(re.search(r"^\s*case\s*$|^\s*case\s+\w+\s*=\s*$", line)) \
or stripped.endswith("tr(")
(literal, text, template), end = parse_string(s, i, transform=not skip)
if skip:
out.append(s[i:end])
elif CJK.search(text):
keys.add(template)
out.append("tr(" + literal + ")")
else:
out.append(literal)
i = end
else:
out.append(s[i])
i += 1
return "".join(out)
def add_import(source):
if re.search(r"^import Localization$", source, re.M):
return source
imports = list(re.finditer(r"^import [\w.]+\n", source, re.M))
if not imports:
return "import Localization\n" + source
last = imports[-1]
return source[:last.end()] + "import Localization\n" + source[last.end():] if False else \
source[:imports[0].start()] + "".join(sorted(set([m.group(0) for m in imports] + ["import Localization\n"]))) + source[last.end():]
def main():
apply = "--apply" in sys.argv
for module in MODULES:
for path in sorted((ROOT / module).rglob("*.swift")):
source = path.read_text(encoding="utf-8")
updated = transform_code(source)
if apply and updated != source:
path.write_text(add_import(updated), encoding="utf-8")
if "--list" in sys.argv:
for key in sorted(keys):
print(key)
print(f"{len(keys)} 条文案", file=sys.stderr)
if __name__ == "__main__":
main()