From 557c17979796ec017387ffe5a7cf1bce95241a99 Mon Sep 17 00:00:00 2001 From: nsd97 Date: Sun, 19 Jul 2026 12:32:26 -0400 Subject: [PATCH] Keep fans through lid-close sleep on AC when enabled. Honor Keep Fans When Closed on Power for willSleep so lid close does not force fans back to auto after a keep decision. Co-authored-by: Cursor --- ChillMac/App/AppSettings.swift | 2 ++ ChillMac/App/DiagnosticExporter.swift | 4 +++- ChillMac/Fan/DisplaySleepFanPolicy.swift | 30 ++++++++++++++++++++++++ ChillMac/Fan/FanMonitor.swift | 23 ++++++++++++++++-- ChillMac/Fan/PowerSource.swift | 16 +++++++++++++ ChillMac/Views/SettingsView.swift | 26 ++++++++++++++++++++ 6 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 ChillMac/Fan/DisplaySleepFanPolicy.swift create mode 100644 ChillMac/Fan/PowerSource.swift diff --git a/ChillMac/App/AppSettings.swift b/ChillMac/App/AppSettings.swift index 4c90ed6..ded05a0 100644 --- a/ChillMac/App/AppSettings.swift +++ b/ChillMac/App/AppSettings.swift @@ -76,6 +76,8 @@ final class AppSettings: ObservableObject { @AppStorage("batterySaverThreshold") var batterySaverThreshold = 20 // percent @AppStorage("forcePerformanceOnBattery") var forcePerformanceOnBattery = false @AppStorage("keepFansOnScreenSleep") var keepFansOnScreenSleep = false + /// When on AC, keep fans through display sleep / lock (clamshell while awake). Does not keep the Mac awake. + @AppStorage("keepFansClosedOnPower") var keepFansClosedOnPower = false @AppStorage("showFPS") var showFPS = false static let popoverMinHeight: CGFloat = 400 diff --git a/ChillMac/App/DiagnosticExporter.swift b/ChillMac/App/DiagnosticExporter.swift index 26b13c3..828d191 100644 --- a/ChillMac/App/DiagnosticExporter.swift +++ b/ChillMac/App/DiagnosticExporter.swift @@ -28,6 +28,7 @@ struct SettingsSnapshot: Codable { let batterySaverThreshold: Int let useFahrenheit: Bool let keepFansOnScreenSleep: Bool + let keepFansClosedOnPower: Bool } // MARK: - Exporter @@ -56,7 +57,8 @@ enum DiagnosticExporter { batterySaverEnabled: settings.batterySaverEnabled, batterySaverThreshold: settings.batterySaverThreshold, useFahrenheit: settings.useFahrenheit, - keepFansOnScreenSleep: settings.keepFansOnScreenSleep + keepFansOnScreenSleep: settings.keepFansOnScreenSleep, + keepFansClosedOnPower: settings.keepFansClosedOnPower ), sleepIntervals: logger.sleepIntervalsSnapshot(), history: logger.snapshot() diff --git a/ChillMac/Fan/DisplaySleepFanPolicy.swift b/ChillMac/Fan/DisplaySleepFanPolicy.swift new file mode 100644 index 0000000..9f091cb --- /dev/null +++ b/ChillMac/Fan/DisplaySleepFanPolicy.swift @@ -0,0 +1,30 @@ +import Foundation + +enum DisplaySleepFanPolicy { + /// Whether screen sleep / lock should leave fans under app control. + /// `keepClosedOnPower` only applies when on AC; `keepOnScreenSleep` is power-agnostic. + static func shouldKeepFansThroughDisplaySleep( + keepClosedOnPower: Bool, + keepOnScreenSleep: Bool, + onAC: Bool + ) -> Bool { + if keepClosedOnPower && onAC { return true } + return keepOnScreenSleep + } + + /// Whether `NSWorkspace.willSleepNotification` should force fans back to auto. + /// + /// Lid close often posts willSleep after screensDidSleep. This must use the same keep + /// decision so a keep preference is not undone by resetting fans on willSleep. + static func shouldResetFansOnSystemWillSleep( + keepClosedOnPower: Bool, + keepOnScreenSleep: Bool, + onAC: Bool + ) -> Bool { + !shouldKeepFansThroughDisplaySleep( + keepClosedOnPower: keepClosedOnPower, + keepOnScreenSleep: keepOnScreenSleep, + onAC: onAC + ) + } +} diff --git a/ChillMac/Fan/FanMonitor.swift b/ChillMac/Fan/FanMonitor.swift index 7a19598..cd9cf7a 100644 --- a/ChillMac/Fan/FanMonitor.swift +++ b/ChillMac/Fan/FanMonitor.swift @@ -121,6 +121,17 @@ final class FanMonitor: ObservableObject { } @objc private func handleSleep() { + let shouldReset = DisplaySleepFanPolicy.shouldResetFansOnSystemWillSleep( + keepClosedOnPower: AppSettings.shared.keepFansClosedOnPower, + keepOnScreenSleep: AppSettings.shared.keepFansOnScreenSleep, + onAC: PowerSource.isOnAC + ) + if !shouldReset { + // Lid-close often posts willSleep after screensDidSleep. Do not undo a keep decision + // by forcing fans back to auto — especially keepClosedOnPower + AC. + NSLog("FanMonitor: system willSleep — keeping fans active (user preference)") + return + } NSLog("FanMonitor: system going to sleep — resetting fans to auto") systemAsleep = true resetAllFansToAuto() @@ -133,7 +144,11 @@ final class FanMonitor: ObservableObject { } @objc private func handleScreenSleep() { - if AppSettings.shared.keepFansOnScreenSleep { + if DisplaySleepFanPolicy.shouldKeepFansThroughDisplaySleep( + keepClosedOnPower: AppSettings.shared.keepFansClosedOnPower, + keepOnScreenSleep: AppSettings.shared.keepFansOnScreenSleep, + onAC: PowerSource.isOnAC + ) { NSLog("FanMonitor: screen sleep — keeping fans active (user preference)") return } @@ -149,7 +164,11 @@ final class FanMonitor: ObservableObject { } @objc private func handleScreenLocked() { - if AppSettings.shared.keepFansOnScreenSleep { + if DisplaySleepFanPolicy.shouldKeepFansThroughDisplaySleep( + keepClosedOnPower: AppSettings.shared.keepFansClosedOnPower, + keepOnScreenSleep: AppSettings.shared.keepFansOnScreenSleep, + onAC: PowerSource.isOnAC + ) { NSLog("FanMonitor: screen locked — keeping fans active (user preference)") return } diff --git a/ChillMac/Fan/PowerSource.swift b/ChillMac/Fan/PowerSource.swift new file mode 100644 index 0000000..83b05e2 --- /dev/null +++ b/ChillMac/Fan/PowerSource.swift @@ -0,0 +1,16 @@ +import Foundation +import IOKit.ps + +enum PowerSource { + /// Injectable seam for unit tests. Production default reads IOPS providing type. + static var providingType: () -> String? = { + guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let type = IOPSGetProvidingPowerSourceType(blob)?.takeUnretainedValue() as String? + else { return nil } + return type + } + + static var isOnAC: Bool { + providingType() == kIOPSACPowerValue + } +} diff --git a/ChillMac/Views/SettingsView.swift b/ChillMac/Views/SettingsView.swift index ab1747a..5f44da1 100644 --- a/ChillMac/Views/SettingsView.swift +++ b/ChillMac/Views/SettingsView.swift @@ -285,6 +285,32 @@ struct SettingsView: View { } .padding(.horizontal, 14) .padding(.vertical, 10) + + Divider().padding(.leading, 52) + + HStack { + Image(systemName: "laptopcomputer.and.arrow.down") + .font(.system(size: 16)) + .foregroundColor(theme.textTertiary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text("Keep Fans When Closed on Power") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(theme.textPrimary) + Text("On AC only — for clamshell while awake. Does not force the Mac to stay awake.") + .font(.system(size: 11)) + .foregroundColor(theme.textQuaternary) + } + Spacer() + Toggle(isOn: $settings.keepFansClosedOnPower) { + EmptyView() + } + .toggleStyle(.switch) + .controlSize(.small) + .tint(.orange) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) } .background(theme.cardBg) .cornerRadius(12)