蓝牙设备电量与低电量通知
This commit is contained in:
1 parent
9e236670e5
commit
4f46446494
5 files changed
+230
-5
No files matched your search
@@ -18,6 +18,7 @@
|
||||
- 卸载应用(主窗口工具分组):列出“应用程序”里的第三方应用及其体积,选中或把应用拖进来后找出它留下的应用数据、缓存、偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项,可逐项取消勾选;确认后连同应用一起移到废纸篓(可放回),并可同时从程序坞移除图标。只匹配应用包名与同名目录,系统自带和 Apple 的应用不列出,正在运行时提示先退出。
|
||||
- 启动项(主窗口工具分组):列出当前用户、所有用户的 LaunchAgents 和系统 LaunchDaemons,显示所属应用、可执行文件、是否登录时运行 / 保持运行,以及运行中(PID)、已加载、已停用状态;当前用户的启动项可以直接停用或重新启用(写入 launchd 停用记录并卸载,不删除文件),其余只读并提供登录项设置入口。
|
||||
- 历史(主窗口监控分组):每分钟把 CPU 平均与峰值、内存与内存压力、网络上下行、GPU、CPU 最高温度和整机功耗写入本机 SQLite 数据库,保留 7 天;可回看最近 1 小时 / 24 小时 / 7 天,睡眠等没有记录的时段线条断开,内存压力严重的时段标红,鼠标移到图上查看那一刻的数值;可关闭记录或清除历史,数据不上传。
|
||||
- 蓝牙设备电量:本机信息页列出已连接的键盘、鼠标、触控板和耳机的电量(AirPods 分左耳、右耳、充电盒),低于 20% 标红;通知里新增“蓝牙设备电量低”(低于 15%)。
|
||||
|
||||
#### 调整
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
|
||||
/// 已连接蓝牙设备的电量
|
||||
public struct BluetoothDevice: Sendable, Equatable, Identifiable {
|
||||
public enum Kind: String, Sendable {
|
||||
case keyboard, mouse, trackpad, headphones, phone, other
|
||||
}
|
||||
|
||||
public var id: String { address.isEmpty ? name : address }
|
||||
public var name: String
|
||||
public var address: String
|
||||
public var kind: Kind
|
||||
/// 电量(0...100);耳机分左耳、右耳、充电盒
|
||||
public var batteries: [(label: String, percent: Int)]
|
||||
|
||||
public static func == (lhs: BluetoothDevice, rhs: BluetoothDevice) -> Bool {
|
||||
lhs.id == rhs.id && lhs.name == rhs.name && lhs.kind == rhs.kind
|
||||
&& lhs.batteries.map(\.label) == rhs.batteries.map(\.label) && lhs.batteries.map(\.percent) == rhs.batteries.map(\.percent)
|
||||
}
|
||||
}
|
||||
|
||||
/// 电量来自两处:system_profiler 的蓝牙信息(AirPods 等耳机的左右耳与充电盒)和 IOKit 里 Apple 键盘、鼠标、触控板的 BatteryPercent
|
||||
public enum BluetoothBatteryReader {
|
||||
public static func read() -> [BluetoothDevice] {
|
||||
var devices = parseSystemProfiler(runSystemProfiler())
|
||||
for (product, percent) in hidBatteries() {
|
||||
if let index = devices.firstIndex(where: { $0.name == product }) {
|
||||
if devices[index].batteries.isEmpty { devices[index].batteries = [("电量", percent)] }
|
||||
} else {
|
||||
devices.append(BluetoothDevice(name: product, address: "", kind: kind(forName: product, minorType: nil), batteries: [("电量", percent)]))
|
||||
}
|
||||
}
|
||||
return devices.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// `system_profiler SPBluetoothDataType -json` 里 device_connected 分组的设备
|
||||
static func parseSystemProfiler(_ data: Data) -> [BluetoothDevice] {
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let sections = root["SPBluetoothDataType"] as? [[String: Any]] else { return [] }
|
||||
var devices: [BluetoothDevice] = []
|
||||
for section in sections {
|
||||
for entry in section["device_connected"] as? [[String: Any]] ?? [] {
|
||||
for (name, value) in entry {
|
||||
guard let info = value as? [String: Any] else { continue }
|
||||
let labels: [(String, String)] = [("device_batteryLevelMain", "电量"), ("device_batteryLevel", "电量"),
|
||||
("device_batteryLevelLeft", "左耳"), ("device_batteryLevelRight", "右耳"),
|
||||
("device_batteryLevelCase", "充电盒")]
|
||||
var batteries: [(String, Int)] = []
|
||||
for (key, label) in labels {
|
||||
if let percent = percentValue(info[key]), !batteries.contains(where: { $0.0 == label }) {
|
||||
batteries.append((label, percent))
|
||||
}
|
||||
}
|
||||
devices.append(BluetoothDevice(name: name, address: info["device_address"] as? String ?? "",
|
||||
kind: kind(forName: name, minorType: info["device_minorType"] as? String),
|
||||
batteries: batteries))
|
||||
}
|
||||
}
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
static func percentValue(_ value: Any?) -> Int? {
|
||||
if let number = value as? Int { return (0...100).contains(number) ? number : nil }
|
||||
guard let text = value as? String, let number = Int(text.trimmingCharacters(in: CharacterSet(charactersIn: "% "))) else { return nil }
|
||||
return (0...100).contains(number) ? number : nil
|
||||
}
|
||||
|
||||
static func kind(forName name: String, minorType: String?) -> BluetoothDevice.Kind {
|
||||
let text = ((minorType ?? "") + " " + name).lowercased()
|
||||
if text.contains("keyboard") { return .keyboard }
|
||||
if text.contains("trackpad") { return .trackpad }
|
||||
if text.contains("mouse") { return .mouse }
|
||||
if text.contains("headphone") || text.contains("headset") || text.contains("airpods") || text.contains("beats") { return .headphones }
|
||||
if text.contains("phone") { return .phone }
|
||||
return .other
|
||||
}
|
||||
|
||||
private static func runSystemProfiler() -> Data {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler")
|
||||
process.arguments = ["SPBluetoothDataType", "-json", "-detailLevel", "basic"]
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
do { try process.run() } catch { return Data() }
|
||||
// 最多等 10 秒,蓝牙服务卡住时不拖住采样
|
||||
let deadline = DispatchTime.now() + 10
|
||||
DispatchQueue.global().asyncAfter(deadline: deadline) { if process.isRunning { process.terminate() } }
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return data
|
||||
}
|
||||
|
||||
private static func hidBatteries() -> [(String, Int)] {
|
||||
var iterator: io_iterator_t = 0
|
||||
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("AppleDeviceManagementHIDEventService"), &iterator) == KERN_SUCCESS else {
|
||||
return []
|
||||
}
|
||||
defer { IOObjectRelease(iterator) }
|
||||
var results: [(String, Int)] = []
|
||||
while case let service = IOIteratorNext(iterator), service != 0 {
|
||||
defer { IOObjectRelease(service) }
|
||||
guard let percent = IORegistryEntryCreateCFProperty(service, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?
|
||||
.takeRetainedValue() as? Int,
|
||||
let product = IORegistryEntryCreateCFProperty(service, "Product" as CFString, kCFAllocatorDefault, 0)?
|
||||
.takeRetainedValue() as? String,
|
||||
(0...100).contains(percent), !results.contains(where: { $0.0 == product }) else { continue }
|
||||
results.append((product, percent))
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ struct SystemInfoPage: View {
|
||||
}
|
||||
}
|
||||
|
||||
BluetoothCard()
|
||||
|
||||
InfoCard(icon: "display.2", title: "显示器") {
|
||||
let displays = DisplayInfo.all()
|
||||
if displays.isEmpty {
|
||||
@@ -218,3 +220,68 @@ struct DisplayInfo {
|
||||
return (best.pixelWidth, best.pixelHeight)
|
||||
}
|
||||
}
|
||||
|
||||
/// 已连接蓝牙设备的电量,页面打开期间每分钟刷新
|
||||
private struct BluetoothCard: View {
|
||||
@State private var devices: [BluetoothDevice]?
|
||||
|
||||
var body: some View {
|
||||
InfoCard(icon: "dot.radiowaves.left.and.right", title: "蓝牙设备") {
|
||||
if let devices {
|
||||
if devices.isEmpty {
|
||||
Text("没有已连接的蓝牙设备").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("不提供电量").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("正在读取…").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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import UserNotifications
|
||||
|
||||
/// 可以发系统通知的状况
|
||||
public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
case cpuTemperature, memoryPressure, diskSpace, networkDown, batteryHealth
|
||||
case cpuTemperature, memoryPressure, diskSpace, networkDown, batteryHealth, bluetoothBattery
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
@@ -18,6 +18,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
case .diskSpace: "磁盘空间不足"
|
||||
case .networkDown: "网络断开"
|
||||
case .batteryHealth: "电池健康度下降"
|
||||
case .bluetoothBattery: "蓝牙设备电量低"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
case .diskSpace: "启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次"
|
||||
case .networkDown: "网络连接中断超过 20 秒,恢复后再提示一次"
|
||||
case .batteryHealth: "电池最大容量低于 80%,每 30 天最多提醒一次"
|
||||
case .bluetoothBattery: "已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
case .diskSpace: "internaldrive"
|
||||
case .networkDown: "wifi.slash"
|
||||
case .batteryHealth: "battery.25percent"
|
||||
case .bluetoothBattery: "dot.radiowaves.left.and.right"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +51,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
case .memoryPressure: .memory
|
||||
case .diskSpace: .disk
|
||||
case .networkDown: .network
|
||||
case .batteryHealth: .system
|
||||
case .batteryHealth, .bluetoothBattery: .system
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +61,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
case .cpuTemperature: 60
|
||||
case .memoryPressure: 30
|
||||
case .networkDown: 20
|
||||
case .diskSpace, .batteryHealth: 0
|
||||
case .diskSpace, .batteryHealth, .bluetoothBattery: 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +69,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
|
||||
var cooldown: TimeInterval {
|
||||
switch self {
|
||||
case .cpuTemperature, .memoryPressure, .networkDown: 30 * 60
|
||||
case .diskSpace: 6 * 60 * 60
|
||||
case .diskSpace, .bluetoothBattery: 6 * 60 * 60
|
||||
case .batteryHealth: 30 * 24 * 60 * 60
|
||||
}
|
||||
}
|
||||
@@ -113,6 +116,8 @@ public final class AlertController: NSObject {
|
||||
@ObservationIgnored private var networkNotified = false
|
||||
@ObservationIgnored private var networkDownTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var periodicTask: Task<Void, Never>?
|
||||
/// 每个蓝牙设备上次提醒的时间
|
||||
@ObservationIgnored private var bluetoothNotified: [String: Date] = [:]
|
||||
|
||||
/// 单元测试与截图进程没有应用包标识,不能使用通知中心
|
||||
private var center: UNUserNotificationCenter? {
|
||||
@@ -145,11 +150,12 @@ public final class AlertController: NSObject {
|
||||
networkNotified = false
|
||||
}
|
||||
|
||||
if enabled.contains(.diskSpace) || enabled.contains(.batteryHealth) {
|
||||
if enabled.contains(.diskSpace) || enabled.contains(.batteryHealth) || enabled.contains(.bluetoothBattery) {
|
||||
if periodicTask == nil {
|
||||
periodicTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
self?.checkPeriodic()
|
||||
await self?.checkBluetooth()
|
||||
try? await Task.sleep(for: .seconds(10 * 60), tolerance: .seconds(60))
|
||||
}
|
||||
}
|
||||
@@ -195,6 +201,21 @@ public final class AlertController: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func checkBluetooth(now: Date = Date()) async {
|
||||
guard settings.enabledAlerts.contains(.bluetoothBattery) else { return }
|
||||
let devices = await Task.detached { BluetoothBatteryReader.read() }.value
|
||||
for device in devices {
|
||||
guard let lowest = device.batteries.min(by: { $0.percent < $1.percent }), lowest.percent <= 15 else {
|
||||
bluetoothNotified[device.id] = nil
|
||||
continue
|
||||
}
|
||||
if let last = bluetoothNotified[device.id], now.timeIntervalSince(last) < AlertKind.bluetoothBattery.cooldown { continue }
|
||||
bluetoothNotified[device.id] = now
|
||||
let part = device.batteries.count > 1 ? "\(lowest.label)" : ""
|
||||
send(.bluetoothBattery, title: "\(device.name)电量低", body: "\(device.name)\(part)只剩 \(lowest.percent)%,记得充电。")
|
||||
}
|
||||
}
|
||||
|
||||
private func fireIfNeeded(_ kind: AlertKind, isActive: Bool, now: Date, body: @autoclosure () -> String) {
|
||||
var tracker = trackers[kind] ?? AlertTracker()
|
||||
let fire = tracker.update(isActive: isActive, now: now, sustain: kind.sustain, cooldown: kind.cooldown)
|
||||
|
||||
@@ -45,3 +45,25 @@ import Testing
|
||||
#expect(await database.count() == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct BluetoothBatteryTests {
|
||||
@Test func parsesConnectedDevices() {
|
||||
let json = """
|
||||
{"SPBluetoothDataType":[{"controller_properties":{},
|
||||
"device_connected":[
|
||||
{"AirPods Pro":{"device_address":"AA:BB","device_minorType":"Headphones","device_batteryLevelLeft":"80%","device_batteryLevelRight":"75 %","device_batteryLevelCase":"40%"}},
|
||||
{"Magic Keyboard":{"device_address":"CC:DD","device_minorType":"Keyboard","device_batteryLevelMain":"55%"}}
|
||||
],
|
||||
"device_not_connected":[{"Old Mouse":{"device_address":"EE:FF","device_minorType":"Mouse"}}]}]}
|
||||
"""
|
||||
let devices = BluetoothBatteryReader.parseSystemProfiler(Data(json.utf8))
|
||||
#expect(devices.count == 2)
|
||||
let airpods = devices.first { $0.name == "AirPods Pro" }
|
||||
#expect(airpods?.kind == .headphones)
|
||||
#expect(airpods?.batteries.map(\.percent) == [80, 75, 40])
|
||||
#expect(airpods?.batteries.map(\.label) == ["左耳", "右耳", "充电盒"])
|
||||
#expect(devices.first { $0.name == "Magic Keyboard" }?.batteries.first?.percent == 55)
|
||||
#expect(BluetoothBatteryReader.percentValue("120%") == nil)
|
||||
#expect(BluetoothBatteryReader.parseSystemProfiler(Data("oops".utf8)).isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user