Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ChillMac/App/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion ChillMac/App/DiagnosticExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ struct SettingsSnapshot: Codable {
let batterySaverThreshold: Int
let useFahrenheit: Bool
let keepFansOnScreenSleep: Bool
let keepFansClosedOnPower: Bool
}

// MARK: - Exporter
Expand Down Expand Up @@ -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()
Expand Down
30 changes: 30 additions & 0 deletions ChillMac/Fan/DisplaySleepFanPolicy.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
23 changes: 21 additions & 2 deletions ChillMac/Fan/FanMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
16 changes: 16 additions & 0 deletions ChillMac/Fan/PowerSource.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
26 changes: 26 additions & 0 deletions ChillMac/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down