Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

359 lines
13 KiB
Swift

import AppKit
@preconcurrency import IOKit.hid
import SwiftUI
import MacToolsPluginKit
public final class DeviceBatteryPluginFactory: NSObject, MacToolsPluginBundleFactory {
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
DeviceBatteryPluginProvider(context: context)
}
}
@MainActor
private struct DeviceBatteryPluginProvider: PluginProvider {
let context: PluginRuntimeContext
func makePlugins() -> [any MacToolsPlugin] {
[DeviceBatteryPlugin(context: context, localization: PluginLocalization(bundle: context.resourceBundle))]
}
}
enum DeviceBatteryInputMonitoringAuthorizationStatus {
case granted
case denied
case unknown
}
@MainActor
final class DeviceBatteryPlugin: MacToolsPlugin, PluginComponentPanel, PluginPanelSurfaceLifecycleHandling {
private enum ControlID {
static let openInputMonitoring = "open-input-monitoring"
}
private enum PermissionID {
static let inputMonitoring = "input-monitoring"
}
let metadata: PluginMetadata
var descriptor: PluginComponentDescriptor {
PluginComponentDescriptor(span: componentSpan)
}
private let viewModel: DeviceBatteryViewModel
private let store: DeviceBatteryStore
private let localization: PluginLocalization
private let inputMonitoringAuthorizationStatus: () -> DeviceBatteryInputMonitoringAuthorizationStatus
private let lowBatteryNotificationController: DeviceBatteryLowBatteryNotificationController
private var componentSpan: PluginComponentSpan {
let visibleItemCount = viewModel.snapshot.visibleItems.count
return PluginComponentSpan(
width: DeviceBatteryComponentLayout.width,
height: DeviceBatteryComponentLayout.spanHeight(
mode: store.layoutMode,
visibleItemCount: visibleItemCount
)
)!
}
convenience init(
context: PluginRuntimeContext,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.init(
context: context,
viewModel: DeviceBatteryViewModel(
sampler: DeviceBatterySampler(localization: localization),
rapooMonitor: RapooHIDBatteryMonitor(localization: localization),
localization: localization
),
localization: localization
)
}
convenience init(
context: PluginRuntimeContext,
viewModel: DeviceBatteryViewModel,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.init(
context: context,
viewModel: viewModel,
localization: localization,
inputMonitoringAuthorizationStatus: Self.currentInputMonitoringAuthorizationStatus
)
}
init(
context: PluginRuntimeContext,
viewModel: DeviceBatteryViewModel,
localization: PluginLocalization = PluginLocalization(bundle: .main),
inputMonitoringAuthorizationStatus: @escaping () -> DeviceBatteryInputMonitoringAuthorizationStatus,
lowBatteryNotifier: any DeviceBatteryLowBatteryNotifying = DeviceBatteryUserNotificationCenterNotifier()
) {
self.viewModel = viewModel
self.store = DeviceBatteryStore(storage: context.storage)
self.localization = localization
self.inputMonitoringAuthorizationStatus = inputMonitoringAuthorizationStatus
self.lowBatteryNotificationController = DeviceBatteryLowBatteryNotificationController(notifier: lowBatteryNotifier)
self.metadata = PluginMetadata(
id: "device-battery",
title: localization.string("metadata.title", defaultValue: "设备电量"),
iconName: "battery.75percent",
iconTint: Color(nsColor: .systemGreen),
order: 20,
defaultDescription: localization.string(
"metadata.description",
defaultValue: "查看 Mac、Apple 移动设备、蓝牙外设和雷柏鼠标电量"
)
)
viewModel.onSnapshotChange = { [weak self] in
self?.handleSnapshotChange()
}
}
var onStateChange: (() -> Void)? {
didSet {
viewModel.onSnapshotChange = { [weak self] in
self?.handleSnapshotChange()
}
}
}
var requestPermissionGuidance: ((String) -> Void)?
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
var componentPanelState: PluginComponentState {
PluginComponentState(
subtitle: viewModel.snapshot.subtitle(localization: localization),
isActive: !viewModel.snapshot.visibleItems.isEmpty,
isEnabled: true,
isVisible: true,
errorMessage: viewModel.snapshot.errorMessage(localization: localization)
)
}
var permissionRequirements: [PluginPermissionRequirement] {
[
PluginPermissionRequirement(
id: PermissionID.inputMonitoring,
kind: .inputMonitoring,
title: localization.string("permission.inputMonitoring.title", defaultValue: "输入监控授权"),
description: localization.string(
"permission.inputMonitoring.description",
defaultValue: "用于读取已适配厂商 HID 鼠标的电量、充电状态、设备型号和名称。"
)
)
]
}
var settingsSections: [PluginSettingsSection] { [] }
var configuration: PluginConfiguration? {
PluginConfiguration(
description: localization.string(
"configuration.description",
defaultValue: "选择组件面板布局和显示内容。"
)
) { [store, viewModel, localization, lowBatteryNotificationController] _ in
DeviceBatterySettingsView(
store: store,
localization: localization,
onChange: {
viewModel.refresh(
includeInternalBattery: store.showInternalBattery,
includeBluetoothDevices: store.showBluetoothDevices,
includeAppleMobileDevices: store.showAppleMobileDevices,
includeRapooDevices: store.showRapooDevices
)
},
onNotificationSettingsChange: {
lowBatteryNotificationController.evaluate(
snapshot: viewModel.snapshot,
isEnabled: store.lowBatteryNotificationEnabled,
threshold: store.lowBatteryNotificationThreshold,
localization: localization
)
}
)
}
}
func activate(context: PluginRuntimeContext) {
viewModel.start(
includeInternalBattery: store.showInternalBattery,
includeBluetoothDevices: store.showBluetoothDevices,
includeAppleMobileDevices: store.showAppleMobileDevices,
includeRapooDevices: store.showRapooDevices
)
onStateChange?()
}
func deactivate(reason: PluginDeactivationReason) {
viewModel.stop()
onStateChange?()
}
func refresh() {
viewModel.refresh(
includeInternalBattery: store.showInternalBattery,
includeBluetoothDevices: store.showBluetoothDevices,
includeAppleMobileDevices: store.showAppleMobileDevices,
includeRapooDevices: store.showRapooDevices
)
onStateChange?()
}
func panelSurfaceDidBecomeVisible(_ surface: PluginPanelSurface) {
guard surface == .component else {
return
}
viewModel.setComponentPanelVisible(true)
}
func panelSurfaceDidBecomeHidden(_ surface: PluginPanelSurface) {
guard surface == .component else {
return
}
viewModel.setComponentPanelVisible(false)
}
func makeView(context: PluginComponentContext) -> AnyView {
AnyView(
DeviceBatteryComponentView(
viewModel: viewModel,
store: store,
localization: localization,
openSettings: openInputMonitoringSettings
)
)
}
func permissionState(for permissionID: String) -> PluginPermissionState {
switch permissionID {
case PermissionID.inputMonitoring:
return inputMonitoringPermissionState
default:
return PluginPermissionState(isGranted: true, footnote: nil)
}
}
func handlePermissionAction(id: String) {
guard id == PermissionID.inputMonitoring else {
return
}
openInputMonitoringSettings()
}
func handleSettingsAction(id: String) {
if id == ControlID.openInputMonitoring {
openInputMonitoringSettings()
}
}
func handleShortcutAction(id: String) {}
private func handleSnapshotChange() {
lowBatteryNotificationController.evaluate(
snapshot: viewModel.snapshot,
isEnabled: store.lowBatteryNotificationEnabled,
threshold: store.lowBatteryNotificationThreshold,
localization: localization
)
onStateChange?()
}
private var inputMonitoringPermissionState: PluginPermissionState {
let authorizationStatus = inputMonitoringAuthorizationStatus()
if viewModel.snapshot.rapooState == .permissionDenied,
authorizationStatus != .granted {
return PluginPermissionState(
isGranted: false,
footnote: localization.string(
"permission.inputMonitoring.openSettingsFootnote",
defaultValue: "前往系统设置 → 隐私与安全性 → 输入监控,允许 MacTools。"
)
)
}
switch authorizationStatus {
case .granted:
return PluginPermissionState(
isGranted: true,
footnote: inputMonitoringGrantedFootnote
)
case .denied, .unknown:
return PluginPermissionState(
isGranted: false,
footnote: inputMonitoringUnauthorizedFootnote
)
}
}
private var inputMonitoringGrantedFootnote: String {
switch viewModel.snapshot.rapooState {
case .connected:
return localization.string(
"permission.inputMonitoring.granted.connected",
defaultValue: "已允许 MacTools 使用输入监控,并已读取到厂商 HID 鼠标信息。"
)
case .failed(let message):
return localization.format(
"permission.inputMonitoring.granted.failed",
defaultValue: "已允许 MacTools 使用输入监控。最近读取厂商 HID 鼠标失败:%@",
message
)
case .idle, .scanning, .waitingForReport, .noDevice, .permissionDenied:
return localization.string(
"permission.inputMonitoring.granted.default",
defaultValue: "已允许 MacTools 使用输入监控,可读取已适配厂商 HID 鼠标信息。"
)
}
}
private var inputMonitoringUnauthorizedFootnote: String {
switch viewModel.snapshot.rapooState {
case .permissionDenied:
return localization.string(
"permission.inputMonitoring.openSettingsFootnote",
defaultValue: "前往系统设置 → 隐私与安全性 → 输入监控,允许 MacTools。"
)
case .failed(let message):
return localization.format(
"permission.inputMonitoring.unauthorized.failed",
defaultValue: "授权后可读取厂商 HID 鼠标信息。最近读取失败:%@",
message
)
case .idle, .scanning, .waitingForReport, .connected, .noDevice:
return localization.string(
"permission.inputMonitoring.unauthorized.default",
defaultValue: "授权后可读取厂商 HID 鼠标信息;未授权不影响 Mac 内置电池、系统蓝牙设备和 Apple 外设电量。"
)
}
}
private static func currentInputMonitoringAuthorizationStatus() -> DeviceBatteryInputMonitoringAuthorizationStatus {
switch IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) {
case kIOHIDAccessTypeGranted:
return .granted
case kIOHIDAccessTypeDenied:
return .denied
case kIOHIDAccessTypeUnknown:
return .unknown
default:
return .unknown
}
}
private func openInputMonitoringSettings() {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") else {
return
}
NSWorkspace.shared.open(url)
}
}