422 lines
12 KiB
Swift
422 lines
12 KiB
Swift
import Foundation
|
|
import MacToolsPluginKit
|
|
|
|
enum DeviceBatteryLayoutMode: String, CaseIterable, Equatable {
|
|
case grid
|
|
case list
|
|
|
|
func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
|
switch self {
|
|
case .grid:
|
|
return localization.string("layout.grid.title", defaultValue: "列表")
|
|
case .list:
|
|
return localization.string("layout.list.title", defaultValue: "圆环")
|
|
}
|
|
}
|
|
|
|
func subtitle(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
|
switch self {
|
|
case .grid:
|
|
return localization.string("layout.grid.subtitle", defaultValue: "按设备逐行显示")
|
|
case .list:
|
|
return localization.string("layout.list.subtitle", defaultValue: "多设备圆环")
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
enum DeviceBatteryChargeState: Equatable, Sendable {
|
|
case unknown
|
|
case normal
|
|
case charging
|
|
case charged
|
|
case plugged
|
|
case invalid
|
|
|
|
func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
|
switch self {
|
|
case .unknown:
|
|
return localization.string("chargeState.unknown", defaultValue: "未知")
|
|
case .normal:
|
|
return localization.string("chargeState.normal", defaultValue: "正常")
|
|
case .charging:
|
|
return localization.string("chargeState.charging", defaultValue: "充电中")
|
|
case .charged:
|
|
return localization.string("chargeState.charged", defaultValue: "已充满")
|
|
case .plugged:
|
|
return localization.string("chargeState.plugged", defaultValue: "外接电源")
|
|
case .invalid:
|
|
return localization.string("chargeState.invalid", defaultValue: "电量无效")
|
|
}
|
|
}
|
|
|
|
var isActiveChargingState: Bool {
|
|
switch self {
|
|
case .charging, .charged, .plugged:
|
|
return true
|
|
case .unknown, .normal, .invalid:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
enum DeviceBatteryKind: Equatable, Sendable {
|
|
case internalBattery
|
|
case phone
|
|
case tablet
|
|
case mediaPlayer
|
|
case watch
|
|
case spatialComputer
|
|
case bluetooth
|
|
case magicAccessory
|
|
case rapooMouse
|
|
case airPodsPart
|
|
case other
|
|
|
|
var iconName: String {
|
|
switch self {
|
|
case .internalBattery:
|
|
return "laptopcomputer"
|
|
case .phone:
|
|
return "iphone"
|
|
case .tablet:
|
|
return "ipad"
|
|
case .mediaPlayer:
|
|
return "ipodtouch"
|
|
case .watch:
|
|
return "applewatch"
|
|
case .spatialComputer:
|
|
return "visionpro"
|
|
case .bluetooth:
|
|
return "dot.radiowaves.left.and.right"
|
|
case .magicAccessory:
|
|
return "keyboard"
|
|
case .rapooMouse:
|
|
return "computermouse.fill"
|
|
case .airPodsPart:
|
|
return "airpodspro"
|
|
case .other:
|
|
return "battery.75percent"
|
|
}
|
|
}
|
|
|
|
func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
|
switch self {
|
|
case .internalBattery:
|
|
return "Mac"
|
|
case .phone:
|
|
return "iPhone"
|
|
case .tablet:
|
|
return "iPad"
|
|
case .mediaPlayer:
|
|
return "iPod touch"
|
|
case .watch:
|
|
return "Apple Watch"
|
|
case .spatialComputer:
|
|
return "Apple Vision Pro"
|
|
case .bluetooth:
|
|
return localization.string("deviceKind.bluetooth", defaultValue: "蓝牙")
|
|
case .magicAccessory:
|
|
return localization.string("deviceKind.magicAccessory", defaultValue: "Apple 外设")
|
|
case .rapooMouse:
|
|
return localization.string("deviceKind.rapooMouse", defaultValue: "雷柏鼠标")
|
|
case .airPodsPart:
|
|
return localization.string("deviceKind.airPodsPart", defaultValue: "耳机")
|
|
case .other:
|
|
return localization.string("deviceKind.other", defaultValue: "设备")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum DeviceBatteryComponentRole: String, Equatable, Sendable {
|
|
case aggregate
|
|
case left
|
|
case right
|
|
case chargingCase
|
|
|
|
var isPart: Bool {
|
|
self != .aggregate
|
|
}
|
|
}
|
|
|
|
enum DeviceBatteryLowBatteryThresholds {
|
|
static let defaultValue = 20
|
|
static let minimum = 1
|
|
static let maximum = 99
|
|
|
|
static func normalized(_ threshold: Int) -> Int {
|
|
min(max(threshold, minimum), maximum)
|
|
}
|
|
}
|
|
|
|
struct DeviceBatteryComponentIdentity: Equatable, Sendable {
|
|
let groupID: String
|
|
let role: DeviceBatteryComponentRole
|
|
}
|
|
|
|
struct DeviceBatteryItem: Identifiable, Equatable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
let model: String?
|
|
let kind: DeviceBatteryKind
|
|
let level: Int?
|
|
let chargeState: DeviceBatteryChargeState
|
|
let parentName: String?
|
|
let source: String
|
|
let lastUpdated: Date?
|
|
let isConnected: Bool
|
|
let detail: String?
|
|
let componentIdentity: DeviceBatteryComponentIdentity?
|
|
|
|
init(
|
|
id: String,
|
|
name: String,
|
|
model: String?,
|
|
kind: DeviceBatteryKind,
|
|
level: Int?,
|
|
chargeState: DeviceBatteryChargeState,
|
|
parentName: String?,
|
|
source: String,
|
|
lastUpdated: Date?,
|
|
isConnected: Bool,
|
|
detail: String?,
|
|
componentIdentity: DeviceBatteryComponentIdentity? = nil
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.model = model
|
|
self.kind = kind
|
|
self.level = level
|
|
self.chargeState = chargeState
|
|
self.parentName = parentName
|
|
self.source = source
|
|
self.lastUpdated = lastUpdated
|
|
self.isConnected = isConnected
|
|
self.detail = detail
|
|
self.componentIdentity = componentIdentity
|
|
}
|
|
|
|
var clampedLevel: Int? {
|
|
guard let level else {
|
|
return nil
|
|
}
|
|
|
|
return min(max(level, 0), 100)
|
|
}
|
|
|
|
func isLowBattery(threshold: Int) -> Bool {
|
|
guard let level = clampedLevel else {
|
|
return false
|
|
}
|
|
|
|
return isConnected
|
|
&& level < threshold
|
|
&& chargeState != .charging
|
|
&& chargeState != .charged
|
|
&& chargeState != .plugged
|
|
}
|
|
|
|
var isDisplayLowBattery: Bool {
|
|
guard let level = clampedLevel else {
|
|
return false
|
|
}
|
|
|
|
return level <= DeviceBatteryLowBatteryThresholds.defaultValue
|
|
&& chargeState != .charging
|
|
&& chargeState != .charged
|
|
}
|
|
}
|
|
|
|
enum DeviceBatteryItemNormalizer {
|
|
static func removingRedundantComponentAggregates(
|
|
_ items: [DeviceBatteryItem]
|
|
) -> [DeviceBatteryItem] {
|
|
let componentGroups = Set(items.compactMap(componentGroupID))
|
|
guard !componentGroups.isEmpty else {
|
|
return items
|
|
}
|
|
|
|
return items.filter { item in
|
|
guard let aggregateGroupID = aggregateGroupID(item) else {
|
|
return true
|
|
}
|
|
|
|
return !componentGroups.contains(aggregateGroupID)
|
|
}
|
|
}
|
|
|
|
private static func aggregateGroupID(_ item: DeviceBatteryItem) -> String? {
|
|
guard let identity = item.componentIdentity,
|
|
identity.role == .aggregate,
|
|
item.clampedLevel != nil
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return identity.groupID
|
|
}
|
|
|
|
private static func componentGroupID(_ item: DeviceBatteryItem) -> String? {
|
|
guard let identity = item.componentIdentity,
|
|
identity.role.isPart,
|
|
item.clampedLevel != nil
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return identity.groupID
|
|
}
|
|
}
|
|
|
|
enum DeviceBatteryAccessState: Equatable, Sendable {
|
|
case idle
|
|
case scanning
|
|
case ready
|
|
case noDevices
|
|
case permissionDenied
|
|
case failed(String)
|
|
|
|
var isError: Bool {
|
|
switch self {
|
|
case .permissionDenied, .failed:
|
|
return true
|
|
case .idle, .scanning, .ready, .noDevices:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DeviceBatterySnapshot: Equatable, Sendable {
|
|
var accessState: DeviceBatteryAccessState
|
|
var items: [DeviceBatteryItem]
|
|
var lastUpdated: Date?
|
|
var rapooState: RapooBatteryAccessState
|
|
|
|
static let idle = DeviceBatterySnapshot(
|
|
accessState: .idle,
|
|
items: [],
|
|
lastUpdated: nil,
|
|
rapooState: .idle
|
|
)
|
|
|
|
var visibleItems: [DeviceBatteryItem] {
|
|
items.sorted(by: Self.sortItems)
|
|
}
|
|
|
|
var primaryItem: DeviceBatteryItem? {
|
|
visibleItems.first
|
|
}
|
|
|
|
var lowBatteryCount: Int {
|
|
visibleItems.filter(\.isDisplayLowBattery).count
|
|
}
|
|
|
|
func lowBatteryItems(threshold: Int) -> [DeviceBatteryItem] {
|
|
let normalizedThreshold = DeviceBatteryLowBatteryThresholds.normalized(threshold)
|
|
return visibleItems.filter { $0.isLowBattery(threshold: normalizedThreshold) }
|
|
}
|
|
|
|
func subtitle(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
|
switch accessState {
|
|
case .idle:
|
|
return localization.string("snapshot.subtitle.idle", defaultValue: "等待检测")
|
|
case .scanning:
|
|
return localization.string("snapshot.subtitle.scanning", defaultValue: "正在读取设备电量")
|
|
case .ready:
|
|
if lowBatteryCount > 0 {
|
|
return localization.format(
|
|
"snapshot.subtitle.readyWithLowBattery",
|
|
defaultValue: "%d 台设备,%d 台低电量",
|
|
visibleItems.count,
|
|
lowBatteryCount
|
|
)
|
|
}
|
|
return localization.format(
|
|
"snapshot.subtitle.ready",
|
|
defaultValue: "%d 台设备",
|
|
visibleItems.count
|
|
)
|
|
case .noDevices:
|
|
return localization.string("snapshot.subtitle.noDevices", defaultValue: "未检测到可显示电量")
|
|
case .permissionDenied:
|
|
return localization.string("snapshot.subtitle.permissionDenied", defaultValue: "需要输入监控权限")
|
|
case let .failed(message):
|
|
return message
|
|
}
|
|
}
|
|
|
|
func errorMessage(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String? {
|
|
switch accessState {
|
|
case .permissionDenied:
|
|
return localization.string(
|
|
"snapshot.error.permissionDenied",
|
|
defaultValue: "无法访问雷柏 HID 接口,请在系统设置中允许 MacTools 使用输入监控。"
|
|
)
|
|
case let .failed(message):
|
|
return message
|
|
case .idle, .scanning, .ready, .noDevices:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func sortItems(_ left: DeviceBatteryItem, _ right: DeviceBatteryItem) -> Bool {
|
|
let leftRank = itemRank(left)
|
|
let rightRank = itemRank(right)
|
|
if leftRank != rightRank {
|
|
return leftRank < rightRank
|
|
}
|
|
|
|
return left.name.localizedCompare(right.name) == .orderedAscending
|
|
}
|
|
|
|
private static func itemRank(_ item: DeviceBatteryItem) -> Int {
|
|
if item.isDisplayLowBattery {
|
|
return 0
|
|
}
|
|
|
|
switch item.kind {
|
|
case .internalBattery:
|
|
return 1
|
|
case .phone, .tablet, .mediaPlayer, .watch, .spatialComputer:
|
|
return 2
|
|
case .rapooMouse:
|
|
return 3
|
|
case .magicAccessory:
|
|
return 4
|
|
case .airPodsPart:
|
|
return 5
|
|
case .bluetooth:
|
|
return 6
|
|
case .other:
|
|
return 7
|
|
}
|
|
}
|
|
}
|
|
|
|
enum DeviceBatteryFormatter {
|
|
static func percent(_ level: Int?) -> String {
|
|
guard let level else {
|
|
return "--"
|
|
}
|
|
|
|
return "\(min(max(level, 0), 100))%"
|
|
}
|
|
|
|
static func time(
|
|
_ date: Date?,
|
|
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
|
) -> String {
|
|
guard let date else {
|
|
return localization.string("time.notUpdated", defaultValue: "未更新")
|
|
}
|
|
|
|
return timeFormatter.string(from: date)
|
|
}
|
|
|
|
private static let timeFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "HH:mm"
|
|
return formatter
|
|
}()
|
|
}
|