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

665 lines
24 KiB
Swift

import Foundation
import OSLog
import SwiftUI
import MacToolsPluginKit
// MARK: - Bundle Factory
public final class BatteryChargeLimitPluginFactory: NSObject, MacToolsPluginBundleFactory {
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
BatteryChargeLimitPluginProvider(context: context)
}
}
@MainActor
private struct BatteryChargeLimitPluginProvider: PluginProvider {
let context: PluginRuntimeContext
func makePlugins() -> [any MacToolsPlugin] {
[
BatteryChargeLimitPlugin(
context: context,
localization: PluginLocalization(bundle: context.resourceBundle)
),
]
}
}
// MARK: - Control IDs
private enum ControlID {
static let enableAction = "battery-enable-action"
static let limitSlider = "battery-limit-slider"
static let chargeAction = "battery-charge-action"
static let dischargeAction = "battery-discharge-action"
static let manageSettings = "battery-manage-settings"
static let missingHelper = "battery-missing-helper"
}
// MARK: - Plugin
@MainActor
final class BatteryChargeLimitPlugin: MacToolsPlugin, PluginPrimaryPanel {
// MARK: Metadata
let metadata: PluginMetadata
let primaryPanelDescriptor = PluginPrimaryPanelDescriptor(
controlStyle: .disclosure,
menuActionBehavior: .keepPresented
)
// MARK: Callbacks
var onStateChange: (() -> Void)?
var requestPermissionGuidance: ((String) -> Void)?
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
// MARK: State
let store: BatteryChargeLimitStore
private let localization: PluginLocalization
private let reader: any BatteryChargeLimitReading
private let writer: any BatteryChargeLimitWriting
private var isExpanded = false
private var batterySnapshot: BatterySnapshot = .empty
private var capabilities: BatterySMCCapabilities = .none
private var lastErrorMessage: String?
private var requiresSMCCleanup = false
private var monitoringTask: Task<Void, Never>?
private var sleepObserver: (any NSObjectProtocol)?
private var wakeObserver: (any NSObjectProtocol)?
// MARK: Init
init(
context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "battery-charge-limit"),
reader: any BatteryChargeLimitReading = BatteryChargeLimitReader(),
writer: (any BatteryChargeLimitWriting)? = nil,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.localization = localization
self.metadata = PluginMetadata(
id: "battery-charge-limit",
title: localization.string("metadata.title", defaultValue: "电池充电上限"),
iconName: "battery.100.bolt",
iconTint: Color(nsColor: .systemGreen),
order: 48,
defaultDescription: localization.string(
"metadata.description",
defaultValue: "限制电池充电至指定上限"
)
)
self.store = BatteryChargeLimitStore(storage: context.storage)
self.reader = reader
self.writer = writer ?? BatteryChargeLimitWriter(resourceBundle: context.resourceBundle)
}
// MARK: - Lifecycle
func activate(context: PluginRuntimeContext) {
batterySnapshot = reader.readSnapshot()
// Re-assert the persisted mode after app restart. SMC keys can be
// reset by firmware across sleep/hibernation, so on launch we
// re-apply whatever the user last had configured.
if store.isEnabled {
startActiveMonitoring()
applyCurrentMode(reason: "activate")
}
}
func deactivate(reason: PluginDeactivationReason) {
stopActiveMonitoring()
if reason.requiresStateCleanup {
restoreUnrestrictedChargingIfNeeded(reason: String(describing: reason), requireInstalledHelper: true)
}
}
func refresh() {
batterySnapshot = reader.readSnapshot()
evaluateAutoTransitions()
onStateChange?()
}
// MARK: - PluginPrimaryPanel
var primaryPanelState: PluginPanelState {
PluginPanelState(
subtitle: panelSubtitle,
isOn: store.isEnabled,
isExpanded: isExpanded,
isEnabled: batterySnapshot.hasBattery,
isVisible: batterySnapshot.hasBattery,
detail: isExpanded ? buildDetail() : nil,
errorMessage: lastErrorMessage
)
}
var permissionRequirements: [PluginPermissionRequirement] { [] }
var settingsSections: [PluginSettingsSection] { [] }
var shortcutDefinitions: [PluginShortcutDefinition] { [] }
var configuration: PluginConfiguration? {
PluginConfiguration(description: metadata.defaultDescription) { [self] _ in
BatteryChargeLimitSettingsView(
store: self.store,
capabilities: self.capabilities,
snapshot: self.batterySnapshot,
localization: self.localization
)
}
}
func handleAction(_ action: PluginPanelAction) {
switch action {
case let .setDisclosureExpanded(expanded):
isExpanded = expanded
if !expanded { lastErrorMessage = nil }
onStateChange?()
case let .setSlider(controlID, value, phase):
guard controlID == ControlID.limitSlider, phase == .ended else { return }
handleLimitChange(Int(value))
case let .invokeAction(controlID):
handleInvokeAction(controlID)
case .setSwitch, .setSelection, .setNavigationSelection, .clearNavigationSelection, .setDate:
break
}
}
func permissionState(for permissionID: String) -> PluginPermissionState {
PluginPermissionState(isGranted: true, footnote: nil)
}
func handlePermissionAction(id: String) {}
func handleSettingsAction(id: String) {}
func handleShortcutAction(id: String) {}
// MARK: - User Actions
private func handleEnableToggle(_ value: Bool) {
store.setEnabled(value)
if value {
// Probe capabilities lazily — the helper install prompt happens
// on first call. Surface a clear error if the hardware can't be
// inhibited.
capabilities = writer.probeCapabilities()
if !capabilities.canInhibit && writer.isHelperAvailable {
lastErrorMessage = localizedDescription(for: .noSupportedSMCKey)
store.setEnabled(false)
onStateChange?()
return
}
// Enabling always starts in holdAtLimit — the core behavior is
// "don't auto-charge; user must explicitly resume."
store.setMode(.holdAtLimit)
startActiveMonitoring()
applyCurrentMode(reason: "user-enable")
} else {
store.setMode(.holdAtLimit)
_ = restoreUnrestrictedCharging(reason: "user-disable", requireInstalledHelper: false)
stopActiveMonitoring()
lastErrorMessage = nil
}
onStateChange?()
}
private func handleLimitChange(_ percent: Int) {
store.setLimitPercent(percent)
// Changing the limit always returns to holdAtLimit. This matches the
// user's design: the act of setting a limit means "stop charging at
// this level; don't auto-resume."
if store.isEnabled {
store.setMode(.holdAtLimit)
applyCurrentMode(reason: "limit-change")
}
onStateChange?()
}
private func handleInvokeAction(_ controlID: String) {
switch controlID {
case ControlID.enableAction:
handleEnableToggle(!store.isEnabled)
case ControlID.chargeAction:
handleChargeActionTap()
case ControlID.dischargeAction:
handleDischargeActionTap()
case ControlID.manageSettings:
// The host intercepts this action and opens the plugin's settings
// configuration page. No-op here.
break
default:
break
}
}
private func handleChargeActionTap() {
guard store.isEnabled else { return }
switch store.mode {
case .holdAtLimit:
// User explicitly asks to start charging. Move to .charging; the
// monitoring loop will revert to .holdAtLimit when the battery
// reaches the limit.
store.setMode(.charging)
applyCurrentMode(reason: "user-resume")
case .charging:
// User asks to stop charging — return to .holdAtLimit.
store.setMode(.holdAtLimit)
applyCurrentMode(reason: "user-stop-charging")
case .discharging:
// Treat as "stop discharging and hold at current level."
store.setMode(.holdAtLimit)
applyCurrentMode(reason: "user-stop-discharge-via-charge")
}
onStateChange?()
}
private func handleDischargeActionTap() {
guard store.isEnabled, capabilities.canForceDischarge else { return }
switch store.mode {
case .discharging:
store.setMode(.holdAtLimit)
applyCurrentMode(reason: "user-stop-discharge")
default:
store.setMode(.discharging)
applyCurrentMode(reason: "user-start-discharge")
}
onStateChange?()
}
// MARK: - State Application
private func applyCurrentMode(reason: String) {
guard store.isEnabled else {
_ = restoreUnrestrictedCharging(reason: reason, requireInstalledHelper: false)
return
}
switch store.mode {
case .holdAtLimit:
_ = setForceDischarge(false)
if let err = inhibitCharging(limitPercent: store.limitPercent) {
lastErrorMessage = localizedDescription(for: err)
BatteryChargeLimitLog.plugin.error("inhibit failed (\(reason, privacy: .public)): \(self.localizedDescription(for: err), privacy: .public)")
} else {
lastErrorMessage = nil
}
case .charging:
if let err = restoreUnrestrictedCharging(reason: reason, requireInstalledHelper: false) {
lastErrorMessage = localizedDescription(for: err)
BatteryChargeLimitLog.plugin.error("resume failed (\(reason, privacy: .public)): \(self.localizedDescription(for: err), privacy: .public)")
} else {
lastErrorMessage = nil
}
case .discharging:
// Force-discharge implies the inhibit keys must also be set so
// the adapter doesn't fight us by charging back up.
if let err = inhibitCharging(limitPercent: store.limitPercent) {
BatteryChargeLimitLog.plugin.error("inhibit-for-discharge failed: \(self.localizedDescription(for: err), privacy: .public)")
}
if let err = setForceDischarge(true) {
lastErrorMessage = localizedDescription(for: err)
BatteryChargeLimitLog.plugin.error("force-discharge failed (\(reason, privacy: .public)): \(self.localizedDescription(for: err), privacy: .public)")
} else {
lastErrorMessage = nil
}
}
onStateChange?()
}
/// Automatic mode transitions driven by battery level changes.
/// Crucially, we DO NOT transition out of `.holdAtLimit` here — the user's
/// design choice is that "below limit, charging stays off until manual resume."
private func evaluateAutoTransitions() {
guard store.isEnabled, let level = batterySnapshot.levelPercent else { return }
switch store.mode {
case .charging where level >= store.limitPercent:
store.setMode(.holdAtLimit)
applyCurrentMode(reason: "auto-reached-limit")
case .discharging where level <= store.limitPercent:
store.setMode(.holdAtLimit)
applyCurrentMode(reason: "auto-discharged-to-limit")
case .holdAtLimit:
// Re-assert the inhibit periodically — firmware can reset SMC
// keys across sleep, adapter unplug/replug, and rare hibernation
// events. Cheap to re-issue.
if batterySnapshot.state == .charging {
applyCurrentMode(reason: "re-assert-inhibit")
}
default:
break
}
}
// MARK: - Monitoring
private func startActiveMonitoring() {
startMonitoring()
registerSleepWakeObservers()
}
private func stopActiveMonitoring() {
unregisterSleepWakeObservers()
stopMonitoring()
}
private func startMonitoring() {
guard monitoringTask == nil else { return }
monitoringTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.batterySnapshot = self?.reader.readSnapshot() ?? .empty
self?.evaluateAutoTransitions()
self?.onStateChange?()
try? await Task.sleep(for: .seconds(5))
}
}
}
private func stopMonitoring() {
monitoringTask?.cancel()
monitoringTask = nil
}
private func registerSleepWakeObservers() {
guard sleepObserver == nil, wakeObserver == nil else { return }
let center = NSWorkspace.shared.notificationCenter
sleepObserver = center.addObserver(
forName: NSWorkspace.willSleepNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in self?.handleSystemWillSleep() }
}
wakeObserver = center.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in self?.handleSystemDidWake() }
}
}
private func unregisterSleepWakeObservers() {
let center = NSWorkspace.shared.notificationCenter
if let obs = sleepObserver { center.removeObserver(obs) }
if let obs = wakeObserver { center.removeObserver(obs) }
sleepObserver = nil
wakeObserver = nil
}
private func handleSystemWillSleep() {
// Keep the inhibit in place on sleep so the Mac doesn't quietly
// charge past the limit while the user is away.
guard store.isEnabled else { return }
BatteryChargeLimitLog.plugin.info("System will sleep — current mode: \(String(describing: self.store.mode), privacy: .public)")
}
private func handleSystemDidWake() {
guard store.isEnabled else { return }
batterySnapshot = reader.readSnapshot()
applyCurrentMode(reason: "did-wake")
BatteryChargeLimitLog.plugin.info("System did wake — re-asserted mode: \(String(describing: self.store.mode), privacy: .public)")
}
// MARK: - Panel Builder
private var panelSubtitle: String {
guard batterySnapshot.hasBattery else {
return localization.string("panel.subtitle.noBattery", defaultValue: "未检测到电池")
}
let level = batterySnapshot.levelPercent ?? 0
if !store.isEnabled {
return localization.format("panel.subtitle.disabled", defaultValue: "未启用 · %d%%", level)
}
let limit = store.limitPercent
switch store.mode {
case .holdAtLimit:
if level >= limit {
return localization.format(
"panel.subtitle.limitReached",
defaultValue: "已达上限 · %d%% / %d%%",
level,
limit
)
}
return localization.format(
"panel.subtitle.chargingStopped",
defaultValue: "已停止充电 · %d%% / %d%%",
level,
limit
)
case .charging:
return localization.format("panel.subtitle.charging", defaultValue: "充电中 · %d%% → %d%%", level, limit)
case .discharging:
return localization.format("panel.subtitle.discharging", defaultValue: "放电中 · %d%% → %d%%", level, limit)
}
}
private func buildDetail() -> PluginPanelDetail {
var controls: [PluginPanelControl] = []
// 1. Enable/disable toggle as the first row.
let enableTitle = store.isEnabled
? localization.string("panel.action.disable", defaultValue: "停用充电上限")
: localization.string("panel.action.enable", defaultValue: "启用充电上限")
let enableIcon = store.isEnabled ? "checkmark.circle.fill" : "circle"
controls.append(PluginPanelControl(
id: ControlID.enableAction,
kind: .actionRow,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: nil,
actionTitle: enableTitle,
actionIconSystemName: enableIcon,
isEnabled: writer.isHelperAvailable
))
if store.isEnabled {
// 2. Limit slider — shown only when enabled
controls.append(PluginPanelControl(
id: ControlID.limitSlider,
kind: .slider,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: localization.string("panel.section.limit", defaultValue: "充电上限"),
sliderValue: Double(store.limitPercent),
sliderBounds: Double(BatteryChargeLimits.minimumPercent)...Double(BatteryChargeLimits.maximumPercent),
sliderStep: Double(BatteryChargeLimits.percentStep),
valueLabel: "\(store.limitPercent)%",
isEnabled: true
))
// 3. Charge/stop button — context-sensitive title and icon
let chargeTitle: String
let chargeIcon: String
switch store.mode {
case .holdAtLimit:
chargeTitle = localization.string("panel.action.startCharging", defaultValue: "开始充电")
chargeIcon = "bolt.fill"
case .charging:
chargeTitle = localization.string("panel.action.stopCharging", defaultValue: "停止充电")
chargeIcon = "bolt.slash.fill"
case .discharging:
chargeTitle = localization.string("panel.action.stopDischarging", defaultValue: "停止放电")
chargeIcon = "stop.fill"
}
controls.append(PluginPanelControl(
id: ControlID.chargeAction,
kind: .actionRow,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: nil,
actionTitle: chargeTitle,
actionIconSystemName: chargeIcon,
showsLeadingDivider: true,
isEnabled: true
))
// 4. Force-discharge button — only when supported AND battery is
// currently above the limit (otherwise it's a no-op).
if capabilities.canForceDischarge,
let level = batterySnapshot.levelPercent,
level > store.limitPercent
{
let title = store.mode == .discharging
? localization.string("panel.action.stopDischarging", defaultValue: "停止放电")
: localization.format(
"panel.action.dischargeToLimit",
defaultValue: "强制放电至 %d%%",
store.limitPercent
)
controls.append(PluginPanelControl(
id: ControlID.dischargeAction,
kind: .actionRow,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: nil,
actionTitle: title,
actionIconSystemName: "minus.circle",
isEnabled: true
))
}
}
// 5. Open settings page
controls.append(PluginPanelControl(
id: ControlID.manageSettings,
kind: .actionRow,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: nil,
actionTitle: localization.string("panel.action.settings", defaultValue: "设置…"),
actionIconSystemName: "slider.horizontal.3",
actionBehavior: .dismissBeforeHandling,
showsLeadingDivider: true,
isEnabled: true
))
// 6. Missing helper warning
if !writer.isHelperAvailable {
controls.append(PluginPanelControl(
id: ControlID.missingHelper,
kind: .actionRow,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: nil,
actionTitle: localization.string("panel.action.missingHelper", defaultValue: "电池控制组件缺失"),
actionIconSystemName: "exclamationmark.triangle",
actionBehavior: .dismissBeforeHandling,
showsLeadingDivider: true,
isEnabled: false
))
}
return PluginPanelDetail(primaryControls: controls, secondaryPanel: nil)
}
private func localizedDescription(for error: BatteryChargeWriteError) -> String {
error.localizedDescription(localization: localization)
}
@discardableResult
private func inhibitCharging(limitPercent: Int) -> BatteryChargeWriteError? {
let error = writer.inhibitCharging(limitPercent: limitPercent)
markCleanupRequiredIfNeeded(after: error)
return error
}
@discardableResult
private func setForceDischarge(_ on: Bool) -> BatteryChargeWriteError? {
let error = writer.setForceDischarge(on)
if on {
markCleanupRequiredIfNeeded(after: error)
}
return error
}
private func markCleanupRequiredIfNeeded(after error: BatteryChargeWriteError?) {
if error == nil || error?.mayHaveChangedSMCState == true {
requiresSMCCleanup = true
}
}
private func restoreUnrestrictedChargingIfNeeded(reason: String, requireInstalledHelper: Bool) {
guard requiresSMCCleanup else {
BatteryChargeLimitLog.plugin.info("Skipped SMC charge cleanup for \(reason, privacy: .public); no active charge-control state")
return
}
_ = restoreUnrestrictedCharging(reason: reason, requireInstalledHelper: requireInstalledHelper)
}
@discardableResult
private func restoreUnrestrictedCharging(
reason: String,
requireInstalledHelper: Bool
) -> BatteryChargeWriteError? {
guard !requireInstalledHelper || writer.isInstalledHelperAvailable else {
BatteryChargeLimitLog.plugin.info("Skipped SMC charge cleanup for \(reason, privacy: .public); helper is not installed")
return nil
}
let dischargeError = writer.setForceDischarge(false)
let resumeError = writer.resumeCharging()
if resumeError == nil && dischargeError == nil {
requiresSMCCleanup = false
BatteryChargeLimitLog.plugin.info("Cleared SMC charge-control state for \(reason, privacy: .public)")
return nil
} else {
BatteryChargeLimitLog.plugin.error("Failed to fully clear SMC charge-control state for \(reason, privacy: .public)")
return dischargeError ?? resumeError
}
}
}
private extension BatteryChargeWriteError {
var mayHaveChangedSMCState: Bool {
if case .writeFailed = self {
return true
}
return false
}
}