From 51b954983b48e72fc8dac625f5256c219f1f15d9 Mon Sep 17 00:00:00 2001 From: gentpan Date: Tue, 15 Sep 2026 03:58:30 +0500 Subject: [PATCH] =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=A0=8F=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E2=80=9C=E7=94=B5=E6=B1=A0=E2=80=9D=E9=A1=B9=E7=9B=AE=EF=BC=9A?= =?UTF-8?q?=E7=94=B5=E9=87=8F=E3=80=8124=20=E5=B0=8F=E6=97=B6=E6=9B=B2?= =?UTF-8?q?=E7=BA=BF=E3=80=81=E5=8A=9F=E8=80=97=E3=80=81=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E5=BA=A6=E4=B8=8E=E8=93=9D=E7=89=99=E8=AE=BE=E5=A4=87=E7=94=B5?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 电池按九种风格画在菜单栏,充电中带闪电,电量低于 20% 按“高负载时着色”变红;点击弹出电池详情,主窗口同名页面复用同一视图,历史页多了电池电量曲线 - 历史库加 battery 列(旧库首次打开时 ALTER TABLE 迁移),HistoryRecorder 单独查最近 24 小时电量供弹窗使用 - BluetoothController 按需轮询蓝牙设备电量:弹窗 / 页面开着每分钟,菜单栏需要时每 5 分钟;本机信息页的蓝牙卡片改用它 - 没有电池的 Mac 上电池项目改为显示电量最低的蓝牙设备;「设置 · 菜单栏」新增“蓝牙设备电量低时提示”开关(同步文档一并带上) - README、架构说明、更新日志与译文同步 --- ARCHITECTURE.md | 15 ++ CHANGELOG.md | 2 + .../Sources/Localization/Translations.swift | 21 ++ .../Sources/Metrics/HistoryDatabase.swift | 27 +- .../OpenStatsUI/AppKit/AppController.swift | 5 +- .../OpenStatsUI/AppKit/MenuBarRenderer.swift | 75 ++++++ .../OpenStatsUI/Panel/HistoryPage.swift | 6 + .../OpenStatsUI/Panel/MainWindowView.swift | 1 + .../OpenStatsUI/Panel/SystemInfoPage.swift | 60 +---- .../OpenStatsUI/Popover/BatteryPopover.swift | 232 ++++++++++++++++++ .../OpenStatsUI/Popover/PopoverRootView.swift | 1 + .../OpenStatsUI/Settings/SettingsPages.swift | 17 +- .../Snapshot/SnapshotRenderer.swift | 2 +- .../Sources/OpenStatsUI/State/AppModel.swift | 13 +- .../OpenStatsUI/State/AppSettings.swift | 28 ++- .../State/BluetoothController.swift | 85 +++++++ .../OpenStatsUI/State/HistoryRecorder.swift | 14 +- .../OpenStatsUI/State/SettingsDocument.swift | 3 + README.md | 34 ++- README.zh-CN.md | 34 ++- project.yml | 2 +- 21 files changed, 592 insertions(+), 85 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/BatteryPopover.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/State/BluetoothController.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 998fc89..c9d641e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -171,6 +171,21 @@ is open and back to accessory when all are closed. ## Online updates +### Battery and Bluetooth + +The battery item reuses `BatterySampler` (IOKit power sources, sampled every 10 s while the item is in the +menu bar or its popover / page is open). `BatteryPopover` is both the popover and the main-window page +(`DetailPage`). The 24-hour charge curve comes from the history database, which gained a `battery` column +(migrated with `ALTER TABLE` on first open); `HistoryRecorder.loadBattery()` queries it independently of +the History page's range. Macs without a battery show only Bluetooth devices, and the menu-bar segment +falls back to the Bluetooth device with the lowest battery. + +`BluetoothController` (`OpenStatsUI/State`) wraps `BluetoothBatteryReader`, which shells out to +`system_profiler` and takes a second or two, so it polls only on demand: every minute while the battery +popover / page or the System page is open, every five minutes when the menu bar needs it (the +low-battery hint or a Mac without a battery), otherwise not at all. `AppModel.bluetoothDemand` derives +that from the same visibility state as `demand`. + `UpdateController` fetches `https://getopenstats.com/download/appcast.json` at launch and daily (version, date, notes taken from `CHANGELOG.md` by `Scripts/appcast.py`, zip URL, sha256, size). An update is installed only after: sha256 matches, the zip holds exactly one `.app`, its bundle ID and diff --git a/CHANGELOG.md b/CHANGELOG.md index bb950f4..2f73578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ #### 新增 +- 菜单栏新增“电池”项目:电量按九种风格显示(电量条、圆环、饼图、数字等),充电中带闪电,电量低于 20% 按“高负载时着色”变红。点击弹出电池详情:电量与剩余 / 充满时间、适配器功率、电池温度、最近 24 小时电量曲线、功耗、健康度与循环次数、已连接蓝牙设备的电量(AirPods 左右耳与充电盒、妙控键盘 / 鼠标 / 触控板),可跳到系统电池设置;主窗口也有对应的“电池”页,历史页多了电池电量曲线。没有电池的 Mac(mini、Studio、iMac)上这个项目改为显示电量最低的蓝牙设备,弹窗只列蓝牙设备。「设置 · 菜单栏」新增“蓝牙设备电量低时提示”:某个设备低于 20% 时在电池项目旁显示它的图标与电量。 - 网络详情“接口”区块右上角新增重置按钮:把开机后下载 / 上传归零、从现在起重新累计,标题旁标出起算时间,两行改叫“重置后下载 / 上传”;重启后自动回到开机后累计,右键按钮可随时改回。 - 磁盘页从展示页变成能动手的磁盘工具,新增四张卡片: - 空间占用:统计家目录里每个文件夹占多少,并列出最大的 20 个文件(应用、照片图库等按整体算一个);可在访达中显示,家目录里、Library 之外的文件可以直接移到废纸篓。只读扫描,文件多时需要几十秒,可随时停止。 @@ -56,6 +57,7 @@ #### 调整 +- 网络详情的 DNS 切换改为下拉菜单:“配置方式”一行直接是菜单,选 Cloudflare、Google 等预设立即切换,选“手动”展开输入框,自定义地址时旁边有铅笔按钮可再次修改;不再显示六个并排的按钮,省下两行。 - 卸载应用:去掉“同时从程序坞移除图标”开关,卸载时始终移除程序坞图标;应用列表的滚动条改为只在滚动时显示的细条(之前接了鼠标会显示一条粗的传统滚动条),升级说明里的滚动条同样修正。 - 主窗口的 CPU 走势图加上 0–100% 刻度与起点、中点、现在三个时间标签。 - CPU 详情的解释性文字不再常驻:热力图怎么看、每类核心是干什么的、平均负载怎么理解、按应用汇总为什么会超过 100%,都收进区块标题旁的 ⓘ,鼠标悬停 ⓘ 或卡片内容时才显示,界面上只留数据。 diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index b9f4f3a..e492830 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -213,6 +213,7 @@ npm 缓存 npm Cache 保持运行 Keep alive 信号强度 Signal 修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。 Changing DNS needs administrator permission: it's applied directly when the helper is installed, otherwise the system asks each time. +修改地址 Edit addresses 偏好设置 Preferences 偏高 Elevated 停止 Stop @@ -220,6 +221,7 @@ npm 缓存 npm Cache 停用失败:{} Failed to turn off: {} 健康 {} Health {} 健康度 Health +健康度是现在充满时的容量与出厂容量之比,低于 80% 时苹果建议更换电池 Health is today's full charge capacity versus the design capacity; Apple recommends a replacement below 80% 元数据不是字典 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 @@ -287,6 +289,7 @@ npm 缓存 npm Cache 单行文字 Inline Text 占用 Usage 占用超过 85% 时数值与图形显示为红色 Show values and graphs in red above 85% +占用超过 85%(电池电量低于 20%)时数值与图形显示为红色 Values and graphics turn red above 85% usage (below 20% battery) 占用高于 Usage above 卷 Volume 卸载 Uninstall @@ -331,6 +334,7 @@ npm 缓存 npm Cache 只在打开温度与风扇页面时记录 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 +只在菜单栏显示电池或打开电池页面时记录 Recorded only while Battery is in the menu bar or the Battery 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) 只有国家、省 / 州与城市;把公网 IP 发给 db-ip.com Country, state and city only; sends your public IP to db-ip.com 只查找以该应用包名命名的文件,以及 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. The Dock icon is removed as well @@ -636,6 +640,7 @@ npm 缓存 npm Cache 最强 Strongest 最忙的核心 Busiest core 最快,重活优先交给它们 Fastest; heavy work goes here first +最近 24 小时 Last 24 hours 最近 60 秒 Last 60 seconds 最近没有应用在读写磁盘 No apps are reading or writing right now 最近的内存压力走势 Recent memory pressure @@ -811,12 +816,16 @@ npm 缓存 npm Cache 由 macOS 调节 Managed by macOS 由辅助工具执行 Handled by the helper 电池 Battery +电池 {} Battery {} 电池供电 On battery +电池健康 Battery health 电池健康度下降 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 temperature +电池电量 Battery level 电池设置 Battery Settings 电源 Power 电源供电 On power adapter @@ -824,7 +833,9 @@ npm 缓存 npm Cache 电源适配器{} Power adapter{} 电量 Battery 电量下限 Battery floor +电量与充电状态;没有电池的 Mac 显示蓝牙设备电量 Charge level and charging state; Macs without a battery show Bluetooth device batteries 电量低于 {}%,已关闭合盖运行 Battery below {}%; lid mode turned off +电量历史 Charge history 电量条 Meter 登录以同步设置 Sign in to sync settings 登录启动项 Launch Agents @@ -871,6 +882,7 @@ npm 缓存 npm Cache 系统服务 System Services 系统正在把内存写到磁盘,可能会变慢。可以关掉占用大的应用。 The system is swapping memory to disk and may slow down. Quit apps that use a lot of memory. 系统版号 Build +系统电池设置… System Battery Settings… 系统级资源库(第三方驱动、辅助工具或后台服务) System Library (third-party drivers, helpers or background services) 系统维护 Maintenance 系统自动 Automatic @@ -934,7 +946,9 @@ npm 缓存 npm Cache 菜单栏项目 Menu Bar Items 菜单栏风格 Menu bar style 蓝牙设备 Bluetooth Devices +蓝牙设备电量 {}% Bluetooth device battery {}% 蓝牙设备电量低 Bluetooth Battery Low +蓝牙设备电量低时提示 Low Bluetooth battery hint 解压失败:{} Unzip failed: {} 计算中 Calculating 记录历史数据 Record History @@ -990,13 +1004,17 @@ npm 缓存 npm Cache 运行中 · PID {} Running · PID {} 还可用 Available 还有 {} 个 {} more +还没有足够的记录。菜单栏显示电池或打开这个页面时,每分钟记录一次电量 Not enough records yet. The level is recorded every minute while Battery is in the menu bar or this page is open +还能用 {} {} left 这个快捷键已被其他应用或系统占用,请换一个 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 +这台 Mac 没有电池:菜单栏显示电量最低的蓝牙设备,弹窗只列蓝牙设备 This Mac has no battery: the menu bar shows the Bluetooth device with the lowest battery, and the popover lists Bluetooth devices only 这段时间没有记录 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. +这里只显示已连接蓝牙设备的电量 Only connected Bluetooth devices are shown here 进程 Processes 进程 {} · 线程 {} Processes {} · Threads {} 进程名:{} Process name: {} @@ -1028,9 +1046,11 @@ npm 缓存 npm Cache 重新扫描 Rescan 重新查询公网 IP Look Up Public IP Again 重新生成 Regenerate +重新读取蓝牙设备电量 Read Bluetooth device batteries again 重置上传与下载统计:从现在起重新累计。重启后自动回到开机后的累计;右键可改回 Reset the upload and download totals and count from now on. After a restart they go back to since-boot totals; right-click to switch back 重置后上传 Uploaded since reset 重置后下载 Downloaded since reset +键盘、鼠标、耳机等低于 20% 时,在菜单栏的电池项目旁显示该设备的图标与电量;需要开启电池项目 When a keyboard, mouse, headphones or similar drops below 20%, show its icon and level next to the Battery item in the menu bar; requires the Battery item 闲 Idle 防休眠 Keep Awake 防休眠已开启 Keep Awake is on @@ -1066,6 +1086,7 @@ npm 缓存 npm Cache ({}) ({}) , , ,{} 项未能移动 , {} couldn't be moved +,充电中 , charging ,已从程序坞移除 , removed from the Dock :{} : {} """# diff --git a/Packages/OpenStatsKit/Sources/Metrics/HistoryDatabase.swift b/Packages/OpenStatsKit/Sources/Metrics/HistoryDatabase.swift index 866bdb4..0933083 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/HistoryDatabase.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/HistoryDatabase.swift @@ -16,9 +16,12 @@ public struct HistoryRecord: Sendable, Equatable { public var gpu: Double? public var temperature: Double? public var power: Double? + /// 电池电量 0...1;没有电池或没在看电池时为空 + public var battery: Double? public init(minute: Int, cpu: Double? = nil, cpuMax: Double? = nil, memory: Double? = nil, pressure: Int? = nil, - download: Double? = nil, upload: Double? = nil, gpu: Double? = nil, temperature: Double? = nil, power: Double? = nil) { + download: Double? = nil, upload: Double? = nil, gpu: Double? = nil, temperature: Double? = nil, power: Double? = nil, + battery: Double? = nil) { self.minute = minute self.cpu = cpu self.cpuMax = cpuMax @@ -29,6 +32,7 @@ public struct HistoryRecord: Sendable, Equatable { self.gpu = gpu self.temperature = temperature self.power = power + self.battery = battery } } @@ -44,6 +48,7 @@ public struct HistoryPoint: Sendable, Equatable { public var gpu: Double? public var temperature: Double? public var power: Double? + public var battery: Double? } /// 本机 SQLite 历史库(~/Library/Application Support/OpenStats/history.sqlite),只存每分钟汇总,7 天约 1 万行 @@ -75,7 +80,7 @@ public actor HistoryDatabase { CREATE TABLE IF NOT EXISTS samples ( minute INTEGER PRIMARY KEY, cpu REAL, cpu_max REAL, memory REAL, pressure INTEGER, - download REAL, upload REAL, gpu REAL, temperature REAL, power REAL + download REAL, upload REAL, gpu REAL, temperature REAL, power REAL, battery REAL ); """ guard sqlite3_exec(handle, schema, nil, nil, nil) == SQLITE_OK else { @@ -84,6 +89,8 @@ public actor HistoryDatabase { db = nil throw Error.open(message) } + // 旧库没有 battery 列:加上;新库已经有,这句报“列已存在”,忽略 + sqlite3_exec(handle, "ALTER TABLE samples ADD COLUMN battery REAL", nil, nil, nil) } deinit { @@ -92,15 +99,15 @@ public actor HistoryDatabase { public func insert(_ record: HistoryRecord) { let sql = """ - INSERT OR REPLACE INTO samples (minute, cpu, cpu_max, memory, pressure, download, upload, gpu, temperature, power) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO samples (minute, cpu, cpu_max, memory, pressure, download, upload, gpu, temperature, power, battery) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ var statement: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return } defer { sqlite3_finalize(statement) } sqlite3_bind_int64(statement, 1, Int64(record.minute)) let values: [Double?] = [record.cpu, record.cpuMax, record.memory, record.pressure.map(Double.init), - record.download, record.upload, record.gpu, record.temperature, record.power] + record.download, record.upload, record.gpu, record.temperature, record.power, record.battery] for (index, value) in values.enumerated() { let position = Int32(index + 2) if let value, value.isFinite { @@ -117,7 +124,7 @@ public actor HistoryDatabase { let size = max(60, bucket) let sql = """ SELECT (minute / ?) * ? AS slot, AVG(cpu), MAX(cpu_max), AVG(memory), MAX(pressure), - AVG(download), AVG(upload), AVG(gpu), MAX(temperature), AVG(power) + AVG(download), AVG(upload), AVG(gpu), MAX(temperature), AVG(power), AVG(battery) FROM samples WHERE minute >= ? AND minute < ? GROUP BY slot ORDER BY slot """ @@ -136,7 +143,8 @@ public actor HistoryDatabase { while sqlite3_step(statement) == SQLITE_ROW { points.append(HistoryPoint(date: Date(timeIntervalSince1970: TimeInterval(sqlite3_column_int64(statement, 0))), cpu: double(1), cpuMax: double(2), memory: double(3), pressure: double(4).map { Int($0) }, - download: double(5), upload: double(6), gpu: double(7), temperature: double(8), power: double(9))) + download: double(5), upload: double(6), gpu: double(7), temperature: double(8), power: double(9), + battery: double(10))) } return points } @@ -171,7 +179,7 @@ public struct HistoryAccumulator: Sendable { /// 加入一次采样;跨到下一分钟时返回上一分钟的记录 public mutating func add(date: Date, cpu: Double?, memory: Double?, pressure: Int?, download: Double?, upload: Double?, - gpu: Double?, temperature: Double?, power: Double?) -> HistoryRecord? { + gpu: Double?, temperature: Double?, power: Double?, battery: Double? = nil) -> HistoryRecord? { let current = Int(date.timeIntervalSince1970) / 60 * 60 var finished: HistoryRecord? if let minute, minute != current { @@ -197,6 +205,7 @@ public struct HistoryAccumulator: Sendable { average("gpu", gpu) peak("temperature", temperature) average("power", power) + average("battery", battery) return finished } @@ -215,6 +224,6 @@ public struct HistoryAccumulator: Sendable { func mean(_ key: String) -> Double? { sums[key].map { $0.total / Double($0.count) } } return HistoryRecord(minute: minute, cpu: mean("cpu"), cpuMax: maxima["cpu"], memory: mean("memory"), pressure: maxima["pressure"].map { Int($0) }, download: mean("download"), upload: mean("upload"), - gpu: mean("gpu"), temperature: maxima["temperature"], power: mean("power")) + gpu: mean("gpu"), temperature: maxima["temperature"], power: mean("power"), battery: mean("battery")) } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index b9d38b8..39db1ad 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -66,7 +66,7 @@ public final class AppController: NSObject, NSApplicationDelegate { model.sync.presentationAnchor = { [weak self] in self?.mainWindow.nsWindow } model.sync.start() - // 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口 + // 开发调试:--show-panel [cpu|memory|network|gpu|disk|temperature|fan|battery] 启动后展开并固定弹窗;--show-window 打开主窗口 let arguments = CommandLine.arguments if let index = arguments.firstIndex(of: "--show-panel") { let item = arguments.dropFirst(index + 1).first.flatMap(MenuBarItem.init(rawValue:)) ?? .network @@ -90,6 +90,7 @@ public final class AppController: NSObject, NSApplicationDelegate { } let appModel = model + appModel.bluetooth.setDemand(appModel.bluetoothDemand) Task { [weak self] in await appModel.hub.update(appModel.demand) await appModel.hub.start { [weak self] snapshot in @@ -174,6 +175,7 @@ public final class AppController: NSObject, NSApplicationDelegate { private func observeModel() { withObservationTracking { _ = model.demand + _ = model.bluetoothDemand _ = model.settings.menuBarItems _ = model.settings.menuBarLayout _ = model.settings.menuBarStyle @@ -195,6 +197,7 @@ public final class AppController: NSObject, NSApplicationDelegate { self.applyLanguageIfChanged() self.model.alerts.applySettings() await self.model.hub.update(self.model.demand) + self.model.bluetooth.setDemand(self.model.bluetoothDemand) self.applyAppearance() self.menuBar.update() self.menuBar.refreshPopoverHeight() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift index 1142762..418fa2b 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift @@ -17,8 +17,16 @@ struct MenuBarReading { var download: Double? var temperature: Double? var fanRPM: Double? + var battery: Double? + var batteryHistory: [Double] = [] + var batteryCharging = false + /// 附在电池项旁的蓝牙设备:电量低的那一个,或没有电池的 Mac 上电量最低的那一个 + var bluetoothDevice: (symbol: String, percent: Int)? var keepAwake = false + /// 蓝牙设备电量低于这个百分比才在菜单栏提示 + static let lowBluetoothPercent = 20 + @MainActor init(model: AppModel) { let store = model.store @@ -34,6 +42,13 @@ struct MenuBarReading { download = store.network?.downloadBytesPerSecond temperature = store.sensors?.temperature(.cpu)?.maximum fanRPM = store.fastestFan?.current + battery = store.battery?.level + batteryHistory = battery.map { Array(repeating: $0, count: 30) } ?? [] + batteryCharging = store.battery?.isCharging ?? false + if let lowest = model.bluetooth.lowest, + battery == nil || (model.settings.bluetoothLowBatteryInMenuBar && lowest.percent <= Self.lowBluetoothPercent) { + bluetoothDevice = (lowest.device.kind.symbol, lowest.percent) + } keepAwake = model.keepAwake.isActive } @@ -58,6 +73,8 @@ struct MenuBarReading { reading.download = 1.4 * 1024 * 1024 reading.temperature = 52 reading.fanRPM = 1840 + reading.battery = 0.82 + reading.batteryHistory = Array(repeating: 0.82, count: 30) return reading }() @@ -67,6 +84,7 @@ struct MenuBarReading { case .gpu: (gpu, gpuHistory) case .memory: (memory, memoryHistory) case .disk: (disk, diskHistory) + case .battery: (battery, batteryHistory) default: (nil, []) } } @@ -83,6 +101,9 @@ struct MenuBarReading { 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))") } + case .battery: + battery.map { tr("电池 \(Format.percent($0))") + (batteryCharging ? tr(",充电中") : "") } + ?? bluetoothDevice.map { tr("蓝牙设备电量 \($0.percent)%") } } } .joined(separator: "\n") @@ -119,6 +140,8 @@ enum MenuBarRenderer { static let trackAlpha: CGFloat = 0.25 static let warningLevel = 0.6 static let highLevel = 0.85 + /// 电量低于这个比例按警示着色 + static let lowBatteryLevel = 0.2 } private struct Segment { @@ -200,6 +223,58 @@ enum MenuBarRenderer { return textSegment(item: item, value: text, sample: "100°", style: style) case .fan: return textSegment(item: item, value: reading.fanRPM.map { "\(Int($0))" } ?? "—", sample: "8888", style: style) + case .battery: + return batterySegment(reading: reading, style: style, colorizeHighLoad: colorizeHighLoad) + } + } + + /// 电池:按电量画图形,电量低时着色为警示;充电中在前面加一道闪电。 + /// 没有电池的 Mac 显示电量最低的蓝牙设备;开了提示且某个设备电量低时,附在电池后面 + private static func batterySegment(reading: MenuBarReading, style: MenuBarStyle, colorizeHighLoad: Bool) -> Segment { + let bluetooth = reading.bluetoothDevice.map { device in + combine([symbolSegment(device.symbol), + inlineValue("\(device.percent)%", sample: "100%", + alert: colorizeHighLoad && device.percent <= MenuBarReading.lowBluetoothPercent)]) + } + guard let level = reading.battery else { + return bluetooth ?? textSegment(item: .battery, value: "—", sample: "100%", style: style) + } + let fraction = min(1, max(0, level)) + let text = Format.percent(fraction) + let alert = colorizeHighLoad && fraction <= Metrics.lowBatteryLevel + let label = MenuBarItem.battery.menuBarLabel + let stacked = stackedText(label: label, value: text, sample: "100%", alert: alert) + var segment: Segment + switch style { + case .stacked: segment = stacked + case .inline: segment = inlineText(label: label, value: text, sample: "100%", alert: alert) + case .icon: + let symbol = reading.batteryCharging ? "battery.100.bolt" : batterySymbol(fraction) + segment = combine([symbolSegment(symbol), inlineValue(text, sample: "100%", alert: alert)]) + case .ring: segment = combine([ring(fraction: fraction, alert: alert), stacked]) + case .pie: segment = combine([pie(fraction: fraction, alert: alert), stacked]) + case .history: segment = combine([historyBars(reading.batteryHistory, alert: alert), stacked]) + case .line: segment = combine([historyLine(reading.batteryHistory, alert: alert), stacked]) + case .meter: segment = combine([meter(fraction: fraction, alert: alert), stacked]) + // 圆点的颜色档按“越满越危险”定义,电量正好相反 + case .dot: segment = combine([levelDot(fraction: 1 - fraction), stacked]) + } + if reading.batteryCharging && style != .icon { + segment = combine([symbolSegment("bolt.fill"), segment]) + } + if let bluetooth { + segment = combine([segment, bluetooth], gap: DS.Space.s2) + } + return segment + } + + private static func batterySymbol(_ fraction: Double) -> String { + switch fraction { + case ..<0.125: "battery.0" + case ..<0.375: "battery.25" + case ..<0.625: "battery.50" + case ..<0.875: "battery.75" + default: "battery.100" } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/HistoryPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/HistoryPage.swift index d3c26fb..250c2ea 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/HistoryPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/HistoryPage.swift @@ -65,6 +65,12 @@ struct HistoryPage: View { series: [.init(label: tr("平均"), color: DS.Palette.warning, value: \.power)], maxValue: nil, format: { Format.watts($0) }, emptyNote: tr("只在打开温度与风扇页面时记录")) + if model.store.battery != nil { + HistoryChartCard(icon: "battery.75", title: tr("电池电量"), points: points, start: start, end: end, hoverDate: $hoverDate, + series: [.init(label: tr("电量"), color: DS.Palette.success, value: \.battery)], + maxValue: 1, format: { Format.percent($0) }, + emptyNote: tr("只在菜单栏显示电池或打开电池页面时记录")) + } } SettingsGroup(caption: tr("历史记录")) { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index 152c637..2f26806 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -39,6 +39,7 @@ public struct MainWindowView: View { NetworkSettings() } case .thermal: ThermalPage() + case .battery: DetailPage { BatteryPopover() } case .processes: ProcessesPage() case .keepAwake: KeepAwakePage() case .cleaner: CleanerPage() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SystemInfoPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SystemInfoPage.swift index 36bac9d..8378b26 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SystemInfoPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SystemInfoPage.swift @@ -223,67 +223,13 @@ struct DisplayInfo { } } -/// 已连接蓝牙设备的电量,页面打开期间每分钟刷新 +/// 已连接蓝牙设备的电量;页面打开期间由 BluetoothController 每分钟刷新 private struct BluetoothCard: View { - @State private var devices: [BluetoothDevice]? + @Environment(AppModel.self) private var model var body: some View { InfoCard(icon: "dot.radiowaves.left.and.right", title: tr("蓝牙设备")) { - if let devices { - if devices.isEmpty { - Text(tr("没有已连接的蓝牙设备")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) - } - ForEach(devices) { device in - HStack(spacing: DS.Space.s2) { - Image(systemName: symbol(device.kind)) - .font(.system(size: DS.TextSize.sm.rawValue)) - .foregroundStyle(DS.Palette.textSecondary) - .frame(width: DS.Size.iconStandalone) - Text(verbatim: device.name) - .dsFont(.xs, weight: .medium) - .foregroundStyle(DS.Palette.textPrimary) - .lineLimit(1) - Spacer(minLength: DS.Space.s2) - if device.batteries.isEmpty { - Text(tr("不提供电量")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) - } - ForEach(device.batteries, id: \.label) { battery in - HStack(spacing: DS.Space.s1) { - if device.batteries.count > 1 { - Text(verbatim: battery.label).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) - } - ProgressTrack(fraction: Double(battery.percent) / 100, - color: battery.percent <= 20 ? DS.Palette.error : DS.Palette.success, - height: DS.Space.s1 + DS.Space.s1 / 2) - .frame(width: DS.Space.s8) - Text(verbatim: "\(battery.percent)%") - .dsFont(.xs, weight: .medium) - .foregroundStyle(DS.Palette.textPrimary) - .monospacedDigit() - } - } - } - } - } else { - Text(tr("正在读取…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) - } - } - .task { - while !Task.isCancelled { - devices = await Task.detached { BluetoothBatteryReader.read() }.value - try? await Task.sleep(for: .seconds(60)) - } - } - } - - private func symbol(_ kind: BluetoothDevice.Kind) -> String { - switch kind { - case .keyboard: "keyboard" - case .mouse: "computermouse" - case .trackpad: "rectangle.and.hand.point.up.left" - case .headphones: "headphones" - case .phone: "iphone" - case .other: "dot.radiowaves.left.and.right" + BluetoothDeviceList(devices: model.bluetooth.devices) } } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/BatteryPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/BatteryPopover.swift new file mode 100644 index 0000000..d5d1049 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/BatteryPopover.swift @@ -0,0 +1,232 @@ +import AppKit +import Localization +import Metrics +import SwiftUI + +/// 电池详情:电量与充电状态、最近 24 小时电量曲线、功耗、健康度,以及已连接蓝牙设备的电量。 +/// 没有电池的 Mac(mini、Studio、iMac)只显示蓝牙设备 +struct BatteryPopover: View { + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + let settings = model.settings + let store = model.store + let battery = store.battery + + BatteryHero(battery: battery) + + ForEach(MenuBarItem.battery.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in + switch section { + case .batteryHistory: + if battery != nil { + BatteryHistorySection() + } + case .batteryPower: + if battery != nil { + SectionCard(title: section.title, trailing: { Text(tr("最近 60 秒")) }) { + PowerRows(power: store.power, history: store.powerHistory.elements, compact: !isDetailPage) + } + } + case .batteryHealth: + if let battery { + BatteryHealthSection(battery: battery) + } + case .batteryBluetooth: + BluetoothSection() + default: + EmptyView() + } + } + } +} + +// MARK: - 顶部 + +private struct BatteryHero: View { + let battery: BatteryStatus? + @Environment(AppModel.self) private var model + + var body: some View { + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + if let battery { + let tone: Tone = battery.level <= 0.2 ? .error : battery.isCharging ? .primary : .success + HStack(spacing: DS.Space.s3) { + RingGauge(fraction: battery.level, color: tone.color, size: DS.Space.s12 + DS.Space.s2) { + Image(systemName: battery.isCharging ? "bolt.fill" : "laptopcomputer") + .font(.system(size: DS.TextSize.sm.rawValue, weight: .medium)) + .foregroundStyle(battery.isCharging ? DS.Palette.primary : DS.Palette.textSecondary) + } + VStack(alignment: .leading, spacing: DS.Space.s1 / 2) { + HeroValue(value: Format.percent(battery.level)) + Text(Self.stateText(battery)) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textSecondary) + if let minutes = battery.minutesRemaining { + Text(battery.isCharging ? tr("\(Format.duration(minutes: minutes))后充满") : tr("还能用 \(Format.duration(minutes: minutes))")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } + } + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: DS.Space.s1) { + if let watts = battery.adapterWatts, battery.isPluggedIn { + Chip(text: "\(watts) W", icon: "bolt.fill", tone: .warning) + } + if let temperature = model.store.sensors?.temperature(.battery) { + Chip(text: Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit), + icon: "thermometer.medium", tone: Tone.forTemperature(temperature.maximum)) + } + } + } + } else { + HStack(spacing: DS.Space.s3) { + Image(systemName: "desktopcomputer") + .font(.system(size: DS.TextSize.lg.rawValue)) + .foregroundStyle(DS.Palette.textSecondary) + VStack(alignment: .leading, spacing: DS.Space.s1 / 2) { + Text(tr("这台 Mac 没有电池")).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) + Text(tr("这里只显示已连接蓝牙设备的电量")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + } + Spacer(minLength: 0) + } + } + } + } + + static func stateText(_ battery: BatteryStatus) -> String { + if battery.isCharging { return tr("充电中") } + if battery.isPluggedIn { return battery.isFullyCharged ? tr("已充满") : tr("电源供电") } + return tr("电池供电") + } +} + +// MARK: - 电量历史 + +/// 最近 24 小时的电量曲线,来自本机历史库;打开期间每 5 分钟重新读一次 +private struct BatteryHistorySection: View { + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + let values = model.history.batteryPoints.compactMap(\.battery) + SectionCard(title: PopoverSection.batteryHistory.title, trailing: { Text(tr("最近 24 小时")) }) { + if values.count > 1 { + LineHistoryChart(values: values, capacity: values.count, maxValue: 1, color: DS.Palette.success, + grid: isDetailPage, height: detailChartHeight(isDetailPage)) + } else { + Text(tr("还没有足够的记录。菜单栏显示电池或打开这个页面时,每分钟记录一次电量")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .task { + while !Task.isCancelled { + model.history.loadBattery() + try? await Task.sleep(for: .seconds(300)) + } + } + } +} + +// MARK: - 健康 + +private struct BatteryHealthSection: View { + let battery: BatteryStatus + @Environment(AppModel.self) private var model + + var body: some View { + SectionCard(title: PopoverSection.batteryHealth.title) { + InfoRow(label: tr("健康度")) { + Text(verbatim: battery.health.map { Format.percent($0) } ?? "—") + .foregroundStyle(battery.health.map { $0 < 0.8 ? DS.Palette.warning : DS.Palette.textPrimary } ?? DS.Palette.textPrimary) + } + InfoRow(label: tr("循环次数"), text: battery.cycleCount.map(String.init) ?? "—") + if let temperature = model.store.sensors?.temperature(.battery) { + InfoRow(label: tr("电池温度"), text: Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit)) + } + InfoRow(label: tr("电源"), text: battery.isPluggedIn + ? tr("电源适配器\(battery.adapterWatts.map { " · \($0)W" } ?? "")") + : tr("电池供电")) + Text(tr("健康度是现在充满时的容量与出厂容量之比,低于 80% 时苹果建议更换电池")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Spacer() + Button(tr("系统电池设置…")) { + if let url = URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension") { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(DSButtonStyle(kind: .ghost)) + } + } + } +} + +// MARK: - 蓝牙设备 + +/// 已连接蓝牙设备的电量;弹窗打开期间每分钟刷新,也可以手动刷新 +private struct BluetoothSection: View { + @Environment(AppModel.self) private var model + + var body: some View { + let bluetooth = model.bluetooth + SectionCard(title: PopoverSection.batteryBluetooth.title, trailing: { + if let updatedAt = bluetooth.updatedAt { + Text(verbatim: updatedAt.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: L10n.locale))) + } + RefreshButton(loading: bluetooth.isReading, help: tr("重新读取蓝牙设备电量")) { bluetooth.refresh() } + }) { + BluetoothDeviceList(devices: bluetooth.devices) + } + } +} + +/// 蓝牙设备电量列表:本机信息页与电池弹窗共用 +struct BluetoothDeviceList: View { + let devices: [BluetoothDevice]? + + var body: some View { + if let devices { + if devices.isEmpty { + Text(tr("没有已连接的蓝牙设备")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ForEach(devices) { device in + HStack(spacing: DS.Space.s2) { + Image(systemName: device.kind.symbol) + .font(.system(size: DS.TextSize.sm.rawValue)) + .foregroundStyle(DS.Palette.textSecondary) + .frame(width: DS.Size.iconStandalone) + Text(verbatim: device.name) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + .lineLimit(1) + Spacer(minLength: DS.Space.s2) + if device.batteries.isEmpty { + Text(tr("不提供电量")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ForEach(device.batteries, id: \.label) { battery in + HStack(spacing: DS.Space.s1) { + if device.batteries.count > 1 { + Text(verbatim: battery.label).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ProgressTrack(fraction: Double(battery.percent) / 100, + color: battery.percent <= 20 ? DS.Palette.error : DS.Palette.success, + height: DS.Space.s1 + DS.Space.s1 / 2) + .frame(width: DS.Space.s8) + Text(verbatim: "\(battery.percent)%") + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + .monospacedDigit() + } + } + } + } + } else { + Text(tr("正在读取…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift index 8cc3992..2e2e42b 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift @@ -23,6 +23,7 @@ struct PopoverRootView: View { case .gpu: GPUPopover() case .disk: DiskPopover() case .temperature, .fan: ThermalPopover(item: item) + case .battery: BatteryPopover() } } .frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index c71890a..fd6439a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -317,7 +317,7 @@ struct MenuBarSettings: View { ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in GroupRow { VStack(alignment: .leading, spacing: DS.Space.s3) { - SettingRow(title: item.title, subtitle: item.subtitle, icon: item.symbol) { + SettingRow(title: item.title, subtitle: itemSubtitle(item), icon: item.symbol) { DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }), label: item.title) @@ -335,13 +335,26 @@ struct MenuBarSettings: View { SettingsGroup(caption: tr("其他")) { GroupRow(showsDivider: false) { - SettingRow(title: tr("高负载时着色"), subtitle: tr("占用超过 85% 时数值与图形显示为红色")) { + SettingRow(title: tr("高负载时着色"), subtitle: tr("占用超过 85%(电池电量低于 20%)时数值与图形显示为红色")) { DSToggle(isOn: $settings.colorizeHighLoad, label: tr("高负载时着色")) } } + GroupRow { + SettingRow(title: tr("蓝牙设备电量低时提示"), + subtitle: tr("键盘、鼠标、耳机等低于 20% 时,在菜单栏的电池项目旁显示该设备的图标与电量;需要开启电池项目")) { + DSToggle(isOn: $settings.bluetoothLowBatteryInMenuBar, label: tr("蓝牙设备电量低时提示")) + } + } } } + /// 没有电池的 Mac 上,电池项目改为显示蓝牙设备电量 + private func itemSubtitle(_ item: MenuBarItem) -> String { + item == .battery && model.store.battery == nil + ? tr("这台 Mac 没有电池:菜单栏显示电量最低的蓝牙设备,弹窗只列蓝牙设备") + : item.subtitle + } + } /// 单个项目自己的菜单栏风格:网速有自己的三种样式;其他项目默认跟随整体,也可以单独选一种。 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index 9b43f43..adf82a2 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -13,7 +13,7 @@ enum SnapshotRenderer { let defaults = UserDefaults(suiteName: "OpenStats.snapshot") ?? .standard let settings = AppSettings(defaults: defaults) - settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature] + settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature, .battery] settings.probeSeconds = 1 // 历史页用示例数据:最近 24 小时每分钟一条,中间留一段“睡眠”空档 let historyURL = FileManager.default.temporaryDirectory.appendingPathComponent("openstats-snapshot-history.sqlite") diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 2c5c5de..65c9297 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -23,6 +23,7 @@ public final class AppModel { let diagnostics = DiagnosticsExporter() public let alerts: AlertController public let uninstaller = UninstallerController() + public let bluetooth = BluetoothController() let startupItems = StartupItemsController() public let history: HistoryRecorder public let sync: SyncController @@ -79,6 +80,7 @@ public final class AppModel { demand.disk = (window && [.overview, .system, .cleaner, .disk].contains(tab)) || menu.contains(.disk) || popover == .disk demand.diskDetail = showing(.disk, .disk) demand.battery = (window && [.overview, .system, .keepAwake].contains(tab)) || keepAwake.lidClosedActive + || menu.contains(.battery) || showing(.battery, .battery) demand.processes = (window && [.processes, .overview, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory) || popover == .disk demand.systemProcesses = window && tab == .processes @@ -88,15 +90,24 @@ public final class AppModel { if (window && tab == .thermal) || thermalPopover { groups.formUnion(TemperatureGroup.allCases) } if menu.contains(.temperature) || fans.mode != .automatic || showing(.cpu, .cpu) { groups.insert(.cpu) } if showing(.gpu, .gpu) { groups.insert(.gpu) } + if showing(.battery, .battery) { groups.insert(.battery) } if settings.enabledAlerts.contains(.cpuTemperature) { groups.insert(.cpu) } demand.temperatures = groups - demand.power = (window && tab == .thermal) || thermalPopover + demand.power = (window && tab == .thermal) || thermalPopover || showing(.battery, .battery) demand.cpuFrequency = showing(.cpu, .cpu) demand.fans = (window && (tab == .thermal || tab == .overview)) || menu.contains(.fan) || fans.mode != .automatic || thermalPopover return demand } + /// 蓝牙电量读取很慢,只在需要时轮询:电池弹窗 / 页面、本机信息页打开时每分钟; + /// 菜单栏开着电池项且要做低电量提示、或这台 Mac 没有电池时每 5 分钟 + var bluetoothDemand: BluetoothController.Demand { + if (isMainWindowVisible && [.battery, .system].contains(settings.panelTab)) || openPopover == .battery { return .foreground } + if settings.menuBarItems.contains(.battery) && (settings.bluetoothLowBatteryInMenuBar || store.battery == nil) { return .background } + return .off + } + /// 网络详情(接口、公网 IP、进程流量)正在显示 var isNetworkDetailVisible: Bool { openPopover == .network || (isMainWindowVisible && settings.panelTab == .network) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 4de0537..f589dd0 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -4,7 +4,7 @@ import Metrics import Observation public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { - case cpu, memory, network, gpu, disk, temperature, fan + case cpu, memory, network, gpu, disk, temperature, fan, battery public var id: String { rawValue } @@ -17,6 +17,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .disk: tr("磁盘") case .temperature: tr("CPU 温度") case .fan: tr("风扇转速") + case .battery: tr("电池") } } @@ -29,6 +30,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .disk: tr("启动磁盘已用占比") case .temperature: tr("CPU 核心最高温度") case .fan: tr("转速最高的风扇") + case .battery: tr("电量与充电状态;没有电池的 Mac 显示蓝牙设备电量") } } @@ -42,6 +44,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .disk: "DISK" case .temperature: "TEMP" case .fan: "FAN" + case .battery: "BAT" } } @@ -55,6 +58,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .disk: tr("磁盘") case .temperature: tr("温度") case .fan: tr("风扇") + case .battery: tr("电池") } } @@ -68,6 +72,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .disk: [.diskActivity, .diskHealth, .diskProcesses] case .temperature: [.thermalSensors, .thermalFans, .thermalPower] case .fan: [.thermalFans, .thermalSensors, .thermalPower] + case .battery: [.batteryHistory, .batteryPower, .batteryHealth, .batteryBluetooth] } } @@ -83,6 +88,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .disk: "internaldrive" case .temperature: "thermometer.medium" case .fan: "fan" + case .battery: "battery.75" } } } @@ -95,6 +101,7 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { case gpuHistory, gpuDetails case diskActivity, diskHealth, diskProcesses case thermalSensors, thermalFans, thermalPower + case batteryHistory, batteryPower, batteryHealth, batteryBluetooth public var id: String { rawValue } @@ -125,7 +132,10 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { case .diskProcesses: tr("读写最多的应用") case .thermalSensors: tr("温度") case .thermalFans: tr("风扇") - case .thermalPower: tr("功耗") + case .thermalPower, .batteryPower: tr("功耗") + case .batteryHistory: tr("电量历史") + case .batteryHealth: tr("电池健康") + case .batteryBluetooth: tr("蓝牙设备") } } } @@ -299,12 +309,12 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable { /// 主窗口侧边栏的页面 public enum PanelTab: String, CaseIterable, Identifiable, Sendable { - case overview, system, history, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner, uninstaller, startupItems + case overview, system, history, cpu, gpu, memory, disk, network, thermal, battery, 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 monitors: [PanelTab] = [.overview, .system, .history, .cpu, .gpu, .memory, .disk, .network, .thermal, .battery] static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .cleaner, .uninstaller] static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsAccount, .settingsHelper, .settingsAbout] @@ -324,6 +334,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .disk: tr("磁盘") case .network: tr("网络") case .thermal: tr("温度与风扇") + case .battery: tr("电池") case .processes: tr("进程") case .keepAwake: tr("防休眠") case .cleaner: tr("清理") @@ -349,6 +360,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .disk: "internaldrive" case .network: "network" case .thermal: "fan" + case .battery: "battery.75" case .processes: "list.bullet.rectangle" case .keepAwake: "cup.and.saucer" case .cleaner: "sparkles" @@ -372,6 +384,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .network: .network case .disk: .disk case .thermal: .temperature + case .battery: .battery default: nil } } @@ -392,6 +405,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .network: self = .network case .disk: self = .disk case .temperature, .fan: self = .thermal + case .battery: self = .battery } } } @@ -424,6 +438,10 @@ public final class AppSettings { 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) } } @@ -544,6 +562,7 @@ public final class AppSettings { refreshSeconds = Self.refreshOptions.contains(defaults.integer(forKey: Keys.refreshSeconds)) ? defaults.integer(forKey: Keys.refreshSeconds) : 2 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 @@ -613,6 +632,7 @@ public final class AppSettings { static let styleOverrides = "styleOverrides" static let refreshSeconds = "refreshSeconds" static let colorizeHighLoad = "colorizeHighLoad" + static let bluetoothLowBatteryInMenuBar = "bluetoothLowBatteryInMenuBar" static let useFahrenheit = "useFahrenheit" static let lidModeBatteryFloor = "lidModeBatteryFloor" static let fanSafetyTemperature = "fanSafetyTemperature" diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/BluetoothController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/BluetoothController.swift new file mode 100644 index 0000000..bdfcec6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/BluetoothController.swift @@ -0,0 +1,85 @@ +import Foundation +import Metrics +import Observation + +/// 已连接蓝牙设备的电量。读取要起 system_profiler、一次一两秒,所以只在有人看或菜单栏需要时轮询 +@MainActor +@Observable +public final class BluetoothController { + public enum Demand: Equatable, Sendable { + /// 不轮询 + case off + /// 菜单栏需要(电量低提示、没有电池的 Mac 显示设备电量):每 5 分钟 + case background + /// 电池弹窗 / 页面、本机信息页打开着:每分钟 + case foreground + + var interval: Duration? { + switch self { + case .off: nil + case .background: .seconds(300) + case .foreground: .seconds(60) + } + } + } + + /// nil 表示还没读过 + public private(set) var devices: [BluetoothDevice]? + public private(set) var updatedAt: Date? + public private(set) var isReading = false + @ObservationIgnored private var demand: Demand = .off + @ObservationIgnored private var task: Task? + + /// 电量最低的一块电池(耳机的左右耳、充电盒分别算) + public var lowest: (device: BluetoothDevice, label: String, percent: Int)? { + var best: (device: BluetoothDevice, label: String, percent: Int)? + for device in devices ?? [] { + for battery in device.batteries { + if let current = best, battery.percent >= current.percent { continue } + best = (device, battery.label, battery.percent) + } + } + return best + } + + func setDemand(_ demand: Demand) { + guard demand != self.demand else { return } + self.demand = demand + task?.cancel() + task = nil + guard let interval = demand.interval else { return } + task = Task { [weak self] in + while !Task.isCancelled { + await self?.read() + try? await Task.sleep(for: interval) + } + } + } + + /// 弹窗里的刷新按钮 + func refresh() { + Task { await read() } + } + + private func read() async { + guard !isReading else { return } + isReading = true + devices = await Task.detached(priority: .utility) { BluetoothBatteryReader.read() }.value + updatedAt = Date() + isReading = false + } +} + +extension BluetoothDevice.Kind { + /// 列表与菜单栏里代表设备类型的图标 + var symbol: String { + switch self { + case .keyboard: "keyboard" + case .mouse: "computermouse" + case .trackpad: "rectangle.and.hand.point.up.left" + case .headphones: "headphones" + case .phone: "iphone" + case .other: "dot.radiowaves.left.and.right" + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HistoryRecorder.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HistoryRecorder.swift index ddf41d0..f2bbb39 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HistoryRecorder.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HistoryRecorder.swift @@ -43,6 +43,8 @@ public final class HistoryRecorder { public private(set) var rangeStart = Date() public private(set) var isQuerying = false public private(set) var recordCount = 0 + /// 电池弹窗 / 页面用的最近 24 小时电量,独立于历史页当前选的范围 + public private(set) var batteryPoints: [HistoryPoint] = [] @ObservationIgnored private let settings: AppSettings @ObservationIgnored private var database: HistoryDatabase? @@ -67,7 +69,8 @@ public final class HistoryRecorder { upload: snapshot.network?.uploadBytesPerSecond, gpu: snapshot.gpu?.utilization, temperature: snapshot.sensors?.temperature(.cpu)?.maximum, - power: snapshot.power?.system) + power: snapshot.power?.system, + battery: snapshot.battery?.level) guard let finished else { return } let now = Date() let prune = now.timeIntervalSince(lastPrune) > 60 * 60 @@ -102,6 +105,15 @@ public final class HistoryRecorder { } } + func loadBattery() { + guard let database else { return } + let end = Date() + let start = end.addingTimeInterval(-Range.day.duration) + Task { + batteryPoints = await database.query(from: start, to: end, bucket: Range.day.bucket) + } + } + func clear() { guard let database else { return } accumulator = HistoryAccumulator() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift index ced051d..046e870 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift @@ -12,6 +12,7 @@ public struct SettingsDocument: Codable, Equatable, Sendable { public var styleOverrides: [String: String]? public var refreshSeconds: Int? public var colorizeHighLoad: Bool? + public var bluetoothLowBatteryInMenuBar: Bool? public var useFahrenheit: Bool? public var lidModeBatteryFloor: Int? public var fanSafetyTemperature: Int? @@ -48,6 +49,7 @@ extension AppSettings { doc.styleOverrides = Dictionary(uniqueKeysWithValues: styleOverrides.map { ($0.key.rawValue, $0.value.rawValue) }) doc.refreshSeconds = refreshSeconds doc.colorizeHighLoad = colorizeHighLoad + doc.bluetoothLowBatteryInMenuBar = bluetoothLowBatteryInMenuBar doc.useFahrenheit = useFahrenheit doc.lidModeBatteryFloor = lidModeBatteryFloor doc.fanSafetyTemperature = fanSafetyTemperature @@ -94,6 +96,7 @@ extension AppSettings { }) assign(\.refreshSeconds, option(doc.refreshSeconds, in: Self.refreshOptions)) assign(\.colorizeHighLoad, doc.colorizeHighLoad) + assign(\.bluetoothLowBatteryInMenuBar, doc.bluetoothLowBatteryInMenuBar) assign(\.useFahrenheit, doc.useFahrenheit) assign(\.lidModeBatteryFloor, option(doc.lidModeBatteryFloor, in: Self.batteryFloorOptions)) assign(\.fanSafetyTemperature, option(doc.fanSafetyTemperature, in: Self.fanSafetyOptions)) diff --git a/README.md b/README.md index 08b8561..726b968 100644 --- a/README.md +++ b/README.md @@ -47,17 +47,41 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese. -Latest release **0.2.0** (2026-09-13) · **31** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) +Latest release **0.2.0** (2026-09-13) · **50** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
-2026-09-15 · Unreleased · 1 changed · 1 fixed · 1 added +2026-09-15 · Unreleased · 3 added · 2 added · 11 changed · 4 fixed · 1 added + +**Added** + +- 菜单栏新增“电池”项目:电量按九种风格显示(电量条、圆环、饼图、数字等),充电中带闪电,电量低于 20% 按“高负载时着色”变红。点击弹出电池详情:电量与剩余 / 充满时间、适配器功率、电池温度、最近 24 小时电量曲线、功耗、健康度与循环次数、已连接蓝牙设备的电量(AirPods 左右耳与充电盒、妙控键盘 / 鼠标 / 触控板),可跳到系统电池设置;主窗口也有对应的“电池”页,历史页多了电池电量曲线。没有电池的 Mac(mini、Studio、iMac)上这个项目改为显示电量最低的蓝牙设备,弹窗只列蓝牙设备。「设置 · 菜单栏」新增“蓝牙设备电量低时提示”:某个设备低于 20% 时在电池项目旁显示它的图标与电量。 +- 网络详情“接口”区块右上角新增重置按钮:把开机后下载 / 上传归零、从现在起重新累计,标题旁标出起算时间,两行改叫“重置后下载 / 上传”;重启后自动回到开机后累计,右键按钮可随时改回。 +- 磁盘页从展示页变成能动手的磁盘工具,新增四张卡片:- 空间占用:统计家目录里每个文件夹占多少,并列出最大的 20 个文件(应用、照片图库等按整体算一个);可在访达中显示,家目录里、Library 之外的文件可以直接移到废纸篓。只读扫描,文件多时需要几十秒,可随时停止。- 文件系统检查:调用系统的 diskutil 对启动盘做一次只读检查,相当于“磁盘工具”的急救但不修改任何东西;记住上次检查的时间与结果,发现问题时提示去磁盘工具修复。- 本地快照:列出 Time Machine 留在本机的快照(它们计入“可清除”空间),可一键全部删除;辅助工具够新时由它执行,否则请求一次管理员授权。辅助工具协议升到第 4 版,已安装的旧版会提示重新安装。- 其他磁盘:列出外置硬盘、U 盘、镜像与网络共享的容量,可在访达中显示或直接推出;接入、拔出时自动刷新。 + +**Added** + +- 网络详情新增「IP 纯净度」区块:CleanIP.io 的纯净度评分与等级画成 F 到 A+ 的六段色带,得分处有标记;下面列出风险评分、命中的风险标记(VPN、代理、Tor、机房、滥用记录等)与一句评价,可打开完整报告。IP 地址区块也多了反向解析、网络类型与接入方式、IP 类型(住宅 / 机房)、原生 / 广播、住宅概率。 +- 「设置 · 网络」新增归属地数据源选择:CleanIP.io(默认,信息最全、中文地名)、ipapi.is、DB-IP、ipinfo.io 或本地 GeoLite2 数据库;每一项都注明会把公网 IP 发给谁。在线数据源都由每台 Mac 自己直接查、不经过我们的服务器,同一个公网 IP 的结果会记住一段时间,收到限流后当天不再请求;切换数据源后立即重新查询。 **Changed** +- 「设置 · 菜单栏」每个显示项目下面多了一行“菜单栏风格”:给这一项单独选一种风格(默认跟随整体,菜单里标出整体现在是哪种),旁边是这一项按当前风格、实时读数画出的预览,改了立刻能看到。以前这个选择藏在开关旁一个没有说明的下拉里,不容易发现。温度、风扇只提供文字类的三种风格;原“风格”分组改名“整体风格”并加了说明。 +- 菜单栏弹窗右上角的两个按钮:左边改为该指标自己的图标(网络是网络图标、磁盘是硬盘),点了直接进主窗口里这个指标的页面;右边的齿轮进总设置。 +- 网络详情的 IP 地址区块精简:原生 / 广播、住宅 / 机房、运营商类型(ISP 等)改为 cleanip.io 样式的徽章,去掉反向解析、网络类型、住宅概率、数据来源四行;数据来源改为标题后的小标记,CleanIP.io 显示它的字标:与标题文字同高、不留上下空白,点了打开 cleanip.io。徽章样式作为通用组件收进设计系统。 +- 公网 IP 双栈支持:IPv4 与 IPv6 各自查询归属地与纯净度、各自缓存;两族都有时 IP 地址区块用胶囊开关切换 IPv4 / IPv6。纯净度区块的标题一行排开:地址族徽章(只有一族时不标)、置信度徽章(高绿、中灰、低黄)、完整报告链接,右侧是刷新按钮;IP 地址区块也有同样的刷新按钮,都是忽略缓存立即重查,查询进行中按钮变成系统的转圈。 +- 公网 IP 的归属地结果保存在本机:点开网络详情立刻显示上次的结果,后台只向 Cloudflare 核对一下地址;地址没变且不满 7 天就不再查归属地,换了 IP、超过 7 天或点了刷新才重新查。 +- 网络详情的纯净度色带占满整行,分数放到色带上方,同一行右侧是 cleanip.io 字标,点了打开这个 IP 的完整报告。 +- 磁盘页与磁盘弹窗的“读取 / 写入”两组数值各占一半宽度,数字长短变化时位置不再左右挪动。 +- 磁盘“读写最多的应用”与网络详情“高占用进程”改为稳定的 5 条榜单:按最近十几秒的平均速率排序和画条,数字仍是当前速率;刚安静下来的应用会在榜上停留几秒再退出,不再随每秒的波动忽隐忽现、上下乱跳。 +- 磁盘页与磁盘弹窗的容量改为分段条:已用、可清除、可用三段按比例排在一条里,段内直接标名称与百分比,下方图例给出各自的容量;可清除是系统随时可以腾出的缓存,访达的“可用”把它算在内。 +- 磁盘页“读写最多的应用”与 CPU、内存详情“按应用汇总”的排行条不再画灰色底槽,只保留代表相对占比的蓝色条。 - 「设置 · 菜单栏」的显示项目上方加了一条说明:按住 ⌘ 拖动可以调整菜单栏图标的顺序,新开启的项目由系统安排位置;各页面右上角的开关悬停时也有同样的提示。官网常见问题同步补充。 **Fixed** +- 网络详情的卡片被 IP 地址标题行撑宽、两侧几乎没有留白:标题、数据来源字标、IPv4 / IPv6 切换与刷新按钮一行放不下时字标自动缩小,切换胶囊也收窄了一点,卡片恢复与其他弹窗一致的边距。 +- 菜单栏弹窗的内容整体偏左、右边留白更宽:接了鼠标时系统默认常驻滚动条,滚动区域给它预留了一条宽度。现在应用内一律用浮层滚动条,内容占满整个弹窗宽度,主窗口页面同样处理。 +- 网络详情里的归属地国旗画错:中国、乌兹别克斯坦等 63 面旗子的 SVG 用了嵌套引用,系统渲染器画成一大块白。现在国旗改为预先渲染好的 PNG,全部按参考图核对过。 - 各监控页右上角的“在菜单栏显示”开关点不动:页面的滚动区域会自动向上延伸到顶栏底下,把开关的点击截走了;现在顶栏盖在滚动区域之上,GPU、磁盘等页面的开关可以正常点击。 **Added** @@ -67,7 +91,7 @@ Latest release **0.2.0** (2026-09-13) · **31** changes in development · [full
-2026-09-15 · Unreleased · 3 added · 4 changed +2026-09-15 · Unreleased · 3 added · 5 changed **Added** @@ -77,6 +101,7 @@ Latest release **0.2.0** (2026-09-13) · **31** changes in development · [full **Changed** +- 网络详情的 DNS 切换改为下拉菜单:“配置方式”一行直接是菜单,选 Cloudflare、Google 等预设立即切换,选“手动”展开输入框,自定义地址时旁边有铅笔按钮可再次修改;不再显示六个并排的按钮,省下两行。 - 卸载应用:去掉“同时从程序坞移除图标”开关,卸载时始终移除程序坞图标;应用列表的滚动条改为只在滚动时显示的细条(之前接了鼠标会显示一条粗的传统滚动条),升级说明里的滚动条同样修正。 - 主窗口的 CPU 走势图加上 0–100% 刻度与起点、中点、现在三个时间标签。 - CPU 详情的解释性文字不再常驻:热力图怎么看、每类核心是干什么的、平均负载怎么理解、按应用汇总为什么会超过 100%,都收进区块标题旁的 ⓘ,鼠标悬停 ⓘ 或卡片内容时才显示,界面上只留数据。 @@ -147,6 +172,7 @@ Choose which sections each popover shows in Settings. `Esc` closes it. and one-click switching to Cloudflare, Google, Tencent, Alibaba Cloud or manual servers; per-process traffic. - **GPU**, **Temperature & fans.** History, sensor groups, fan speeds and quick modes. +- **Battery.** Charge level, time remaining, adapter wattage and battery temperature; a 24-hour charge curve; power draw; health and cycle count; the batteries of connected Bluetooth devices (AirPods, Magic Keyboard / Mouse / Trackpad). Macs without a battery show the Bluetooth devices only.

CPU popover @@ -154,7 +180,7 @@ Choose which sections each popover shows in Settings. `Esc` closes it. Memory popover, dark

-**Main window** — a sidebar with Dashboard, CPU, GPU, Memory, Network, Temperature & fans, +**Main window** — a sidebar with Dashboard, CPU, GPU, Memory, Network, Temperature & fans, Battery, Processes, Keep awake and Clean; resizable in both directions. See [Cleanup](#cleanup). **Ask Apple Intelligence about a process** — right-click a process you don't recognise and the on-device diff --git a/README.zh-CN.md b/README.zh-CN.md index b8060ba..a0bf1b9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,17 +46,41 @@ brew install --cask gentpan/tap/openstats -最新版本 **0.2.0**(2026-09-13) · 开发中 **31** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +最新版本 **0.2.0**(2026-09-13) · 开发中 **50** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
-2026-09-15 · 未发布 · 调整 1 · 修复 1 · 新增 1 +2026-09-15 · 未发布 · 新增 3 · 新增 2 · 调整 11 · 修复 4 · 新增 1 + +**新增** + +- 菜单栏新增“电池”项目:电量按九种风格显示(电量条、圆环、饼图、数字等),充电中带闪电,电量低于 20% 按“高负载时着色”变红。点击弹出电池详情:电量与剩余 / 充满时间、适配器功率、电池温度、最近 24 小时电量曲线、功耗、健康度与循环次数、已连接蓝牙设备的电量(AirPods 左右耳与充电盒、妙控键盘 / 鼠标 / 触控板),可跳到系统电池设置;主窗口也有对应的“电池”页,历史页多了电池电量曲线。没有电池的 Mac(mini、Studio、iMac)上这个项目改为显示电量最低的蓝牙设备,弹窗只列蓝牙设备。「设置 · 菜单栏」新增“蓝牙设备电量低时提示”:某个设备低于 20% 时在电池项目旁显示它的图标与电量。 +- 网络详情“接口”区块右上角新增重置按钮:把开机后下载 / 上传归零、从现在起重新累计,标题旁标出起算时间,两行改叫“重置后下载 / 上传”;重启后自动回到开机后累计,右键按钮可随时改回。 +- 磁盘页从展示页变成能动手的磁盘工具,新增四张卡片:- 空间占用:统计家目录里每个文件夹占多少,并列出最大的 20 个文件(应用、照片图库等按整体算一个);可在访达中显示,家目录里、Library 之外的文件可以直接移到废纸篓。只读扫描,文件多时需要几十秒,可随时停止。- 文件系统检查:调用系统的 diskutil 对启动盘做一次只读检查,相当于“磁盘工具”的急救但不修改任何东西;记住上次检查的时间与结果,发现问题时提示去磁盘工具修复。- 本地快照:列出 Time Machine 留在本机的快照(它们计入“可清除”空间),可一键全部删除;辅助工具够新时由它执行,否则请求一次管理员授权。辅助工具协议升到第 4 版,已安装的旧版会提示重新安装。- 其他磁盘:列出外置硬盘、U 盘、镜像与网络共享的容量,可在访达中显示或直接推出;接入、拔出时自动刷新。 + +**新增** + +- 网络详情新增「IP 纯净度」区块:CleanIP.io 的纯净度评分与等级画成 F 到 A+ 的六段色带,得分处有标记;下面列出风险评分、命中的风险标记(VPN、代理、Tor、机房、滥用记录等)与一句评价,可打开完整报告。IP 地址区块也多了反向解析、网络类型与接入方式、IP 类型(住宅 / 机房)、原生 / 广播、住宅概率。 +- 「设置 · 网络」新增归属地数据源选择:CleanIP.io(默认,信息最全、中文地名)、ipapi.is、DB-IP、ipinfo.io 或本地 GeoLite2 数据库;每一项都注明会把公网 IP 发给谁。在线数据源都由每台 Mac 自己直接查、不经过我们的服务器,同一个公网 IP 的结果会记住一段时间,收到限流后当天不再请求;切换数据源后立即重新查询。 **调整** +- 「设置 · 菜单栏」每个显示项目下面多了一行“菜单栏风格”:给这一项单独选一种风格(默认跟随整体,菜单里标出整体现在是哪种),旁边是这一项按当前风格、实时读数画出的预览,改了立刻能看到。以前这个选择藏在开关旁一个没有说明的下拉里,不容易发现。温度、风扇只提供文字类的三种风格;原“风格”分组改名“整体风格”并加了说明。 +- 菜单栏弹窗右上角的两个按钮:左边改为该指标自己的图标(网络是网络图标、磁盘是硬盘),点了直接进主窗口里这个指标的页面;右边的齿轮进总设置。 +- 网络详情的 IP 地址区块精简:原生 / 广播、住宅 / 机房、运营商类型(ISP 等)改为 cleanip.io 样式的徽章,去掉反向解析、网络类型、住宅概率、数据来源四行;数据来源改为标题后的小标记,CleanIP.io 显示它的字标:与标题文字同高、不留上下空白,点了打开 cleanip.io。徽章样式作为通用组件收进设计系统。 +- 公网 IP 双栈支持:IPv4 与 IPv6 各自查询归属地与纯净度、各自缓存;两族都有时 IP 地址区块用胶囊开关切换 IPv4 / IPv6。纯净度区块的标题一行排开:地址族徽章(只有一族时不标)、置信度徽章(高绿、中灰、低黄)、完整报告链接,右侧是刷新按钮;IP 地址区块也有同样的刷新按钮,都是忽略缓存立即重查,查询进行中按钮变成系统的转圈。 +- 公网 IP 的归属地结果保存在本机:点开网络详情立刻显示上次的结果,后台只向 Cloudflare 核对一下地址;地址没变且不满 7 天就不再查归属地,换了 IP、超过 7 天或点了刷新才重新查。 +- 网络详情的纯净度色带占满整行,分数放到色带上方,同一行右侧是 cleanip.io 字标,点了打开这个 IP 的完整报告。 +- 磁盘页与磁盘弹窗的“读取 / 写入”两组数值各占一半宽度,数字长短变化时位置不再左右挪动。 +- 磁盘“读写最多的应用”与网络详情“高占用进程”改为稳定的 5 条榜单:按最近十几秒的平均速率排序和画条,数字仍是当前速率;刚安静下来的应用会在榜上停留几秒再退出,不再随每秒的波动忽隐忽现、上下乱跳。 +- 磁盘页与磁盘弹窗的容量改为分段条:已用、可清除、可用三段按比例排在一条里,段内直接标名称与百分比,下方图例给出各自的容量;可清除是系统随时可以腾出的缓存,访达的“可用”把它算在内。 +- 磁盘页“读写最多的应用”与 CPU、内存详情“按应用汇总”的排行条不再画灰色底槽,只保留代表相对占比的蓝色条。 - 「设置 · 菜单栏」的显示项目上方加了一条说明:按住 ⌘ 拖动可以调整菜单栏图标的顺序,新开启的项目由系统安排位置;各页面右上角的开关悬停时也有同样的提示。官网常见问题同步补充。 **修复** +- 网络详情的卡片被 IP 地址标题行撑宽、两侧几乎没有留白:标题、数据来源字标、IPv4 / IPv6 切换与刷新按钮一行放不下时字标自动缩小,切换胶囊也收窄了一点,卡片恢复与其他弹窗一致的边距。 +- 菜单栏弹窗的内容整体偏左、右边留白更宽:接了鼠标时系统默认常驻滚动条,滚动区域给它预留了一条宽度。现在应用内一律用浮层滚动条,内容占满整个弹窗宽度,主窗口页面同样处理。 +- 网络详情里的归属地国旗画错:中国、乌兹别克斯坦等 63 面旗子的 SVG 用了嵌套引用,系统渲染器画成一大块白。现在国旗改为预先渲染好的 PNG,全部按参考图核对过。 - 各监控页右上角的“在菜单栏显示”开关点不动:页面的滚动区域会自动向上延伸到顶栏底下,把开关的点击截走了;现在顶栏盖在滚动区域之上,GPU、磁盘等页面的开关可以正常点击。 **新增** @@ -66,7 +90,7 @@ brew install --cask gentpan/tap/openstats
-2026-09-15 · 未发布 · 新增 3 · 调整 4 +2026-09-15 · 未发布 · 新增 3 · 调整 5 **新增** @@ -76,6 +100,7 @@ brew install --cask gentpan/tap/openstats **调整** +- 网络详情的 DNS 切换改为下拉菜单:“配置方式”一行直接是菜单,选 Cloudflare、Google 等预设立即切换,选“手动”展开输入框,自定义地址时旁边有铅笔按钮可再次修改;不再显示六个并排的按钮,省下两行。 - 卸载应用:去掉“同时从程序坞移除图标”开关,卸载时始终移除程序坞图标;应用列表的滚动条改为只在滚动时显示的细条(之前接了鼠标会显示一条粗的传统滚动条),升级说明里的滚动条同样修正。 - 主窗口的 CPU 走势图加上 0–100% 刻度与起点、中点、现在三个时间标签。 - CPU 详情的解释性文字不再常驻:热力图怎么看、每类核心是干什么的、平均负载怎么理解、按应用汇总为什么会超过 100%,都收进区块标题旁的 ⓘ,鼠标悬停 ⓘ 或卡片内容时才显示,界面上只留数据。 @@ -139,6 +164,7 @@ brew install --cask gentpan/tap/openstats - **网络**:流量历史;连接探测格子(延迟、抖动、丢包);接口、Wi-Fi 信号、VPN / 代理;本地与公网 IPv4 / IPv6, 归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。 - **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。 +- **电池**:电量与剩余 / 充满时间、适配器功率与电池温度;最近 24 小时电量曲线;功耗;健康度与循环次数;已连接蓝牙设备的电量(AirPods、妙控键盘 / 鼠标 / 触控板)。没有电池的 Mac 只显示蓝牙设备。

CPU 详情弹窗 @@ -146,7 +172,7 @@ brew install --cask gentpan/tap/openstats 内存详情弹窗(深色)

-**主窗口**——左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理,宽高都可调整。 +**主窗口**——左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、电池、进程、防休眠、清理,宽高都可调整。 仪表盘有健康评分、芯片与系统徽章、三列指标卡片、核心负载、电池、高占用进程和快捷开关;清理见[清理](#清理)。 **用 Apple 智能解释进程**——看不懂的进程右键「用 Apple 智能解释」,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出。 diff --git a/project.yml b/project.yml index 3561b4d..94104e6 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: SWIFT_VERSION: "6.0" MARKETING_VERSION: "0.2.0" # 构建号四位数字,每次 make build 加一;营销版本只在发布时用 Scripts/version.sh patch / minor 修改 - CURRENT_PROJECT_VERSION: "0045" + CURRENT_PROJECT_VERSION: "0048" MACOSX_DEPLOYMENT_TARGET: "14.0" # 本地开发使用 ad-hoc 签名;发布时改为 Developer ID(见 docs/PLAN.md) CODE_SIGN_STYLE: Manual