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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ Apple doesn't do this because silence sells in store demos and most users never

**0 to minimum RPM is binary:** Apple Silicon MacBook fans cannot spin below their minimum RPM (2317 on M5 Max, 1200 on M1 Max). When Smart decides fans should run, they jump directly to minimum — this is a hardware limitation of brushless DC motors that require a startup burst to overcome static friction. Above minimum, all speed changes are smooth and governed.

### M4/M5 sensor and responsiveness safeguards

On newer Apple Silicon machines, some legacy `Tp0*` keys may exist but return
placeholder values. ThermalForge prefers the machine's aggregate `TC*` CPU
sensors when they are available, and falls back to the legacy family only when
they are not. Full telemetry is refreshed once per second while the 100ms fan
ramp remains active; this keeps Smart responsive without repeatedly issuing a
large burst of SMC reads. Daemon commands run off the menu-bar actor as well, so
a slow privileged-daemon response cannot freeze the menu bar.

### FAQ

**What if ThermalForge closes during normal use?**
Expand Down
99 changes: 81 additions & 18 deletions Sources/ThermalForgeApp/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import SwiftUI
@MainActor
final class AppState: ObservableObject {
@Published var latestStatus: ThermalStatus?
// Keep Apple-default behavior on first launch. Smart remains selectable
// from the menu bar; the local diagnostic build used Smart explicitly.
@Published var activeProfile: FanProfile = .silent
@Published var monitorState: MonitorState = .idle
@Published var maxTemp: Float?
Expand All @@ -35,6 +37,12 @@ final class AppState: ObservableObject {
private var monitor: ThermalMonitor?
private let executor = PrivilegedExecutor()
private var heartbeatTimer: Timer?
private var heartbeatInFlight = false
/// Daemon commands are serialized and coalesced. A stalled daemon must not
/// create one detached task per 100ms ramp tick; only the newest pending
/// RPM target is useful once the socket becomes available again.
private var pendingFanCommand: FanCommand?
private var fanCommandInFlight = false

init() {
launchAtLogin = (SMAppService.mainApp.status == .enabled)
Expand Down Expand Up @@ -92,8 +100,22 @@ final class AppState: ObservableObject {
// MARK: - Heartbeat

private func startHeartbeat() {
let client = DaemonClient()
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.startHeartbeatRequest()
}
}
}

@MainActor
private func startHeartbeatRequest() {
guard !heartbeatInFlight else { return }
heartbeatInFlight = true

// Every daemon request has bounded socket I/O, but keep the whole
// heartbeat off MainActor so a slow daemon can never pause SwiftUI.
Task.detached { [weak self] in
let client = DaemonClient()
_ = try? client.send("heartbeat")

// Piggyback a version check on the heartbeat. Detect the raw "error:"
Expand All @@ -111,13 +133,17 @@ final class AppState: ObservableObject {
// menu bar and suspends our monitor (nil if unreadable — don't guess).
let hold = try? client.readState()

Task { @MainActor [weak self] in
self?.daemonVersionMismatch = mismatch
self?.externalHold = (hold?.isCLIHold == true) ? hold : nil
}
await self?.completeHeartbeat(mismatch: mismatch, hold: hold)
}
}

@MainActor
private func completeHeartbeat(mismatch: String?, hold: DaemonHoldState?) {
daemonVersionMismatch = mismatch
externalHold = (hold?.isCLIHold == true) ? hold : nil
heartbeatInFlight = false
}

// MARK: - Monitoring

func startMonitoring() {
Expand All @@ -129,9 +155,11 @@ final class AppState: ObservableObject {
self?.latestStatus = status
self?.activeProfile = profile
self?.monitorState = state
// Max of only the displayed sensors
// Peak across all CPU and GPU sensors for menu bar display
let displayPrefixes = ["TC", "Tp", "TG", "Tg"]
// Prefer aggregate TC* sensors when present. The Tp0* aliases
// on M4/M5 can be placeholder values and must not drive the UI
// temperature or Smart profile.
let hasAggregateCPU = status.temperatures.keys.contains { $0.hasPrefix("TC") }
let displayPrefixes = hasAggregateCPU ? ["TC", "TG", "Tg"] : ["Tp", "TG", "Tg"]
self?.maxTemp = status.temperatures
.filter { key, _ in displayPrefixes.contains(where: { key.hasPrefix($0) }) }
.values.max()
Expand All @@ -144,22 +172,57 @@ final class AppState: ObservableObject {
// monitor resumes control when they pick a profile or press
// Default (which clears externalHold).
guard self.externalHold == nil else { return }
do {
try self.executor.execute(command)
} catch {
// Daemon rejected us because a CLI hold owns the fans. Latch
// it now (don't wait up to 5s for the poll) so the monitor
// stops trying and the banner appears immediately.
if let state = try? DaemonClient().readState(), state.isCLIHold {
self.externalHold = state
}
}

// DaemonClient.sendRaw performs a blocking Unix-socket read.
// Never run it on MainActor: a slow SMC transaction must not
// freeze the menu bar or make Bartender appear to intercept it.
self.enqueueFanCommand(command)
}
}
monitor.start()
self.monitor = monitor
}

@MainActor
private func latchExternalHold(_ state: DaemonHoldState) {
externalHold = state
}

@MainActor
private func enqueueFanCommand(_ command: FanCommand) {
pendingFanCommand = command
guard !fanCommandInFlight else { return }
fanCommandInFlight = true
drainFanCommandQueue()
}

@MainActor
private func drainFanCommandQueue() {
guard let command = pendingFanCommand else {
fanCommandInFlight = false
return
}
pendingFanCommand = nil
let executor = self.executor

Task.detached { [weak self, executor] in
do {
try executor.execute(command)
} catch {
// Do not follow a timed-out command with another blocking
// state read. The non-overlapping heartbeat owns hold-state
// reconciliation and will publish a CLI hold on its next pass.
TFLogger.shared.error("Fan command failed: \(command) — \(error)")
}
await self?.fanCommandFinished()
}
}

@MainActor
private func fanCommandFinished() {
drainFanCommandQueue()
}

// MARK: - Actions

/// Explicit user takeover of any reflected CLI hold. Returns whether one was
Expand Down
4 changes: 4 additions & 0 deletions Sources/ThermalForgeApp/ThermalForgeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ struct ThermalForgeApp: App {
needsDaemonUpdate: appState.daemonVersionMismatch != nil
)
}
// MenuBarView is a rich custom panel (live telemetry, inline profile
// picker, banners and bordered controls), so it must use window style.
// Native menu style can register the status item yet fail to present
// this view when clicked on newer macOS releases.
.menuBarExtraStyle(.window)
}
}
Expand Down
13 changes: 13 additions & 0 deletions Sources/ThermalForgeCore/Daemon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ public struct DaemonHoldState: Codable, Equatable {
}

