diff --git a/Mystral/Info.plist b/Mystral/Info.plist
index d82932a..1eb059e 100644
--- a/Mystral/Info.plist
+++ b/Mystral/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_TYPE)
CFBundleShortVersionString
- 1.1.0
+ 1.1.1
CFBundleVersion
- 17
+ 18
LSMinimumSystemVersion
$(MACOSX_DEPLOYMENT_TARGET)
LSUIElement
diff --git a/Mystral/Services/SMCHelperMode.swift b/Mystral/Services/SMCHelperMode.swift
index db2a5be..ba70a06 100644
--- a/Mystral/Services/SMCHelperMode.swift
+++ b/Mystral/Services/SMCHelperMode.swift
@@ -1,4 +1,6 @@
import Foundation
+import IOKit
+import IOKit.pwr_mgt
import os
private let logger = Logger(subsystem: "com.fexxdev.Mystral", category: "SMCHelper")
@@ -8,6 +10,11 @@ enum SMCHelperMode {
static let cmdDir = "/tmp/mystral-cmds"
static let pidPath = "/tmp/mystral-helper.pid"
+ /// State the C system-power callback needs (it can't capture context). Set once in
+ /// run(); read only from the callback, which runs on the helper's serial queue.
+ nonisolated(unsafe) fileprivate static var rootPowerPort: io_connect_t = 0
+ nonisolated(unsafe) fileprivate static var powerSMC: SMCServiceProtocol?
+
static var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"
}
@@ -74,8 +81,7 @@ enum SMCHelperMode {
signal(SIGTERM, SIG_IGN)
sigSource.setEventHandler {
logger.info("SMCHelper — SIGTERM received, restoring auto mode and cleaning up")
- let count = (try? smc.getAllFans().count) ?? 2
- try? smc.setForcedMode(fanCount: count, forced: false)
+ restoreAutoMode(smc: smc)
cleanup()
exit(0)
}
@@ -113,11 +119,57 @@ enum SMCHelperMode {
}
timer.resume()
+ registerForSleepNotifications(smc: smc, queue: queue)
+
withExtendedLifetime(activityToken) {
dispatchMain()
}
}
+ /// Registers the helper for system-power notifications so it restores auto fan mode
+ /// just before the Mac sleeps (issue #2). Delivery is bound to the helper's serial
+ /// `queue`, so the callback never races the polling timer's SMC access.
+ private static func registerForSleepNotifications(smc: SMCServiceProtocol, queue: DispatchQueue) {
+ powerSMC = smc
+ var notifyPort: IONotificationPortRef?
+ var notifier: io_object_t = 0
+ let port = IORegisterForSystemPower(nil, ¬ifyPort, mystralSleepWakeCallback, ¬ifier)
+ guard port != 0, let notifyPort else {
+ logger.error("SMCHelper — IORegisterForSystemPower failed; sleep auto-restore disabled")
+ return
+ }
+ rootPowerPort = port
+ IONotificationPortSetDispatchQueue(notifyPort, queue)
+ logger.info("SMCHelper — registered for system sleep/wake power notifications")
+ }
+
+ /// Hand fan control back to macOS auto mode (releases the forced setpoint the SMC
+ /// otherwise holds). Used both on SIGTERM and when the system is about to sleep so
+ /// the firmware idles the fans instead of whining at the last forced RPM (issue #2).
+ static func restoreAutoMode(smc: SMCServiceProtocol) {
+ let count = (try? smc.getAllFans().count) ?? 2
+ try? smc.setForcedMode(fanCount: count, forced: false)
+ }
+
+ /// Routes a system-power message to its fan action: on "will sleep" it hands fans
+ /// back to macOS auto so the firmware idles them (issue #2); other messages leave
+ /// fans alone (wake re-apply is the app's `handleWake`). Returns whether the message
+ /// is a sleep query/notification the caller must acknowledge with `IOAllowPowerChange`
+ /// — failing to ack stalls sleep ~30s. IOKit-free so it's unit-testable.
+ @discardableResult
+ static func handlePowerMessage(_ messageType: UInt32, smc: SMCServiceProtocol?) -> Bool {
+ switch messageType {
+ case kMystralMsgSystemWillSleep:
+ logger.info("SMCHelper — system will sleep, restoring auto fan mode")
+ if let smc { restoreAutoMode(smc: smc) }
+ return true
+ case kMystralMsgCanSystemSleep:
+ return true // never veto idle sleep; ack only
+ default:
+ return false // e.g. kIOMessageSystemHasPoweredOn — app's handleWake re-applies
+ }
+ }
+
private static func dumpDiagnostics(smc: SMCService) {
let diagPath = "/tmp/mystral-diagnostics.log"
var log = "=== Mystral SMC Diagnostics ===\n"
@@ -224,3 +276,22 @@ enum SMCHelperMode {
try? FileManager.default.removeItem(atPath: cmdDir)
}
}
+
+// IOKit's kIOMessage* power constants are nested function-like C macros
+// (`iokit_common_msg(x)` = `(UInt32)(sys_iokit | x)`, with `sys_iokit = 0x38 << 26`)
+// that Swift's importer can't translate, so reconstruct the two we handle.
+// See .
+private let kMystralMsgCanSystemSleep: UInt32 = (0x38 << 26) | 0x270 // 0xE0000270
+private let kMystralMsgSystemWillSleep: UInt32 = (0x38 << 26) | 0x280 // 0xE0000280
+
+/// C-compatible (`@convention(c)`) system-power callback — captures nothing, so it reads
+/// the SMC handle and root port from `SMCHelperMode`'s static storage and delegates the
+/// (unit-tested) routing to `handlePowerMessage`. Its only job here is the IOKit ack.
+private func mystralSleepWakeCallback(_ refcon: UnsafeMutableRawPointer?,
+ _ service: io_service_t,
+ _ messageType: UInt32,
+ _ messageArgument: UnsafeMutableRawPointer?) {
+ if SMCHelperMode.handlePowerMessage(messageType, smc: SMCHelperMode.powerSMC) {
+ IOAllowPowerChange(SMCHelperMode.rootPowerPort, Int(bitPattern: messageArgument))
+ }
+}
diff --git a/MystralTests/SMCServiceTests.swift b/MystralTests/SMCServiceTests.swift
index 1a484ae..67792bc 100644
--- a/MystralTests/SMCServiceTests.swift
+++ b/MystralTests/SMCServiceTests.swift
@@ -13,6 +13,8 @@ final class MockSMCService: SMCServiceProtocol, @unchecked Sendable {
var lastSetFanIndex: Int?
var lastSetPercentage: Double?
var lastSetMode: FanMode?
+ var lastForcedModeFanCount: Int?
+ var lastForcedModeForced: Bool?
func getAllSensors() throws -> [Sensor] { sensors }
func readTemperature(key: String) throws -> Double { sensors.first { $0.id == key }?.temperature ?? 0 }
@@ -20,7 +22,7 @@ final class MockSMCService: SMCServiceProtocol, @unchecked Sendable {
func readFanSpeed(index: Int) throws -> Int { fans[index].currentRPM }
func setFanSpeed(index: Int, percentage: Double) throws { lastSetFanIndex = index; lastSetPercentage = percentage }
func setFanMode(index: Int, mode: FanMode) throws { lastSetMode = mode }
- func setForcedMode(fanCount: Int, forced: Bool) throws {}
+ func setForcedMode(fanCount: Int, forced: Bool) throws { lastForcedModeFanCount = fanCount; lastForcedModeForced = forced }
}
final class SMCServiceTests: XCTestCase {
@@ -42,4 +44,39 @@ final class SMCServiceTests: XCTestCase {
XCTAssertEqual(svc.lastSetFanIndex, 0)
XCTAssertEqual(svc.lastSetPercentage, 75.0)
}
+
+ // Issue #2: on system sleep the helper must hand fan control back to macOS
+ // auto mode so the firmware idles the fans (no whining during sleep).
+ func testRestoreAutoModeReleasesForcedControl() {
+ let svc = MockSMCService()
+ SMCHelperMode.restoreAutoMode(smc: svc)
+ XCTAssertEqual(svc.lastForcedModeForced, false)
+ XCTAssertEqual(svc.lastForcedModeFanCount, 2) // mock reports 2 fans
+ }
+
+ // Issue #2: the exact routing the IOKit sleep/wake callback runs, driven with the
+ // real IOKit ABI message numbers from . handlePowerMessage
+ // returns whether the message must be acked via IOAllowPowerChange.
+
+ func testSystemWillSleepHandsFansBackToAuto() {
+ let svc = MockSMCService()
+ let needsAck = SMCHelperMode.handlePowerMessage(0xE000_0280, smc: svc) // kIOMessageSystemWillSleep
+ XCTAssertEqual(svc.lastForcedModeForced, false)
+ XCTAssertEqual(svc.lastForcedModeFanCount, 2)
+ XCTAssertTrue(needsAck) // must ack or the system stalls ~30s before sleeping
+ }
+
+ func testCanSystemSleepIsAckedWithoutTouchingFans() {
+ let svc = MockSMCService()
+ let needsAck = SMCHelperMode.handlePowerMessage(0xE000_0270, smc: svc) // kIOMessageCanSystemSleep
+ XCTAssertNil(svc.lastForcedModeForced) // fans left untouched
+ XCTAssertTrue(needsAck)
+ }
+
+ func testPoweredOnLeavesFansToTheAppAndNeedsNoAck() {
+ let svc = MockSMCService()
+ let needsAck = SMCHelperMode.handlePowerMessage(0xE000_0300, smc: svc) // kIOMessageSystemHasPoweredOn
+ XCTAssertNil(svc.lastForcedModeForced) // app's handleWake re-applies the curve
+ XCTAssertFalse(needsAck)
+ }
}
diff --git a/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md b/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md
new file mode 100644
index 0000000..ca97983
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md
@@ -0,0 +1,168 @@
+# Quiet Fans on Sleep — Design
+
+**Date:** 2026-06-08
+**Status:** Approved (design)
+**Issue:** #2 — "There's no need for the fan to spin when it's hibernating"
+
+## Goal
+
+When the Mac goes to **system sleep**, fans should stop whining. Today they keep
+spinning at whatever speed Mystral last forced, because the SMC firmware retains
+the forced fan mode + commanded RPM while the app's poll loop is suspended.
+
+Fix: on sleep, **hand fan control back to macOS auto mode** so the firmware idles
+the fans the way it normally would during sleep. On wake, Mystral re-applies its
+curve (already handled).
+
+## Root cause (verified by reading the code)
+
+- `FanController` (app, `@MainActor`, 2 s `Timer`) forces fan mode
+ (`setForcedMode(forced: true)`) and writes curve-driven speeds each tick.
+- The app cannot write the SMC itself — only the **root helper daemon**
+ (`SMCHelperMode`, a KeepAlive LaunchDaemon) can. The app sends commands to the
+ helper via JSON files in `/tmp/mystral-cmds`, which the helper drains on its own
+ 2 s `DispatchSource` timer.
+- On system sleep both timers are suspended. The SMC keeps honoring the last
+ `setForcedMode(true)` + last `setFanSpeed`, so the fan holds that RPM → whine.
+- Wake is already handled: `FanController.handleWake` (`didWakeNotification`)
+ resets `forcedModeSet = false` and grants a 15 s helper grace, so the next tick
+ re-asserts forced mode + curve.
+- There is **no `willSleep` handling anywhere** today.
+
+## Why the helper (not the app) must do this — chosen approach
+
+Restoring auto mode means an SMC write, and only the helper can write the SMC.
+Two ways to trigger it:
+
+- **App-driven (rejected):** app observes `NSWorkspace.willSleepNotification` →
+ writes a `setForcedMode(false)` command file → helper drains it on its 2 s
+ timer. Racy: the system can sleep before the helper polls, and the stale command
+ then applies on *wake*, fighting `handleWake`. The file queue is too slow/unordered
+ for sleep transitions.
+- **Helper-driven via IOKit (chosen):** the helper registers with
+ `IORegisterForSystemPower` and, on `kIOMessageSystemWillSleep`, restores auto
+ mode **in-process** (no file round-trip) before acking the power change. The SMC
+ owner does the write directly, synchronously, inside the pre-sleep window. This
+ is the same operation the helper's existing `SIGTERM` handler already performs.
+
+Behavior is **always-on** — no setting, no UI. Quieting fans during sleep is
+unambiguously correct.
+
+## Architecture
+
+All changes are confined to **`Mystral/Services/SMCHelperMode.swift`** (plus a
+test-only mock extension). No app-side, `SMCProxyService`, or UI changes.
+
+### 1. Shared "restore auto" helper
+
+Factor the auto-restore logic (currently inline in the `SIGTERM` handler) into one
+testable function and use it from both the `SIGTERM` path and the new sleep path:
+
+```swift
+static func restoreAutoMode(smc: SMCServiceProtocol) {
+ let count = (try? smc.getAllFans().count) ?? 2
+ try? smc.setForcedMode(fanCount: count, forced: false)
+}
+```
+
+Takes the protocol type so the existing `MockSMCService` can drive it in tests.
+Concrete `SMCService` (used by the helper) conforms to `SMCServiceProtocol`.
+
+### 2. IOKit system-power registration
+
+`import IOKit.pwr_mgt` (for `IORegisterForSystemPower`/`IOAllowPowerChange`/
+`IONotificationPortSetDispatchQueue`) and `import IOKit` (for the `kIOMessage*`
+constants from `IOMessage.h`).
+
+In `run()`, after the SMC is opened and the dispatch `queue` is created, register
+for power notifications and bind delivery to the **same `queue`** the helper's
+timer uses, so the callback is serialized with `processCommands`/SMC reads (no
+concurrent SMC access):
+
+```swift
+var notifyPort: IONotificationPortRef?
+var notifier: io_object_t = 0
+let rootPort = IORegisterForSystemPower(nil, ¬ifyPort, mystralPowerCallback, ¬ifier)
+if rootPort != 0, let notifyPort {
+ rootPowerPort = rootPort // static, read by the C callback
+ powerSMC = smc // static, read by the C callback
+ IONotificationPortSetDispatchQueue(notifyPort, queue)
+ // notifyPort / notifier retained for process lifetime (run() never returns)
+} else {
+ logger.error("SMCHelper — IORegisterForSystemPower failed; sleep auto-restore disabled")
+}
+```
+
+`IORegisterForSystemPower`'s callback is a C function pointer and cannot capture
+context, so the bits it needs are held in file-scope static storage on
+`SMCHelperMode`, set once in `run()`. They are `fileprivate` (not `private`) so the
+same-file C callback can read them, and `nonisolated(unsafe)` because the project
+builds in **Swift 6 language mode** (mutable statics are otherwise rejected as
+nonisolated global shared mutable state). Safe here: written once at startup, read
+only from the callback, which runs on the helper's serial `queue`:
+
+```swift
+nonisolated(unsafe) fileprivate static var rootPowerPort: io_connect_t = 0
+nonisolated(unsafe) fileprivate static var powerSMC: SMCServiceProtocol?
+```
+
+### 3. The callback (C-compatible, file-private free function)
+
+```swift
+private func mystralPowerCallback(_ refcon: UnsafeMutableRawPointer?,
+ _ service: io_service_t,
+ _ messageType: UInt32,
+ _ messageArgument: UnsafeMutableRawPointer?) {
+ switch messageType {
+ case UInt32(kIOMessageCanSystemSleep):
+ // We never veto idle sleep — must ack promptly or sleep is delayed 30 s.
+ IOAllowPowerChange(SMCHelperMode.rootPowerPort, Int(bitPattern: messageArgument))
+ case UInt32(kIOMessageSystemWillSleep):
+ if let smc = SMCHelperMode.powerSMC { SMCHelperMode.restoreAutoMode(smc: smc) }
+ IOAllowPowerChange(SMCHelperMode.rootPowerPort, Int(bitPattern: messageArgument))
+ default:
+ break // kIOMessageSystemHasPoweredOn: app's handleWake re-applies the curve
+ }
+}
+```
+
+`rootPowerPort` and `powerSMC` are `fileprivate static` on `SMCHelperMode` so the
+same-file free function can read them; `restoreAutoMode` is `static` (default
+internal) and likewise reachable.
+
+### Wake path — unchanged
+
+On `kIOMessageSystemHasPoweredOn` the helper does nothing. The app's existing
+`handleWake` resets `forcedModeSet`, grants the 15 s grace, and the next 2 s tick
+re-asserts forced mode + writes curve speeds. A brief (≤2 s) auto-mode window right
+after wake is harmless. If the app isn't running, nothing was forced anyway, so
+there's nothing to re-apply.
+
+## Testing
+
+- **Unit (no hardware):** extend `MockSMCService` to record forced-mode calls
+ (`lastForcedModeForced`, `lastForcedModeFanCount`), then assert
+ `SMCHelperMode.restoreAutoMode(smc:)` calls `setForcedMode(fanCount: 2, forced: false)`
+ for the 2-fan mock. This covers the actual sleep response logic.
+- **Manual:** run the app with a forced curve, sleep the Mac, confirm the fan
+ spins down (or audibly quiets); wake and confirm the curve re-engages within one
+ tick. IOKit registration / callback delivery verified by Console logs.
+
+## Out of scope (YAGNI)
+
+- Any user setting / toggle for the behavior.
+- Forcing a fixed minimum RPM during sleep (rejected in favor of firmware auto).
+- Explicit IOKit teardown (`IODeregisterForSystemPower`/`IOServiceClose`) — the
+ helper holds the registration for its whole life; process exit reclaims it.
+- Display-only sleep — only full system sleep (`kIOMessageSystemWillSleep`) is
+ handled; the curve should keep running while the display alone sleeps.
+
+## Risks
+
+- **`IORegisterForSystemPower` registration fails** → logged, and the helper
+ behaves exactly as today (no regression). Graceful degradation.
+- **Must always `IOAllowPowerChange`** for both `kIOMessageCanSystemSleep` and
+ `kIOMessageSystemWillSleep`, or sleep is delayed ~30 s. Both paths ack
+ unconditionally.
+- **Concurrency:** the callback shares the helper's serial `queue` with the timer,
+ so SMC access stays serialized.