- 账号与同步:GitHub / Google / Apple 登录后把菜单栏项目与风格等偏好同步到 getopenstats.com,换一台 Mac 登录即恢复(AccountSync 模块、SyncController、SettingsDocument、账号设置页,openstats:// 回调) - 磁盘页成为工具页:已用 / 可清除 / 可用分段容量条、空间分析、文件系统检查、本地快照清理(辅助工具协议 v4)、其他磁盘推出 - 公网 IP:归属地数据源可选 CleanIP.io / ipapi.is / DB-IP / ipinfo.io / 本地 GeoLite2,每台 Mac 自己查、本地缓存 7 天;IPv4 / IPv6 双栈各自查询与切换;弹窗显示 ASN、运营方、原生 / 住宅等徽章与纯净度评分色带;接口区块可重置上传 / 下载统计 - 弹窗:右上角按钮改为该指标自己的图标 + 总设置;主窗口页面标题栏的开关点不动已修复;滚动条改为悬浮式,内容不再偏左;网络弹窗卡片被标题行撑宽的问题已修复 - 设置:⌘ 拖动排序说明;每个菜单栏项目单独选风格并带实时预览;网络与磁盘的进程榜单固定 5 行并平滑排序;CPU 持续高负载通知 - 设计系统:分段条、评分色带、标签徽章、胶囊切换、刷新按钮等组件与徽章配色 token - 更新日志、架构说明、README 与译文同步
642 lines
26 KiB
Swift
642 lines
26 KiB
Swift
import Foundation
|
||
import Localization
|
||
import Metrics
|
||
import Observation
|
||
|
||
public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||
case cpu, memory, network, gpu, disk, temperature, fan
|
||
|
||
public var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .cpu: "CPU"
|
||
case .memory: tr("内存")
|
||
case .network: tr("网络")
|
||
case .gpu: "GPU"
|
||
case .disk: tr("磁盘")
|
||
case .temperature: tr("CPU 温度")
|
||
case .fan: tr("风扇转速")
|
||
}
|
||
}
|
||
|
||
var subtitle: String {
|
||
switch self {
|
||
case .cpu: tr("总占用")
|
||
case .memory: tr("已用内存占比")
|
||
case .network: tr("上传与下载速度、IP 地址、DNS")
|
||
case .gpu: tr("GPU 占用")
|
||
case .disk: tr("启动磁盘已用占比")
|
||
case .temperature: tr("CPU 核心最高温度")
|
||
case .fan: tr("转速最高的风扇")
|
||
}
|
||
}
|
||
|
||
/// 菜单栏上方的小标签
|
||
var menuBarLabel: String {
|
||
switch self {
|
||
case .cpu: "CPU"
|
||
case .memory: "RAM"
|
||
case .network: "NET"
|
||
case .gpu: "GPU"
|
||
case .disk: "DISK"
|
||
case .temperature: "TEMP"
|
||
case .fan: "FAN"
|
||
}
|
||
}
|
||
|
||
/// 详情弹窗标题
|
||
var popoverTitle: String {
|
||
switch self {
|
||
case .cpu: "CPU"
|
||
case .memory: tr("内存")
|
||
case .network: tr("网络")
|
||
case .gpu: "GPU"
|
||
case .disk: tr("磁盘")
|
||
case .temperature: tr("温度")
|
||
case .fan: tr("风扇")
|
||
}
|
||
}
|
||
|
||
/// 点击该项弹出的详情里可以显示的内容,按显示顺序排列
|
||
var popoverSections: [PopoverSection] {
|
||
switch self {
|
||
case .cpu: [.cpuCores, .cpuHeatmap, .cpuClusters, .cpuLoadAverage, .cpuApps]
|
||
case .memory: [.memoryWaterline, .memoryCompression, .memoryApps]
|
||
case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkPurity, .networkDNS, .networkProcesses]
|
||
case .gpu: [.gpuHistory, .gpuDetails]
|
||
case .disk: [.diskActivity, .diskHealth, .diskProcesses]
|
||
case .temperature: [.thermalSensors, .thermalFans, .thermalPower]
|
||
case .fan: [.thermalFans, .thermalSensors, .thermalPower]
|
||
}
|
||
}
|
||
|
||
/// 可以单独指定风格的指标(网速有自己的样式)
|
||
var supportsStyleOverride: Bool { self != .network }
|
||
|
||
var symbol: String {
|
||
switch self {
|
||
case .cpu: "cpu"
|
||
case .memory: "memorychip"
|
||
case .network: "arrow.up.arrow.down"
|
||
case .gpu: "square.3.layers.3d"
|
||
case .disk: "internaldrive"
|
||
case .temperature: "thermometer.medium"
|
||
case .fan: "fan"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 详情弹窗里可以单独隐藏的区块
|
||
public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
|
||
case cpuHeatmap, cpuCores, cpuClusters, cpuLoadAverage, cpuApps
|
||
case memoryWaterline, memoryCompression, memoryApps
|
||
case networkHistory, networkProbe, networkInterface, networkAddresses, networkPurity, networkDNS, networkProcesses
|
||
case gpuHistory, gpuDetails
|
||
case diskActivity, diskHealth, diskProcesses
|
||
case thermalSensors, thermalFans, thermalPower
|
||
|
||
public var id: String { rawValue }
|
||
|
||
/// 弹窗默认不显示的区块(主窗口详情页始终显示):热力图已经能看出各核心此刻的占用,
|
||
/// 每核柱状图给想要一眼看清单个核心的人在设置里打开,避免弹窗默认太长
|
||
static let hiddenByDefault: Set<PopoverSection> = [.cpuCores]
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .cpuHeatmap: tr("核心热力图")
|
||
case .cpuCores: 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 .networkPurity: tr("IP 纯净度")
|
||
case .networkDNS: "DNS"
|
||
case .gpuHistory: tr("使用历史")
|
||
case .gpuDetails: tr("显卡信息")
|
||
case .diskActivity: tr("读写速度")
|
||
case .diskHealth: tr("SSD 健康")
|
||
case .diskProcesses: tr("读写最多的应用")
|
||
case .thermalSensors: tr("温度")
|
||
case .thermalFans: tr("风扇")
|
||
case .thermalPower: tr("功耗")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 菜单栏布局
|
||
public enum MenuBarLayout: String, CaseIterable, Identifiable, Sendable {
|
||
/// 每个指标一个图标,点击弹出该项详情
|
||
case separate
|
||
/// 所有指标合成一个图标,点击打开主窗口
|
||
case combined
|
||
|
||
public var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .separate: tr("每项独立")
|
||
case .combined: tr("合并为一个")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 连接探测的目标
|
||
public enum ProbeTarget: String, CaseIterable, Identifiable, Sendable {
|
||
case cloudflare, google, aliyun, tencent, gateway
|
||
|
||
public var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
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("路由器(网关)")
|
||
}
|
||
}
|
||
|
||
/// 网关地址随网络变化,由调用方传入
|
||
func address(router: String?) -> String? {
|
||
switch self {
|
||
case .cloudflare: "1.1.1.1"
|
||
case .google: "8.8.8.8"
|
||
case .aliyun: "223.5.5.5"
|
||
case .tencent: "119.29.29.29"
|
||
case .gateway: router
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 公网 IP 归属地的数据源
|
||
public enum GeoSource: String, CaseIterable, Identifiable, Sendable {
|
||
case cleanIP, ipapi, dbip, ipinfo, localDatabase
|
||
|
||
public var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .cleanIP: "CleanIP.io"
|
||
case .ipapi: "ipapi.is"
|
||
case .dbip: "DB-IP"
|
||
case .ipinfo: "ipinfo.io"
|
||
case .localDatabase: tr("本地 GeoLite2 数据库")
|
||
}
|
||
}
|
||
|
||
var detail: String {
|
||
switch self {
|
||
case .cleanIP: tr("归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名;把公网 IP 发给 cleanip.io")
|
||
case .ipapi: tr("归属地与 ASN;把公网 IP 发给 ipapi.is,匿名额度每天 30 次,同一 IP 的结果记住一天")
|
||
case .dbip: tr("只有国家、省 / 州与城市;把公网 IP 发给 db-ip.com")
|
||
case .ipinfo: tr("归属地与 ASN;把公网 IP 发给 ipinfo.io,匿名额度很小,容易被限流")
|
||
case .localDatabase: tr("在本机的 MaxMind 数据库里查国家、城市与 ASN,不向任何服务发送 IP;数据库未就绪时暂用 CleanIP.io")
|
||
}
|
||
}
|
||
|
||
/// 对应的在线数据源;本地数据库没有
|
||
var provider: GeoProvider? {
|
||
switch self {
|
||
case .cleanIP: .cleanIP
|
||
case .ipapi: .ipapi
|
||
case .dbip: .dbip
|
||
case .ipinfo: .ipinfo
|
||
case .localDatabase: nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 菜单栏风格:整体选一种套用到所有指标,个别指标可以单独指定(AppSettings.styleOverrides);风格内部的排版保持一致
|
||
public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
|
||
case stacked, inline, icon, ring, pie, history, line, meter, dot
|
||
|
||
public var id: String { rawValue }
|
||
|
||
/// 温度、风扇没有百分比,图形类风格画不出来,只给文字类的三种
|
||
static func options(for item: MenuBarItem) -> [MenuBarStyle] {
|
||
switch item {
|
||
case .temperature, .fan: [.stacked, .inline, .icon]
|
||
default: allCases
|
||
}
|
||
}
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .stacked: tr("双行文字")
|
||
case .inline: tr("单行文字")
|
||
case .icon: tr("图标")
|
||
case .ring: tr("圆环")
|
||
case .pie: tr("饼图")
|
||
case .history: tr("柱状历史")
|
||
case .line: tr("折线历史")
|
||
case .meter: tr("电量条")
|
||
case .dot: tr("状态圆点")
|
||
}
|
||
}
|
||
|
||
var detail: String {
|
||
switch self {
|
||
case .stacked: tr("小标签在上、数值在下,最紧凑")
|
||
case .inline: tr("标签与数值同一行,最易读")
|
||
case .icon: tr("用系统图标代替文字标签")
|
||
case .ring: tr("圆环表示当前占用比例")
|
||
case .pie: tr("饼图表示当前占用比例")
|
||
case .history: tr("10 根柱子是最近 10 次采样(约 20 秒)的变化")
|
||
case .line: tr("折线是最近 30 次采样(约 1 分钟)的走势")
|
||
case .meter: tr("竖向电量条表示当前占用比例")
|
||
case .dot: tr("绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)")
|
||
}
|
||
}
|
||
}
|
||
|
||
public enum NetworkMenuStyle: String, CaseIterable, Identifiable, Sendable {
|
||
case dots, arrows, inline
|
||
|
||
public var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
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"
|
||
}
|
||
}
|
||
}
|
||
|
||
public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
|
||
case system, light, dark
|
||
|
||
public var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .system: tr("跟随系统")
|
||
case .light: tr("浅色")
|
||
case .dark: tr("深色")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 主窗口侧边栏的页面
|
||
public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||
case overview, system, history, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner, uninstaller, startupItems
|
||
case settingsGeneral, settingsMenuBar, settingsNotifications, settingsAccount, settingsHelper, settingsAbout
|
||
|
||
public var id: String { rawValue }
|
||
|
||
static let monitors: [PanelTab] = [.overview, .system, .history, .cpu, .gpu, .memory, .disk, .network, .thermal]
|
||
static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .cleaner, .uninstaller]
|
||
static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsAccount, .settingsHelper, .settingsAbout]
|
||
|
||
var isSettings: Bool { Self.settings.contains(self) }
|
||
|
||
/// 页面顶栏的标题:设置页带上分组名
|
||
var headerTitle: String { isSettings ? tr("设置 · \(title)") : title }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .overview: tr("仪表盘")
|
||
case .system: tr("本机信息")
|
||
case .history: tr("历史")
|
||
case .cpu: "CPU"
|
||
case .gpu: "GPU"
|
||
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 .settingsAccount: tr("账号与同步")
|
||
case .settingsHelper: tr("辅助工具")
|
||
case .settingsAbout: tr("关于")
|
||
}
|
||
}
|
||
|
||
var symbol: String {
|
||
switch self {
|
||
case .overview: "square.grid.2x2"
|
||
case .system: "laptopcomputer"
|
||
case .history: "clock.arrow.circlepath"
|
||
case .cpu: "cpu"
|
||
case .gpu: "square.3.layers.3d"
|
||
case .memory: "memorychip"
|
||
case .disk: "internaldrive"
|
||
case .network: "network"
|
||
case .thermal: "fan"
|
||
case .processes: "list.bullet.rectangle"
|
||
case .keepAwake: "cup.and.saucer"
|
||
case .cleaner: "sparkles"
|
||
case .uninstaller: "trash"
|
||
case .startupItems: "power"
|
||
case .settingsGeneral: "gearshape"
|
||
case .settingsMenuBar: "menubar.rectangle"
|
||
case .settingsNotifications: "bell.badge"
|
||
case .settingsAccount: "person.crop.circle"
|
||
case .settingsHelper: "lock.shield"
|
||
case .settingsAbout: "info.circle"
|
||
}
|
||
}
|
||
|
||
/// 页面对应的菜单栏项目,页面右上角可直接开关
|
||
var menuBarItem: MenuBarItem? {
|
||
switch self {
|
||
case .cpu: .cpu
|
||
case .gpu: .gpu
|
||
case .memory: .memory
|
||
case .network: .network
|
||
case .disk: .disk
|
||
case .thermal: .temperature
|
||
default: nil
|
||
}
|
||
}
|
||
|
||
/// 页面右上角可以直接开关的菜单栏项目:温度与风扇页有温度、风扇两项
|
||
var menuBarItems: [MenuBarItem] {
|
||
switch self {
|
||
case .thermal: [.temperature, .fan]
|
||
default: menuBarItem.map { [$0] } ?? []
|
||
}
|
||
}
|
||
|
||
init(item: MenuBarItem) {
|
||
switch item {
|
||
case .cpu: self = .cpu
|
||
case .gpu: self = .gpu
|
||
case .memory: self = .memory
|
||
case .network: self = .network
|
||
case .disk: self = .disk
|
||
case .temperature, .fan: self = .thermal
|
||
}
|
||
}
|
||
}
|
||
|
||
/// UserDefaults 持久化的偏好设置
|
||
@MainActor
|
||
@Observable
|
||
public final class AppSettings {
|
||
@ObservationIgnored private let defaults: UserDefaults
|
||
|
||
public var menuBarItems: Set<MenuBarItem> {
|
||
didSet { defaults.set(menuBarItems.map(\.rawValue).sorted(), forKey: Keys.menuBarItems) }
|
||
}
|
||
public var menuBarStyle: MenuBarStyle {
|
||
didSet { defaults.set(menuBarStyle.rawValue, forKey: Keys.menuBarStyle) }
|
||
}
|
||
public var networkStyle: NetworkMenuStyle {
|
||
didSet { defaults.set(networkStyle.rawValue, forKey: Keys.networkStyle) }
|
||
}
|
||
/// 个别指标单独指定的风格;未指定的跟随 menuBarStyle
|
||
public var styleOverrides: [MenuBarItem: MenuBarStyle] {
|
||
didSet {
|
||
defaults.set(Dictionary(uniqueKeysWithValues: styleOverrides.map { ($0.key.rawValue, $0.value.rawValue) }),
|
||
forKey: Keys.styleOverrides)
|
||
}
|
||
}
|
||
public var refreshSeconds: Int {
|
||
didSet { defaults.set(refreshSeconds, forKey: Keys.refreshSeconds) }
|
||
}
|
||
public var colorizeHighLoad: Bool {
|
||
didSet { defaults.set(colorizeHighLoad, forKey: Keys.colorizeHighLoad) }
|
||
}
|
||
public var useFahrenheit: Bool {
|
||
didSet { defaults.set(useFahrenheit, forKey: Keys.useFahrenheit) }
|
||
}
|
||
/// 电量低于该值时自动关闭“合盖运行”
|
||
public var lidModeBatteryFloor: Int {
|
||
didSet { defaults.set(lidModeBatteryFloor, forKey: Keys.lidModeBatteryFloor) }
|
||
}
|
||
/// 自定义风扇模式下 CPU 达到该温度时恢复系统控制
|
||
public var fanSafetyTemperature: Int {
|
||
didSet { defaults.set(fanSafetyTemperature, forKey: Keys.fanSafetyTemperature) }
|
||
}
|
||
public var panelTab: PanelTab {
|
||
didSet { defaults.set(panelTab.rawValue, forKey: Keys.panelTab) }
|
||
}
|
||
public var appearance: AppearanceMode {
|
||
didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) }
|
||
}
|
||
public var menuBarLayout: MenuBarLayout {
|
||
didSet { defaults.set(menuBarLayout.rawValue, forKey: Keys.menuBarLayout) }
|
||
}
|
||
/// 用户在详情弹窗里隐藏的区块
|
||
public var hiddenPopoverSections: Set<PopoverSection> {
|
||
didSet { defaults.set(hiddenPopoverSections.map(\.rawValue).sorted(), forKey: Keys.hiddenPopoverSections) }
|
||
}
|
||
public var probeEnabled: Bool {
|
||
didSet { defaults.set(probeEnabled, forKey: Keys.probeEnabled) }
|
||
}
|
||
/// 网络详情关闭时,菜单栏显示网络项期间继续低频探测
|
||
public var probeInBackground: Bool {
|
||
didSet { defaults.set(probeInBackground, forKey: Keys.probeInBackground) }
|
||
}
|
||
public var probeSeconds: Int {
|
||
didSet { defaults.set(probeSeconds, forKey: Keys.probeSeconds) }
|
||
}
|
||
public var probeTarget: ProbeTarget {
|
||
didSet { defaults.set(probeTarget.rawValue, forKey: Keys.probeTarget) }
|
||
}
|
||
/// 公网 IP 归属地的数据源
|
||
public var geoSource: GeoSource {
|
||
didSet { defaults.set(geoSource.rawValue, forKey: Keys.geoSource) }
|
||
}
|
||
/// 打开网络详情时查询公网 IP(会访问 Cloudflare / ipify)
|
||
public var publicIPLookup: Bool {
|
||
didSet { defaults.set(publicIPLookup, forKey: Keys.publicIPLookup) }
|
||
}
|
||
/// 本地归属地库包含城市数据(GeoLite2-City,体积约为国家库的 7 倍)
|
||
public var geoIncludeCity: Bool {
|
||
didSet { defaults.set(geoIncludeCity, forKey: Keys.geoIncludeCity) }
|
||
}
|
||
/// 定期从官网检查归属地数据库更新
|
||
public var geoAutoUpdate: Bool {
|
||
didSet { defaults.set(geoAutoUpdate, forKey: Keys.geoAutoUpdate) }
|
||
}
|
||
/// 打开了系统通知的状况
|
||
public var enabledAlerts: Set<AlertKind> {
|
||
didSet { defaults.set(enabledAlerts.map(\.rawValue).sorted(), forKey: Keys.enabledAlerts) }
|
||
}
|
||
/// CPU 过热提醒的温度(摄氏度)
|
||
public var alertCPUTemperature: Int {
|
||
didSet { defaults.set(alertCPUTemperature, forKey: Keys.alertCPUTemperature) }
|
||
}
|
||
/// CPU 持续高负载提醒的总占用阈值(百分比)
|
||
public var alertCPULoad: Int {
|
||
didSet { defaults.set(alertCPULoad, forKey: Keys.alertCPULoad) }
|
||
}
|
||
/// CPU 详情顶部走势图显示最近多少秒
|
||
public var cpuChartSeconds: Int {
|
||
didSet { defaults.set(cpuChartSeconds, forKey: Keys.cpuChartSeconds) }
|
||
}
|
||
/// 界面语言:界面文字立即切换;系统提供的名称(显示器、应用名)在下次启动时跟着切换
|
||
public var language: AppLanguage {
|
||
didSet {
|
||
defaults.set(language.rawValue, forKey: Keys.language)
|
||
language.applyToProcessLocale(defaults: defaults)
|
||
L10n.configure(language)
|
||
}
|
||
}
|
||
nonisolated public static let languageKey = "language"
|
||
/// 全局快捷键
|
||
public var hotKeys: [HotKeyAction: HotKey] {
|
||
didSet {
|
||
let stored = Dictionary(uniqueKeysWithValues: hotKeys.map { ($0.key.rawValue, $0.value) })
|
||
defaults.set(try? JSONEncoder().encode(stored), forKey: Keys.hotKeys)
|
||
}
|
||
}
|
||
/// 每分钟把主要指标写入本机历史库
|
||
public var historyEnabled: Bool {
|
||
didSet { defaults.set(historyEnabled, forKey: Keys.historyEnabled) }
|
||
}
|
||
/// 启动时与每天检查一次新版本
|
||
public var autoCheckUpdates: Bool {
|
||
didSet { defaults.set(autoCheckUpdates, forKey: Keys.autoCheckUpdates) }
|
||
}
|
||
/// 可再生的缓存也先移到废纸篓(可恢复,但不会立即释放空间)
|
||
public var cleanPrefersTrash: Bool {
|
||
didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) }
|
||
}
|
||
|
||
public static let refreshOptions = [1, 2, 3, 5]
|
||
public static let probeOptions = [1, 2, 5]
|
||
public static let batteryFloorOptions = [10, 20, 30, 40]
|
||
public static let fanSafetyOptions = [85, 90, 95, 100]
|
||
public static let alertTemperatureOptions = [85, 90, 95, 100]
|
||
public static let alertLoadOptions = [70, 80, 90]
|
||
public static let cpuChartOptions = [60, 180, 300]
|
||
|
||
public init(defaults: UserDefaults = .standard) {
|
||
self.defaults = defaults
|
||
let items = defaults.stringArray(forKey: Keys.menuBarItems)?.compactMap(MenuBarItem.init(rawValue:))
|
||
menuBarItems = Set(items ?? [.cpu, .memory, .network])
|
||
menuBarStyle = defaults.string(forKey: Keys.menuBarStyle).flatMap(MenuBarStyle.init(rawValue:)) ?? .stacked
|
||
networkStyle = defaults.string(forKey: Keys.networkStyle).flatMap(NetworkMenuStyle.init(rawValue:)) ?? .dots
|
||
let storedOverrides = defaults.dictionary(forKey: Keys.styleOverrides) as? [String: String] ?? [:]
|
||
styleOverrides = Dictionary(uniqueKeysWithValues: storedOverrides.compactMap { key, value in
|
||
guard let item = MenuBarItem(rawValue: key), let style = MenuBarStyle(rawValue: value) else { return nil }
|
||
return (item, style)
|
||
})
|
||
refreshSeconds = Self.refreshOptions.contains(defaults.integer(forKey: Keys.refreshSeconds))
|
||
? defaults.integer(forKey: Keys.refreshSeconds) : 2
|
||
colorizeHighLoad = defaults.bool(forKey: Keys.colorizeHighLoad)
|
||
useFahrenheit = defaults.bool(forKey: Keys.useFahrenheit)
|
||
lidModeBatteryFloor = Self.batteryFloorOptions.contains(defaults.integer(forKey: Keys.lidModeBatteryFloor))
|
||
? defaults.integer(forKey: Keys.lidModeBatteryFloor) : 20
|
||
fanSafetyTemperature = Self.fanSafetyOptions.contains(defaults.integer(forKey: Keys.fanSafetyTemperature))
|
||
? defaults.integer(forKey: Keys.fanSafetyTemperature) : 95
|
||
panelTab = defaults.string(forKey: Keys.panelTab).flatMap(PanelTab.init(rawValue:)) ?? .overview
|
||
appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system
|
||
cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash)
|
||
menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate
|
||
hiddenPopoverSections = defaults.stringArray(forKey: Keys.hiddenPopoverSections)
|
||
.map { Set($0.compactMap(PopoverSection.init(rawValue:))) } ?? PopoverSection.hiddenByDefault
|
||
probeEnabled = defaults.object(forKey: Keys.probeEnabled) as? Bool ?? true
|
||
probeInBackground = defaults.object(forKey: Keys.probeInBackground) as? Bool ?? true
|
||
probeSeconds = Self.probeOptions.contains(defaults.integer(forKey: Keys.probeSeconds))
|
||
? defaults.integer(forKey: Keys.probeSeconds) : 2
|
||
probeTarget = defaults.string(forKey: Keys.probeTarget).flatMap(ProbeTarget.init(rawValue:)) ?? .cloudflare
|
||
geoSource = defaults.string(forKey: Keys.geoSource).flatMap(GeoSource.init(rawValue:)) ?? .cleanIP
|
||
publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true
|
||
geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity)
|
||
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) }
|
||
})
|
||
enabledAlerts = Set(defaults.stringArray(forKey: Keys.enabledAlerts)?.compactMap(AlertKind.init(rawValue:)) ?? [])
|
||
alertCPUTemperature = Self.alertTemperatureOptions.contains(defaults.integer(forKey: Keys.alertCPUTemperature))
|
||
? defaults.integer(forKey: Keys.alertCPUTemperature) : 95
|
||
alertCPULoad = Self.alertLoadOptions.contains(defaults.integer(forKey: Keys.alertCPULoad))
|
||
? defaults.integer(forKey: Keys.alertCPULoad) : 80
|
||
cpuChartSeconds = Self.cpuChartOptions.contains(defaults.integer(forKey: Keys.cpuChartSeconds))
|
||
? defaults.integer(forKey: Keys.cpuChartSeconds) : 60
|
||
}
|
||
|
||
/// 按固定顺序返回已启用的菜单栏项目
|
||
var orderedMenuBarItems: [MenuBarItem] {
|
||
MenuBarItem.allCases.filter(menuBarItems.contains)
|
||
}
|
||
|
||
func isEnabled(_ item: MenuBarItem) -> Bool { menuBarItems.contains(item) }
|
||
|
||
func style(for item: MenuBarItem) -> MenuBarStyle {
|
||
styleOverrides[item] ?? menuBarStyle
|
||
}
|
||
|
||
/// nil 表示跟随整体风格
|
||
func setStyleOverride(_ style: MenuBarStyle?, for item: MenuBarItem) {
|
||
styleOverrides[item] = style
|
||
}
|
||
|
||
func isVisible(_ section: PopoverSection) -> Bool { !hiddenPopoverSections.contains(section) }
|
||
|
||
func setVisible(_ section: PopoverSection, _ visible: Bool) {
|
||
if visible { hiddenPopoverSections.remove(section) } else { hiddenPopoverSections.insert(section) }
|
||
}
|
||
|
||
func setEnabled(_ item: MenuBarItem, _ enabled: Bool) {
|
||
if enabled { menuBarItems.insert(item) } else { menuBarItems.remove(item) }
|
||
}
|
||
|
||
private enum Keys {
|
||
static let menuBarItems = "menuBarItems"
|
||
static let menuBarStyle = "menuBarStyle"
|
||
static let networkStyle = "networkStyle"
|
||
static let styleOverrides = "styleOverrides"
|
||
static let refreshSeconds = "refreshSeconds"
|
||
static let colorizeHighLoad = "colorizeHighLoad"
|
||
static let useFahrenheit = "useFahrenheit"
|
||
static let lidModeBatteryFloor = "lidModeBatteryFloor"
|
||
static let fanSafetyTemperature = "fanSafetyTemperature"
|
||
static let panelTab = "panelTab"
|
||
static let appearance = "appearance"
|
||
static let cleanPrefersTrash = "cleanPrefersTrash"
|
||
static let menuBarLayout = "menuBarLayout"
|
||
static let hiddenPopoverSections = "hiddenPopoverSections"
|
||
static let probeEnabled = "probeEnabled"
|
||
static let probeSeconds = "probeSeconds"
|
||
static let probeInBackground = "probeInBackground"
|
||
static let probeTarget = "probeTarget"
|
||
static let geoSource = "geoSource"
|
||
static let publicIPLookup = "publicIPLookup"
|
||
static let geoIncludeCity = "geoIncludeCity"
|
||
static let geoAutoUpdate = "geoAutoUpdate"
|
||
static let autoCheckUpdates = "autoCheckUpdates"
|
||
static let historyEnabled = "historyEnabled"
|
||
static let hotKeys = "hotKeys"
|
||
static let language = "language"
|
||
static let enabledAlerts = "enabledAlerts"
|
||
static let alertCPUTemperature = "alertCPUTemperature"
|
||
static let alertCPULoad = "alertCPULoad"
|
||
static let cpuChartSeconds = "cpuChartSeconds"
|
||
}
|
||
}
|