public final class DaemonClient {
/// A daemon-side SMC stall must not leave callers blocked forever. The app
/// treats a timeout like an unreachable daemon and lets the watchdog/reset
/// path recover on the next cycle.
private static let ioTimeoutSeconds: Int = 2

public init() {}

/// Read the daemon's current hold (what's set and who owns it) so the menu
Expand Down Expand Up @@ -150,6 +155,14 @@ public final class DaemonClient {
guard fd >= 0 else { throw DaemonError.connectionFailed }
defer { close(fd) }

var timeout = timeval(tv_sec: Self.ioTimeoutSeconds, tv_usec: 0)
_ = withUnsafePointer(to: &timeout) {
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, $0, socklen_t(MemoryLayout<timeval>.size))
}
_ = withUnsafePointer(to: &timeout) {
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, $0, socklen_t(MemoryLayout<timeval>.size))
}

var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
setPath(&addr, ThermalForgeDaemon.socketPath)
Expand Down
52 changes: 22 additions & 30 deletions Sources/ThermalForgeCore/FanControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ public final class FanControl {
private let modeKeyTemplate: String
/// Whether Ftst unlock is available (M1-M4) or not (M5+)
private let hasFtst: Bool
/// Temperature keys to query for this machine. M4/M5 machines expose a
/// stable aggregate CPU temperature, while legacy Tp0* aliases can return
/// placeholder values on those machines.
private let fltTemperatureKeys: [String]

public init() throws {
guard let connection = SMCConnection() else {
Expand All @@ -92,6 +96,20 @@ public final class FanControl {
} else {
self.hasFtst = false
}

let aggregateCPUKeys = ["TCDX", "TCHP", "TCMb"]
let legacyCPUKeys = [
"Tp01", "Tp02", "Tp03", "Tp04", "Tp05", "Tp06", "Tp07", "Tp08",
"Tp09", "Tp0A", "Tp0B", "Tp0C", "Tp0D", "Tp0F", "Tp0G", "Tp0H",
"Tp0J", "Tp0L", "Tp0P", "Tp0S", "Tp0T", "Tp0W", "Tp0X", "Tp0b",
]
let hasAggregateCPU = aggregateCPUKeys.contains { connection.getKeyInfo($0)?.size == 4 }

self.fltTemperatureKeys = (hasAggregateCPU ? aggregateCPUKeys : legacyCPUKeys) + [
"Tg05", "Tg0D", "Tg0L", "Tg0T", "Tg0f", "Tg0j",
"Tm02", "Tm06", "Tm08", "Tm09", "TRDX", "TMVR",
"TPDX", "TH0x", "TH0A", "TH0B", "TAOL", "TA0P", "TS0P", "TB0T",
]
}

// MARK: - Fan Count
Expand Down Expand Up @@ -313,38 +331,12 @@ public final class FanControl {
))
}

// Probe temperature keys across all known Apple Silicon generations.
// Keys that don't exist on a given machine are skipped automatically.
// Labels use the raw SMC key name — no assumptions about what a key
// means on hardware we haven't verified.
// Probe only the temperature family appropriate for this machine.
// On M4/M5, Tp0* aliases are present but are not reliable thermal
// sensors; querying them also adds substantial SMC traffic.
var temps: [String: Float] = [:]

// All known CPU/GPU/memory/misc thermal keys (flt type, 4 bytes)
let fltKeys: [String] = [
// CPU — aggregate (M5 Max verified)
"TCDX", "TCHP", "TCMb",
// CPU — per-core (Tp prefix, present across M1-M5 with varying mappings)
"Tp01", "Tp02", "Tp03", "Tp04", "Tp05", "Tp06", "Tp07", "Tp08",
"Tp09", "Tp0A", "Tp0B", "Tp0C", "Tp0D", "Tp0F", "Tp0G", "Tp0H",
"Tp0J", "Tp0L", "Tp0P", "Tp0S", "Tp0T", "Tp0W", "Tp0X", "Tp0b",
// GPU (flt type — M1 through M4)
"Tg05", "Tg0D", "Tg0L", "Tg0T", "Tg0f", "Tg0j",
// Memory
"Tm02", "Tm06", "Tm08", "Tm09",
"TRDX", "TMVR",
// Power delivery
"TPDX",
// SSD
"TH0x", "TH0A", "TH0B",
// Ambient
"TAOL", "TA0P",
// Proximity
"TS0P",
// Battery
"TB0T",
]

for key in fltKeys {
for key in fltTemperatureKeys {
let result = smc.readKey(key)
if result.success && result.size == 4 {
let temp = smcBytesToFloat(result.bytes, size: result.size)
Expand Down
Loading