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? 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 } }