英文界面:翻译层、语言设置与英文文案
This commit is contained in:
1 parent
de7a50b57c
commit
e6bed09dc0
63 files changed
+2288
-925
No files matched your search
@@ -21,6 +21,7 @@
|
||||
- 蓝牙设备电量:本机信息页列出已连接的键盘、鼠标、触控板和耳机的电量(AirPods 分左耳、右耳、充电盒),低于 20% 标红;通知里新增“蓝牙设备电量低”(低于 15%)。
|
||||
- 全局快捷键(设置 · 通用):可为显示 / 隐藏主窗口、打开进程页、开关防休眠、释放内存录制快捷键,在任何应用中都能用,不需要辅助功能权限;被其他应用占用时提示更换。
|
||||
- 桌面小组件“系统概览”:小号显示 CPU、内存圆环与磁盘用量,中号显示 CPU、内存、磁盘、电池四个圆环和磁盘可用空间;在自己的沙盒进程里读取数据,主应用没开也能显示,刷新间隔由系统决定(几分钟一次)。
|
||||
- 英文界面(设置 · 通用 · 语言):跟随系统、简体中文、English 三选一,系统首选语言不是中文时默认显示英文;约 880 条界面文案、通知、错误提示和 Apple 智能解释的回答都有英文版本,日期按英文格式显示,切换后重启生效。
|
||||
|
||||
#### 调整
|
||||
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
Reference in new issue
Block a user