512 lines
18 KiB
Swift
512 lines
18 KiB
Swift
import Foundation
|
|
import OSLog
|
|
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
// MARK: - Bundle Factory
|
|
|
|
public final class FanControlPluginFactory: NSObject, MacToolsPluginBundleFactory {
|
|
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
|
|
FanControlPluginProvider(context: context)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct FanControlPluginProvider: PluginProvider {
|
|
let context: PluginRuntimeContext
|
|
func makePlugins() -> [any MacToolsPlugin] {
|
|
[FanControlPlugin(context: context, localization: PluginLocalization(bundle: context.resourceBundle))]
|
|
}
|
|
}
|
|
|
|
// MARK: - Control IDs
|
|
|
|
private enum ControlID {
|
|
static let presetList = "fan-preset-list"
|
|
static let customSlider = "fan-custom-rpm"
|
|
static let addPreset = "fan-add-preset"
|
|
static let deletePreset = "fan-delete-preset"
|
|
static let missingHelper = "fan-missing-helper"
|
|
}
|
|
|
|
// MARK: - Plugin
|
|
|
|
@MainActor
|
|
final class FanControlPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginFeatureVisibilityLifecycleHandling {
|
|
|
|
// 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: Private State
|
|
|
|
let presetStore: FanControlPresetStore
|
|
private let smcReader: any FanControlSMCReading
|
|
private let smcWriter: any FanControlSMCWriting
|
|
private let localization: PluginLocalization
|
|
private let monitoringActiveInterval: Duration
|
|
private let monitoringIdleInterval: Duration
|
|
|
|
private var isExpanded = false
|
|
private var isFeaturePanelVisible = false
|
|
private var fanSnapshot = FanSnapshot.empty
|
|
private var lastErrorMessage: String?
|
|
private var requiresAutoRestore = false
|
|
private var monitoringTask: Task<Void, Never>?
|
|
private var sleepObserver: (any NSObjectProtocol)?
|
|
private var wakeObserver: (any NSObjectProtocol)?
|
|
|
|
// MARK: Init
|
|
|
|
init(
|
|
context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "fan-control"),
|
|
smcReader: any FanControlSMCReading = FanControlSMCReader(),
|
|
smcWriter: (any FanControlSMCWriting)? = nil,
|
|
localization: PluginLocalization = PluginLocalization(bundle: .main),
|
|
monitoringActiveInterval: Duration = .seconds(2),
|
|
monitoringIdleInterval: Duration = .seconds(10)
|
|
) {
|
|
self.localization = localization
|
|
self.presetStore = FanControlPresetStore(storage: context.storage, localization: localization)
|
|
self.smcReader = smcReader
|
|
self.smcWriter = smcWriter ?? FanControlSMCWriter(
|
|
resourceBundle: context.resourceBundle,
|
|
localization: localization
|
|
)
|
|
self.monitoringActiveInterval = monitoringActiveInterval
|
|
self.monitoringIdleInterval = monitoringIdleInterval
|
|
self.metadata = PluginMetadata(
|
|
id: "fan-control",
|
|
title: localization.string("metadata.title", defaultValue: "风扇控制"),
|
|
iconName: "fan",
|
|
iconTint: Color(nsColor: .systemCyan),
|
|
order: 45,
|
|
defaultDescription: localization.string("metadata.description", defaultValue: "管理风扇转速预设")
|
|
)
|
|
}
|
|
|
|
// MARK: - MacToolsPlugin
|
|
|
|
func activate(context: PluginRuntimeContext) {
|
|
startMonitoring()
|
|
registerSleepWakeObservers()
|
|
// Re-apply the persisted active preset so fan state is consistent
|
|
// even after the app restarts. Skip if already "auto" to avoid
|
|
// spurious admin prompts on launch.
|
|
let preset = presetStore.activePreset
|
|
if case .auto = preset.strategy { return }
|
|
applyActivePreset()
|
|
}
|
|
|
|
func deactivate(reason: PluginDeactivationReason) {
|
|
unregisterSleepWakeObservers()
|
|
stopMonitoring()
|
|
if reason.requiresStateCleanup {
|
|
restoreAutomaticControlIfNeeded(reason: String(describing: reason))
|
|
}
|
|
}
|
|
|
|
func refresh() {
|
|
fanSnapshot = smcReader.readSnapshot()
|
|
onStateChange?()
|
|
}
|
|
|
|
// MARK: - PluginPrimaryPanel
|
|
|
|
var primaryPanelState: PluginPanelState {
|
|
PluginPanelState(
|
|
subtitle: panelSubtitle,
|
|
isOn: false,
|
|
isExpanded: isExpanded,
|
|
isEnabled: true,
|
|
isVisible: true,
|
|
detail: isExpanded ? buildDetail() : nil,
|
|
errorMessage: lastErrorMessage
|
|
)
|
|
}
|
|
|
|
var permissionRequirements: [PluginPermissionRequirement] { [] }
|
|
var settingsSections: [PluginSettingsSection] { [] }
|
|
var shortcutDefinitions: [PluginShortcutDefinition] { [] }
|
|
|
|
var configuration: PluginConfiguration? {
|
|
PluginConfiguration(description: metadata.defaultDescription) { [self] _ in
|
|
FanControlPresetManagerView(
|
|
presetStore: self.presetStore,
|
|
fanSnapshot: self.fanSnapshot,
|
|
localization: self.localization
|
|
)
|
|
}
|
|
}
|
|
|
|
func handleAction(_ action: PluginPanelAction) {
|
|
switch action {
|
|
case let .setDisclosureExpanded(expanded):
|
|
guard isExpanded != expanded else { return }
|
|
isExpanded = expanded
|
|
if !expanded { lastErrorMessage = nil }
|
|
restartMonitoringIfRunning()
|
|
onStateChange?()
|
|
|
|
case let .setSelection(controlID, optionID):
|
|
guard controlID == ControlID.presetList else { return }
|
|
guard presetStore.allPresets.contains(where: { $0.id == optionID }) else { return }
|
|
presetStore.setActivePreset(id: optionID)
|
|
lastErrorMessage = nil
|
|
onStateChange?()
|
|
applyActivePreset()
|
|
|
|
case let .setSlider(controlID, value, phase):
|
|
guard controlID == ControlID.customSlider, phase == .ended else { return }
|
|
let rpm = Int(value)
|
|
let activeID = presetStore.activePresetID
|
|
presetStore.updateCustomPresetRPM(id: activeID, rpm: rpm)
|
|
lastErrorMessage = nil
|
|
onStateChange?()
|
|
applyActivePreset()
|
|
|
|
case let .invokeAction(controlID):
|
|
handleInvokeAction(controlID)
|
|
|
|
case .setSwitch, .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) {}
|
|
|
|
func featureVisibilityDidChange(_ isVisible: Bool) {
|
|
guard isFeaturePanelVisible != isVisible else {
|
|
return
|
|
}
|
|
|
|
isFeaturePanelVisible = isVisible
|
|
restartMonitoringIfRunning()
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func handleInvokeAction(_ controlID: String) {
|
|
switch controlID {
|
|
case ControlID.addPreset:
|
|
// Navigation to settings page is handled by MenuBarContent;
|
|
// the host intercepts this action ID and calls
|
|
// pluginHost.presentPluginConfiguration(pluginID: "fan-control").
|
|
break
|
|
|
|
case ControlID.deletePreset:
|
|
let idToDelete = presetStore.activePresetID
|
|
guard !presetStore.activePreset.isBuiltIn else { return }
|
|
presetStore.deleteCustomPreset(id: idToDelete)
|
|
lastErrorMessage = nil
|
|
onStateChange?()
|
|
// Active preset has been reset to auto by the store
|
|
applyActivePreset()
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func applyActivePreset() {
|
|
let preset = presetStore.activePreset
|
|
let snapshot = fanSnapshot.fanCount > 0 ? fanSnapshot : smcReader.readSnapshot()
|
|
let result = smcWriter.apply(strategy: preset.strategy, snapshot: snapshot)
|
|
updateAutoRestoreRequirement(afterApplying: preset.strategy, result: result)
|
|
if let err = result {
|
|
lastErrorMessage = err.localizedDescription(localization: localization)
|
|
FanControlLog.plugin.error("Apply preset failed: \(err.localizedDescription, privacy: .public)")
|
|
} else {
|
|
lastErrorMessage = nil
|
|
}
|
|
onStateChange?()
|
|
}
|
|
|
|
// MARK: - Monitoring
|
|
|
|
private func startMonitoring() {
|
|
guard monitoringTask == nil else { return }
|
|
monitoringTask = Task { @MainActor [weak self] in
|
|
while !Task.isCancelled {
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
let snapshot = self.smcReader.readSnapshot()
|
|
if self.updateFanSnapshotIfNeeded(snapshot) {
|
|
self.onStateChange?()
|
|
}
|
|
try? await Task.sleep(for: self.currentMonitoringInterval)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopMonitoring() {
|
|
monitoringTask?.cancel()
|
|
monitoringTask = nil
|
|
}
|
|
|
|
private var currentMonitoringInterval: Duration {
|
|
if isFeaturePanelVisible && isExpanded {
|
|
return monitoringActiveInterval
|
|
}
|
|
return monitoringIdleInterval
|
|
}
|
|
|
|
private func updateFanSnapshotIfNeeded(_ snapshot: FanSnapshot) -> Bool {
|
|
guard !fanSnapshot.isMeaningfullyEquivalent(to: snapshot) else {
|
|
return false
|
|
}
|
|
|
|
fanSnapshot = snapshot
|
|
return true
|
|
}
|
|
|
|
private func restartMonitoringIfRunning() {
|
|
guard monitoringTask != nil else {
|
|
return
|
|
}
|
|
|
|
stopMonitoring()
|
|
startMonitoring()
|
|
}
|
|
|
|
private func registerSleepWakeObservers() {
|
|
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() {
|
|
let preset = presetStore.activePreset
|
|
guard case .auto = preset.strategy else {
|
|
restoreAutomaticControlIfNeeded(reason: "system sleep")
|
|
return
|
|
}
|
|
}
|
|
|
|
private func handleSystemDidWake() {
|
|
fanSnapshot = smcReader.readSnapshot()
|
|
let preset = presetStore.activePreset
|
|
if case .auto = preset.strategy { return }
|
|
applyActivePreset()
|
|
FanControlLog.plugin.info("System did wake — re-applied active preset: \(preset.name, privacy: .public)")
|
|
}
|
|
|
|
// MARK: - Panel Builder
|
|
|
|
private var panelSubtitle: String {
|
|
let preset = presetStore.activePreset
|
|
if let rpm = fanSnapshot.averageSpeed, rpm > 0 {
|
|
return localization.format(
|
|
"panel.subtitle.withRPM",
|
|
defaultValue: "%@ · %@",
|
|
preset.displayName(localization: localization),
|
|
"\(rpm) RPM"
|
|
)
|
|
}
|
|
return preset.displayName(localization: localization)
|
|
}
|
|
|
|
private func buildDetail() -> PluginPanelDetail {
|
|
var controls: [PluginPanelControl] = []
|
|
|
|
// 1. Preset select list
|
|
let presetOptions = presetStore.allPresets.map {
|
|
PluginPanelControlOption(
|
|
id: $0.id,
|
|
title: $0.displayName(localization: localization),
|
|
subtitle: presetSubtitle(for: $0)
|
|
)
|
|
}
|
|
controls.append(PluginPanelControl(
|
|
id: ControlID.presetList,
|
|
kind: .selectList,
|
|
options: presetOptions,
|
|
selectedOptionID: presetStore.activePresetID,
|
|
dateValue: nil,
|
|
minimumDate: nil,
|
|
displayedComponents: nil,
|
|
datePickerStyle: nil,
|
|
sectionTitle: nil,
|
|
isEnabled: true
|
|
))
|
|
|
|
// 2. Slider for the active custom preset
|
|
let activePreset = presetStore.activePreset
|
|
if case let .fixed(rpm) = activePreset.strategy, !activePreset.isBuiltIn {
|
|
let maxSlider = Double(fanSnapshot.globalMaxSpeed > 0
|
|
? fanSnapshot.globalMaxSpeed
|
|
: FanRPMLimits.fallbackMax)
|
|
controls.append(PluginPanelControl(
|
|
id: ControlID.customSlider,
|
|
kind: .slider,
|
|
options: [],
|
|
selectedOptionID: nil,
|
|
dateValue: nil,
|
|
minimumDate: nil,
|
|
displayedComponents: nil,
|
|
datePickerStyle: nil,
|
|
sectionTitle: localization.string("panel.slider.targetRPM", defaultValue: "目标转速"),
|
|
sliderValue: Double(rpm),
|
|
sliderBounds: Double(FanRPMLimits.absoluteMin)...maxSlider,
|
|
sliderStep: 100,
|
|
valueLabel: "\(rpm) RPM",
|
|
isEnabled: true
|
|
))
|
|
|
|
// 3. Delete button for custom preset
|
|
controls.append(PluginPanelControl(
|
|
id: ControlID.deletePreset,
|
|
kind: .actionRow,
|
|
options: [],
|
|
selectedOptionID: nil,
|
|
dateValue: nil,
|
|
minimumDate: nil,
|
|
displayedComponents: nil,
|
|
datePickerStyle: nil,
|
|
sectionTitle: nil,
|
|
actionTitle: localization.string("panel.action.deletePreset", defaultValue: "删除此预设"),
|
|
actionIconSystemName: "trash",
|
|
isEnabled: true
|
|
))
|
|
}
|
|
|
|
// 4. Add custom preset → opens plugin settings page
|
|
controls.append(PluginPanelControl(
|
|
id: ControlID.addPreset,
|
|
kind: .actionRow,
|
|
options: [],
|
|
selectedOptionID: nil,
|
|
dateValue: nil,
|
|
minimumDate: nil,
|
|
displayedComponents: nil,
|
|
datePickerStyle: nil,
|
|
sectionTitle: nil,
|
|
actionTitle: localization.string("panel.action.managePresets", defaultValue: "管理预设…"),
|
|
actionIconSystemName: "slider.horizontal.3",
|
|
actionBehavior: .dismissBeforeHandling,
|
|
showsLeadingDivider: true,
|
|
isEnabled: true
|
|
))
|
|
|
|
// 5. Helper-not-found warning
|
|
if !smcWriter.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 presetSubtitle(for preset: FanPreset) -> String? {
|
|
switch preset.strategy {
|
|
case .auto:
|
|
return localization.string("preset.auto.subtitle", defaultValue: "由 macOS 管理")
|
|
case .fullSpeed:
|
|
let maxRPM = fanSnapshot.globalMaxSpeed
|
|
return maxRPM > 0
|
|
? localization.format("preset.fullSpeed.subtitleWithRPM", defaultValue: "最高 %d RPM", maxRPM)
|
|
: localization.string("preset.fullSpeed.subtitle", defaultValue: "最高转速")
|
|
case .fixed(let rpm):
|
|
return "\(rpm) RPM"
|
|
}
|
|
}
|
|
|
|
private func updateAutoRestoreRequirement(afterApplying strategy: FanControlStrategy, result: FanWriteError?) {
|
|
switch strategy {
|
|
case .auto:
|
|
if result == nil {
|
|
requiresAutoRestore = false
|
|
}
|
|
case .fullSpeed, .fixed:
|
|
if result == nil || result?.mayHaveChangedFanState == true {
|
|
requiresAutoRestore = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private func restoreAutomaticControlIfNeeded(reason: String) {
|
|
guard requiresAutoRestore else {
|
|
FanControlLog.plugin.info("Skipped fan auto restore for \(reason, privacy: .public); no successful manual fan write in this session")
|
|
return
|
|
}
|
|
|
|
guard smcWriter.isInstalledHelperAvailable else {
|
|
FanControlLog.plugin.info("Skipped fan auto restore for \(reason, privacy: .public); SMC helper is not installed")
|
|
return
|
|
}
|
|
|
|
let snapshot = fanSnapshot.fanCount > 0 ? fanSnapshot : smcReader.readSnapshot()
|
|
let result = smcWriter.apply(strategy: .auto, snapshot: snapshot)
|
|
updateAutoRestoreRequirement(afterApplying: .auto, result: result)
|
|
|
|
if let result {
|
|
FanControlLog.plugin.error("Failed to restore fan control to auto for \(reason, privacy: .public): \(result.localizedDescription, privacy: .public)")
|
|
} else {
|
|
FanControlLog.plugin.info("Restored fan control to auto for \(reason, privacy: .public)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension FanWriteError {
|
|
var mayHaveChangedFanState: Bool {
|
|
if case .writeFailed = self {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|