Skip to content
Merged
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
4 changes: 2 additions & 2 deletions Mystral/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.1.0</string>
<string>1.1.1</string>
<key>CFBundleVersion</key>
<string>17</string>
<string>18</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
Expand Down
75 changes: 73 additions & 2 deletions Mystral/Services/SMCHelperMode.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Foundation
import IOKit
import IOKit.pwr_mgt
import os

private let logger = Logger(subsystem: "com.fexxdev.Mystral", category: "SMCHelper")
Expand All @@ -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"
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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, &notifyPort, mystralSleepWakeCallback, &notifier)
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)
Comment on lines +149 to +151
}

/// 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"
Expand Down Expand Up @@ -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 <IOKit/IOMessage.h>.
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))
}
}
Comment on lines +290 to +297
39 changes: 38 additions & 1 deletion MystralTests/SMCServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ 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 }
func getAllFans() throws -> [Fan] { fans }
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 {
Expand All @@ -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 <IOKit/IOMessage.h>. 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)
}
}
168 changes: 168 additions & 0 deletions docs/superpowers/specs/2026-06-08-quiet-fans-on-sleep-design.md
Original file line number Diff line number Diff line change
@@ -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, &notifyPort, mystralPowerCallback, &notifier)
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.