Files

981 lines
45 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 GiantAccel, LLC
// XStats modifications Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
import AIUsage
import Foundation
import Localization
import Metrics
import Observation
public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case cpu, memory, network, gpu, disk, temperature, fan, battery, aiUsage, display
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("风扇转速")
case .battery: tr("电池")
case .aiUsage: tr("AI 用量")
case .display: 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("转速最高的风扇")
case .battery: tr("电量与充电状态;没有电池的 Mac 显示蓝牙设备电量")
case .aiUsage: tr("Codex / Claude Code 本机 Token 用量与订阅额度")
case .display: 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"
case .battery: "BAT"
case .aiUsage: "AI"
case .display: "DSP"
}
}
/// 详情弹窗标题
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("风扇")
case .battery: tr("电池")
case .aiUsage: tr("AI 用量")
case .display: 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]
case .battery: [.batteryHistory, .batteryPower, .batteryHealth, .batteryBluetooth]
case .aiUsage, .display: []
}
}
/// 可以单独指定风格的指标(网速有自己的样式)
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"
case .battery: "battery.75"
case .aiUsage: "sparkles"
case .display: "display"
}
}
}
/// 详情弹窗里可以单独隐藏的区块
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
case batteryHistory, batteryPower, batteryHealth, batteryBluetooth
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, .batteryPower: tr("功耗")
case .batteryHistory: tr("电量历史")
case .batteryHealth: tr("电池健康")
case .batteryBluetooth: tr("蓝牙设备")
}
}
}
/// 网络速度旁的可选出口地区显示。
public enum NetworkLocationStyle: String, CaseIterable, Identifiable, Sendable {
case off, flag, text
public var id: String { rawValue }
var title: String {
switch self {
case .off: tr("不显示")
case .flag: tr("国旗")
case .text: tr("地区文字")
}
}
}
/// 菜单栏布局
public enum MenuBarLayout: String, CaseIterable, Identifiable, Sendable {
/// 每个指标一个图标,点击弹出该项详情
case separate
/// 所有指标合成一个图标,点击弹出状态总览,可切到各项详情
case combined
/// 只显示应用标志;保存的项目选择用于弹出面板,不持续绘制读数。
case iconOnly
public var id: String { rawValue }
var title: String {
switch self {
case .separate: tr("每项独立")
case .combined: tr("合并显示")
case .iconOnly: tr("仅图标")
}
}
var subtitle: String {
switch self {
case .separate: tr("每个指标一个图标,点击弹出该项详情")
case .combined: tr("所有指标合成一个图标,点击弹出状态总览,可切到各项详情")
case .iconOnly: tr("只显示 XStats 图标,点击查看已选项目的状态总览")
}
}
}
/// 连接探测的目标
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
}
}
}
/// 菜单栏风格:整体选一种套用到所有指标,个别指标可以单独指定(AppSettings.styleOverrides);风格内部的排版保持一致
public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
case stacked, stackedCenter, inline, icon, ring, pie, history, line, meter, dot
public var id: String { rawValue }
/// 温度、风扇没有百分比;AI 额度没有历史采样,只提供当前剩余量的图形。
/// 整体选择历史图时,AI 额度尚无历史序列,用圆环表达当前剩余量。
func resolved(for item: MenuBarItem) -> MenuBarStyle {
item == .aiUsage && (self == .history || self == .line) ? .ring : self
}
static func options(for item: MenuBarItem) -> [MenuBarStyle] {
switch item {
case .temperature, .fan, .display: [.stacked, .stackedCenter, .inline, .icon]
case .aiUsage: [.stacked, .stackedCenter, .inline, .icon, .ring, .pie, .meter, .dot]
default: allCases
}
}
var title: String {
switch self {
case .stacked: tr("双行文字")
case .stackedCenter: 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 .stackedCenter: 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 {
self == .system ? tr("自动检测") : nativeName
}
}
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, aiUsage, cpu, gpu, memory, disk, network, thermal, battery, processes, keepAwake, rest, cleaner, uninstaller, startupItems
case settingsGeneral, settingsMenuBar, settingsNotifications, settingsAccount, settingsHelper, settingsAbout
public var id: String { rawValue }
static let monitors: [PanelTab] = [.overview, .system, .history, .aiUsage, .cpu, .gpu, .memory, .disk, .network, .thermal, .battery]
static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .rest, .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 .aiUsage: tr("AI 用量")
case .cpu: "CPU"
case .gpu: "GPU"
case .memory: tr("内存")
case .disk: tr("磁盘")
case .network: tr("网络")
case .thermal: tr("温度与风扇")
case .battery: tr("电池")
case .processes: tr("进程")
case .keepAwake: tr("防休眠")
case .rest: 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 .aiUsage: "sparkles"
case .cpu: "cpu"
case .gpu: "square.3.layers.3d"
case .memory: "memorychip"
case .disk: "internaldrive"
case .network: "network"
case .thermal: "fan"
case .battery: "battery.75"
case .processes: "list.bullet.rectangle"
case .keepAwake: "cup.and.saucer"
case .rest: "timer"
case .cleaner: "eraser"
case .uninstaller: "trash"
case .startupItems: "power"
case .settingsGeneral: "gearshape"
case .settingsMenuBar: "menubar.rectangle"
case .settingsNotifications: "bell.badge"
case .settingsAccount: "arrow.triangle.2.circlepath"
case .settingsHelper: "lock.shield"
case .settingsAbout: "info.circle"
}
}
/// 页面对应的菜单栏项目,页面右上角可直接开关
var menuBarItem: MenuBarItem? {
switch self {
case .system: .display
case .cpu: .cpu
case .gpu: .gpu
case .memory: .memory
case .network: .network
case .disk: .disk
case .thermal: .temperature
case .battery: .battery
case .aiUsage: .aiUsage
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
case .battery: self = .battery
case .aiUsage: self = .aiUsage
case .display: self = .system
}
}
}
/// 自动更新检查策略;手动检查始终可用。
public enum UpdateCheckSchedule: String, CaseIterable, Identifiable, Sendable {
case quietRuntime, atLaunch, daily, weekly, monthly, never
public var id: String { rawValue }
var title: String {
switch self {
case .quietRuntime: tr("运行时静默")
case .atLaunch: tr("启动时")
case .daily: tr("每日一次")
case .weekly: tr("每周一次")
case .monthly: tr("每月一次")
case .never: tr("从不")
}
}
var promptsForUpdates: Bool { self != .quietRuntime && self != .never }
/// 返回交给 Sparkle 的下次检查间隔;从不和本次启动已检查过时不注册计时器。
func sparkleInterval(lastChecked: Date?, now: Date, launchedAt: Date, retry: Bool = false) -> TimeInterval? {
guard let next = nextCheckDate(lastChecked: lastChecked, launchedAt: launchedAt) else { return nil }
return retry ? 60 * 60 : max(60 * 60, next.timeIntervalSince(now))
}
/// 开机网络未就绪时,启动检查在本次成功之前始终待办,由 Sparkle 每小时重试。
func shouldCheck(lastChecked: Date?, now: Date, launchedAt: Date) -> Bool {
guard let next = nextCheckDate(lastChecked: lastChecked, launchedAt: launchedAt) else { return false }
return now >= next
}
/// 到期判断和 Sparkle 调度共用同一日期规则,避免自然月与启动策略产生两套语义。
private func nextCheckDate(lastChecked: Date?, launchedAt: Date) -> Date? {
switch self {
case .never: return nil
case .atLaunch: return lastChecked.map { $0 >= launchedAt } == true ? nil : .distantPast
case .quietRuntime, .daily, .weekly, .monthly:
guard let lastChecked else { return .distantPast }
switch self {
case .quietRuntime, .daily: return lastChecked.addingTimeInterval(24 * 60 * 60)
case .weekly: return lastChecked.addingTimeInterval(7 * 24 * 60 * 60)
case .monthly: return Calendar.current.date(byAdding: .month, value: 1, to: lastChecked) ?? lastChecked
case .atLaunch, .never: return nil
}
}
}
}
/// UserDefaults 持久化的偏好设置
@MainActor
@Observable
public final class AppSettings {
@ObservationIgnored private let defaults: UserDefaults
/// 日历独立于性能指标,即使指标合并也保留单独入口;旧用户默认关闭。
public var calendarEnabled: Bool {
didSet { defaults.set(calendarEnabled, forKey: Keys.calendarEnabled) }
}
public var calendarPreferences: CalendarPreferences {
didSet { defaults.set(try? JSONEncoder().encode(calendarPreferences), forKey: Keys.calendarPreferences) }
}
/// nil 表示全部列表,空集合表示明确不显示任何列表;列表 ID 仅在本机有意义。
var calendarEventSourceIDs: Set<String>? {
didSet { defaults.set(calendarEventSourceIDs.map { Array($0).sorted() }, forKey: "calendarEventSourceIDs") }
}
var calendarReminderSourceIDs: Set<String>? {
didSet { defaults.set(calendarReminderSourceIDs.map { Array($0).sorted() }, forKey: "calendarReminderSourceIDs") }
}
/// 休息提醒完全由本机计时;旧用户默认关闭。
public var restEnabled: Bool {
didSet {
defaults.set(restEnabled, forKey: Keys.restEnabled)
if !restEnabled && panelTab == .rest { panelTab = .settingsGeneral }
}
}
public var restWorkMinutes: Int {
didSet { defaults.set(restWorkMinutes, forKey: Keys.restWorkMinutes) }
}
public var restBreakMinutes: Int {
didSet { defaults.set(restBreakMinutes, forKey: Keys.restBreakMinutes) }
}
public var restLongBreakMinutes: Int {
didSet { defaults.set(restLongBreakMinutes, forKey: Keys.restLongBreakMinutes) }
}
public var restDailyGoal: Int {
didSet { defaults.set(restDailyGoal, forKey: Keys.restDailyGoal) }
}
/// 兼容旧版布尔偏好与备份;新界面以 restMode 为准。
public var restCycleEnabled: Bool {
get { restMode == .cycle }
set { restMode = newValue ? .cycle : .single }
}
public var restMode: RestRunMode {
didSet {
defaults.set(restMode.rawValue, forKey: Keys.restMode)
defaults.set(restCycleEnabled, forKey: Keys.restCycleEnabled)
}
}
public var restSound: RestSound {
didSet { defaults.set(restSound.rawValue, forKey: Keys.restSound) }
}
public var restHUDStyle: RestHUDStyle {
didSet { defaults.set(restHUDStyle.rawValue, forKey: Keys.restHUDStyle) }
}
public var calendarFeatures: Set<CalendarFeature> {
didSet { defaults.set(calendarFeatures.map(\.rawValue).sorted(), forKey: Keys.calendarFeatures) }
}
public var calendarFirstWeekday: Int {
didSet { defaults.set(calendarFirstWeekday, forKey: Keys.calendarFirstWeekday) }
}
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) }
}
public var networkLocationStyle: NetworkLocationStyle {
didSet { defaults.set(networkLocationStyle.rawValue, forKey: Keys.networkLocationStyle) }
}
/// 个别指标单独指定的风格;未指定的跟随 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) }
}
/// AI 用量模块默认不启用,独立于系统指标的秒级采样。
public var aiUsageEnabled: Bool {
didSet {
defaults.set(aiUsageEnabled, forKey: Keys.aiUsageEnabled)
if !aiUsageEnabled && panelTab == .aiUsage { panelTab = .settingsGeneral }
}
}
/// 独立进程管理器默认关闭;其他监控页仍可按需读取应用用量。
public var processesEnabled: Bool {
didSet {
defaults.set(processesEnabled, forKey: Keys.processesEnabled)
if !processesEnabled && panelTab == .processes { panelTab = .settingsGeneral }
}
}
/// 清理工具默认关闭;扫描只在打开页面后按需执行。
public var cleanerEnabled: Bool {
didSet {
defaults.set(cleanerEnabled, forKey: Keys.cleanerEnabled)
if !cleanerEnabled && panelTab == .cleaner { panelTab = .settingsGeneral }
}
}
/// 来源开关只保存在本机;关闭后不扫描,也不显示其历史缓存。
public var aiUsageSources: Set<AIProviderID> {
didSet { defaults.set(aiUsageSources.map(\.rawValue).sorted(), forKey: Keys.aiUsageSources) }
}
/// 订阅额度继续自动查询;关闭本地用量后停止扫描本机会话日志。
public var aiUsageShowsLocalUsage: Bool {
didSet { defaults.set(aiUsageShowsLocalUsage, forKey: Keys.aiUsageShowsLocalUsage) }
}
public var aiQuotaShowsRemaining: Bool {
didSet { defaults.set(aiQuotaShowsRemaining, forKey: Keys.aiQuotaShowsRemaining) }
}
public var aiUsageWesternUnits: Bool {
didSet { defaults.set(aiUsageWesternUnits, forKey: Keys.aiUsageWesternUnits) }
}
public var aiUsageCurrency: AIUsageCurrency {
didSet { defaults.set(aiUsageCurrency.rawValue, forKey: Keys.aiUsageCurrency) }
}
public var aiUsageRefreshMinutes: Int {
didSet { defaults.set(aiUsageRefreshMinutes, forKey: Keys.aiUsageRefreshMinutes) }
}
public var colorizeHighLoad: Bool {
didSet { defaults.set(colorizeHighLoad, forKey: Keys.colorizeHighLoad) }
}
/// 键盘、鼠标、耳机等蓝牙设备电量低时,在菜单栏电池项旁提示
public var bluetoothLowBatteryInMenuBar: Bool {
didSet { defaults.set(bluetoothLowBatteryInMenuBar, forKey: Keys.bluetoothLowBatteryInMenuBar) }
}
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 {
// 统一拦截旧路由与外部打开请求,关闭的进程模块不能被其他入口重新打开。
if panelTab == .processes && !processesEnabled { panelTab = .settingsGeneral }
defaults.set(panelTab.rawValue, forKey: Keys.panelTab)
}
}
public var appearance: AppearanceMode {
didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) }
}
/// 网络测速里单个节点的时间与流量上限
public var speedTestBudget: SpeedTestBudget {
didSet { defaults.set(speedTestBudget.rawValue, forKey: Keys.speedTestBudget) }
}
/// VPN / 代理开启时测速走直连还是代理;默认直连以测本机宽带。
public var speedTestRoute: SpeedRoute {
didSet { defaults.set(speedTestRoute.rawValue, forKey: Keys.speedTestRoute) }
}
/// 主窗口开着时在程序坞与 ⌘Tab 中显示图标;默认关闭,和其他菜单栏工具一样只在菜单栏运行
public var showDockIcon: Bool {
didSet { defaults.set(showDockIcon, forKey: Keys.showDockIcon) }
}
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) }
}
/// 弹窗里默认收起、被用户展开的区块(IP 纯净度、DNS);只影响菜单栏弹窗,主窗口始终完整显示
public var expandedPopoverSections: Set<PopoverSection> {
didSet { defaults.set(expandedPopoverSections.map(\.rawValue).sorted(), forKey: Keys.expandedPopoverSections) }
}
public var probeEnabled: Bool {
didSet { defaults.set(probeEnabled, forKey: Keys.probeEnabled) }
}
/// 网络详情关闭时,菜单栏显示网络项期间继续低频探测
public var probeInBackground: Bool {
didSet { defaults.set(probeInBackground, forKey: Keys.probeInBackground) }
}
public var probeTarget: ProbeTarget {
didSet { defaults.set(probeTarget.rawValue, forKey: Keys.probeTarget) }
}
/// 打开网络详情时查询公网 IP(会访问 Cloudflare / ipify),归属地与纯净度由 cleanip.io 提供
public var publicIPLookup: Bool {
didSet { defaults.set(publicIPLookup, forKey: Keys.publicIPLookup) }
}
/// 自动检查发现新版本时发送系统通知,默认开启。
public var notifyUpdates: Bool {
didSet { defaults.set(notifyUpdates, forKey: Keys.notifyUpdates) }
}
/// 打开了系统通知的状况
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 {
if historyEnabled != oldValue { historyRecordingGeneration += 1 }
defaults.set(historyEnabled, forKey: Keys.historyEnabled)
}
}
/// 开关快速关闭再开启时,之前启动的异步补录仍须失效。
@ObservationIgnored private(set) var historyRecordingGeneration = 0
/// 旧版布尔设置只用于兼容旧备份;新界面以检查策略为准。
public var autoCheckUpdates: Bool {
get { updateCheckSchedule != .never }
set { updateCheckSchedule = newValue ? .daily : .never }
}
public var updateCheckSchedule: UpdateCheckSchedule {
didSet {
defaults.set(updateCheckSchedule.rawValue, forKey: Keys.updateCheckSchedule)
defaults.set(autoCheckUpdates, forKey: Keys.autoCheckUpdates)
}
}
/// 可再生的缓存也先移到废纸篓(可恢复,但不会立即释放空间)
public var cleanPrefersTrash: Bool {
didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) }
}
/// 清理页的规则选择;nil 表示用户从未调整过,使用规则默认值。空集合表示用户明确全部取消。
public var cleanSelectedRuleIDs: Set<String>? {
didSet {
if let cleanSelectedRuleIDs {
defaults.set(cleanSelectedRuleIDs.sorted(), forKey: Keys.cleanSelectedRuleIDs)
} else {
defaults.removeObject(forKey: Keys.cleanSelectedRuleIDs)
}
}
}
/// nil 使用动态默认目录;空数组表示用户明确移除了所有目录。仅在本机保存。
public var projectPurgeConfiguredPaths: [String]? {
didSet {
if let projectPurgeConfiguredPaths {
defaults.set(projectPurgeConfiguredPaths, forKey: Keys.projectPurgeConfiguredPaths)
} else {
defaults.removeObject(forKey: Keys.projectPurgeConfiguredPaths)
}
}
}
public static let refreshOptions = [1, 2, 3, 5]
public static let restWorkOptions = [20, 25, 30, 45, 60]
public static let restBreakOptions = [3, 5, 10]
public static let restLongBreakOptions = [10, 15, 20, 30]
public static let restDailyGoalOptions = [4, 6, 8, 12]
public static let aiUsageRefreshOptions = [1, 3, 5, 10, 15, 30, 60]
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, calendarLocale: Locale = .current, calendar: Calendar = .current) {
self.defaults = defaults
let isRestEnabled = defaults.bool(forKey: Keys.restEnabled)
restEnabled = isRestEnabled
restWorkMinutes = Self.restWorkOptions.contains(defaults.integer(forKey: Keys.restWorkMinutes))
? defaults.integer(forKey: Keys.restWorkMinutes) : 25
restBreakMinutes = Self.restBreakOptions.contains(defaults.integer(forKey: Keys.restBreakMinutes))
? defaults.integer(forKey: Keys.restBreakMinutes) : 5
restLongBreakMinutes = Self.restLongBreakOptions.contains(defaults.integer(forKey: Keys.restLongBreakMinutes))
? defaults.integer(forKey: Keys.restLongBreakMinutes) : 15
restDailyGoal = Self.restDailyGoalOptions.contains(defaults.integer(forKey: Keys.restDailyGoal))
? defaults.integer(forKey: Keys.restDailyGoal) : 8
restMode = defaults.string(forKey: Keys.restMode).flatMap(RestRunMode.init(rawValue:))
?? (defaults.bool(forKey: Keys.restCycleEnabled) ? .cycle : .single)
restSound = defaults.string(forKey: Keys.restSound).flatMap(RestSound.init(rawValue:)) ?? .off
restHUDStyle = defaults.string(forKey: Keys.restHUDStyle).flatMap(RestHUDStyle.init(rawValue:)) ?? .countdown
// 旧版只在用户开关日历后写入该键;已有安装缺少保存值时沿用旧版默认,不按地区重新初始化。
let isExistingCalendarInstall = defaults.object(forKey: Keys.calendarEnabled) != nil
calendarEnabled = defaults.bool(forKey: Keys.calendarEnabled)
let savedCalendarPreferences = defaults.data(forKey: Keys.calendarPreferences)
calendarPreferences = savedCalendarPreferences
.flatMap { try? JSONDecoder().decode(CalendarPreferences.self, from: $0) }
?? CalendarPreferences(locale: isExistingCalendarInstall ? Locale(identifier: "zh_CN") : calendarLocale)
calendarEventSourceIDs = defaults.stringArray(forKey: "calendarEventSourceIDs").map { Set($0) }
calendarReminderSourceIDs = defaults.stringArray(forKey: "calendarReminderSourceIDs").map { Set($0) }
let savedCalendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures)
calendarFeatures = savedCalendarFeatures
.map(CalendarFeature.restored(from:))
?? (isExistingCalendarInstall ? CalendarFeature.mainlandChinaDefaults : CalendarFeature.defaults(for: calendarLocale))
let savedCalendarFirstWeekday = (defaults.object(forKey: Keys.calendarFirstWeekday) as? Int)
.flatMap { (1...7).contains($0) ? $0 : nil }
calendarFirstWeekday = savedCalendarFirstWeekday ?? (isExistingCalendarInstall ? 2 : calendar.firstWeekday)
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
networkLocationStyle = defaults.string(forKey: Keys.networkLocationStyle).flatMap(NetworkLocationStyle.init(rawValue:)) ?? .off
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
let isAIUsageEnabled = defaults.bool(forKey: Keys.aiUsageEnabled)
aiUsageEnabled = isAIUsageEnabled
let isCleanerEnabled = defaults.bool(forKey: Keys.cleanerEnabled)
cleanerEnabled = isCleanerEnabled
let isProcessesEnabled = defaults.bool(forKey: Keys.processesEnabled)
processesEnabled = isProcessesEnabled
aiUsageSources = defaults.stringArray(forKey: Keys.aiUsageSources)
.map { Set($0.compactMap(AIProviderID.init(rawValue:))) } ?? Set(AIProviderID.allCases)
aiUsageShowsLocalUsage = defaults.object(forKey: Keys.aiUsageShowsLocalUsage) as? Bool ?? true
aiQuotaShowsRemaining = defaults.object(forKey: Keys.aiQuotaShowsRemaining) as? Bool ?? true
aiUsageWesternUnits = defaults.bool(forKey: Keys.aiUsageWesternUnits)
aiUsageCurrency = defaults.string(forKey: Keys.aiUsageCurrency).flatMap(AIUsageCurrency.init(rawValue:)) ?? .usd
aiUsageRefreshMinutes = Self.aiUsageRefreshOptions.contains(defaults.integer(forKey: Keys.aiUsageRefreshMinutes))
? defaults.integer(forKey: Keys.aiUsageRefreshMinutes) : 30
colorizeHighLoad = defaults.bool(forKey: Keys.colorizeHighLoad)
bluetoothLowBatteryInMenuBar = defaults.object(forKey: Keys.bluetoothLowBatteryInMenuBar) as? Bool ?? true
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
let savedPanelTabValue = defaults.string(forKey: Keys.panelTab)
let savedPanelTab = savedPanelTabValue.flatMap(PanelTab.init(rawValue:)) ?? .overview
// 旧版 AI 助手设置页已移除,升级后仍留在设置分组。
panelTab = savedPanelTabValue == "settingsAI" || savedPanelTab == .settingsAccount
|| (savedPanelTab == .aiUsage && !isAIUsageEnabled)
|| (savedPanelTab == .rest && !isRestEnabled)
|| (savedPanelTab == .cleaner && !isCleanerEnabled)
|| (savedPanelTab == .processes && !isProcessesEnabled)
? .settingsGeneral : savedPanelTab
appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system
showDockIcon = defaults.bool(forKey: Keys.showDockIcon)
speedTestBudget = defaults.string(forKey: Keys.speedTestBudget).flatMap(SpeedTestBudget.init(rawValue:)) ?? .full
speedTestRoute = defaults.string(forKey: Keys.speedTestRoute).flatMap(SpeedRoute.init(rawValue:)) ?? .direct
cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash)
cleanSelectedRuleIDs = defaults.stringArray(forKey: Keys.cleanSelectedRuleIDs).map(Set.init)
projectPurgeConfiguredPaths = defaults.stringArray(forKey: Keys.projectPurgeConfiguredPaths)
menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate
expandedPopoverSections = Set((defaults.stringArray(forKey: Keys.expandedPopoverSections) ?? []).compactMap(PopoverSection.init(rawValue:)))
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
probeTarget = defaults.string(forKey: Keys.probeTarget).flatMap(ProbeTarget.init(rawValue:)) ?? .cloudflare
publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true
updateCheckSchedule = defaults.string(forKey: Keys.updateCheckSchedule)
.flatMap(UpdateCheckSchedule.init(rawValue:))
?? ((defaults.object(forKey: Keys.autoCheckUpdates) as? Bool ?? true) ? .daily : .never)
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) }
})
notifyUpdates = defaults.object(forKey: Keys.notifyUpdates) as? Bool ?? true
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
// 仅在没有偏好时确定默认值;后续语言、地区变化及备份恢复不重置用户选择。
if savedCalendarPreferences == nil {
defaults.set(try? JSONEncoder().encode(calendarPreferences), forKey: Keys.calendarPreferences)
}
if savedCalendarFeatures == nil {
defaults.set(calendarFeatures.map(\.rawValue).sorted(), forKey: Keys.calendarFeatures)
}
// 首次采用系统的周起始日并保存,之后系统设置变化不覆盖用户选择。
if savedCalendarFirstWeekday == nil {
defaults.set(calendarFirstWeekday, forKey: Keys.calendarFirstWeekday)
}
}
/// 按固定顺序返回当前实际显示的菜单栏项目;可选模块关闭时保留展示偏好但不渲染。
var orderedMenuBarItems: [MenuBarItem] {
MenuBarItem.allCases.filter { item in
menuBarItems.contains(item) && (item != .aiUsage || aiUsageEnabled)
}
}
func isEnabled(_ item: MenuBarItem) -> Bool { menuBarItems.contains(item) }
func style(for item: MenuBarItem) -> MenuBarStyle {
(styleOverrides[item] ?? menuBarStyle).resolved(for: item)
}
/// nil 表示跟随整体风格
func setStyleOverride(_ style: MenuBarStyle?, for item: MenuBarItem) {
styleOverrides[item] = style
}
func isVisible(_ section: PopoverSection) -> Bool { !hiddenPopoverSections.contains(section) }
func isExpanded(_ section: PopoverSection) -> Bool { expandedPopoverSections.contains(section) }
func setExpanded(_ section: PopoverSection, _ expanded: Bool) {
if expanded { expandedPopoverSections.insert(section) } else { expandedPopoverSections.remove(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 restEnabled = "restEnabled"
static let restWorkMinutes = "restWorkMinutes"
static let restBreakMinutes = "restBreakMinutes"
static let restLongBreakMinutes = "restLongBreakMinutes"
static let restDailyGoal = "restDailyGoal"
static let restCycleEnabled = "restCycleEnabled"
static let restMode = "restMode"
static let restSound = "restSound"
static let restHUDStyle = "restHUDStyle"
static let calendarEnabled = "calendarEnabled"
static let calendarPreferences = "calendarPreferences"
static let calendarFeatures = "calendarFeatures"
static let calendarFirstWeekday = "calendarFirstWeekday"
static let menuBarItems = "menuBarItems"
static let menuBarStyle = "menuBarStyle"
static let networkStyle = "networkStyle"
static let networkLocationStyle = "networkLocationStyle"
static let styleOverrides = "styleOverrides"
static let refreshSeconds = "refreshSeconds"
static let aiUsageEnabled = "aiUsageEnabled"
static let cleanerEnabled = "cleanerEnabled"
static let processesEnabled = "processesEnabled"
static let aiUsageSources = "aiUsageSources"
static let aiUsageShowsLocalUsage = "aiUsageShowsLocalUsage"
static let aiQuotaShowsRemaining = "aiQuotaShowsRemaining"
static let aiUsageWesternUnits = "aiUsageWesternUnits"
static let aiUsageCurrency = "aiUsageCurrency"
static let aiUsageRefreshMinutes = "aiUsageRefreshMinutes"
static let colorizeHighLoad = "colorizeHighLoad"
static let bluetoothLowBatteryInMenuBar = "bluetoothLowBatteryInMenuBar"
static let useFahrenheit = "useFahrenheit"
static let lidModeBatteryFloor = "lidModeBatteryFloor"
static let fanSafetyTemperature = "fanSafetyTemperature"
static let panelTab = "panelTab"
static let appearance = "appearance"
static let showDockIcon = "showDockIcon"
static let speedTestBudget = "speedTestBudget"
static let speedTestRoute = "speedTestRoute"
static let cleanPrefersTrash = "cleanPrefersTrash"
static let cleanSelectedRuleIDs = "cleanSelectedRuleIDs"
static let projectPurgeConfiguredPaths = "projectPurgeConfiguredPaths"
static let menuBarLayout = "menuBarLayout"
static let hiddenPopoverSections = "hiddenPopoverSections"
static let expandedPopoverSections = "expandedPopoverSections"
static let probeEnabled = "probeEnabled"
static let probeInBackground = "probeInBackground"
static let probeTarget = "probeTarget"
static let publicIPLookup = "publicIPLookup"
static let autoCheckUpdates = "autoCheckUpdates"
static let updateCheckSchedule = "updateCheckSchedule"
static let historyEnabled = "historyEnabled"
static let hotKeys = "hotKeys"
static let language = "language"
static let enabledAlerts = "enabledAlerts"
static let notifyUpdates = "notifyUpdates"
static let alertCPUTemperature = "alertCPUTemperature"
static let alertCPULoad = "alertCPULoad"
static let cpuChartSeconds = "cpuChartSeconds"
}
}