fanctl is a small macOS fan-control CLI for Apple Silicon Macs.
It provides:
- read-only fan status
- fixed RPM control
- max fan mode
- return-to-Apple automatic mode
- temperature sensor discovery
- daemon-managed temperature-driven profiles
The project is intentionally boring: SwiftPM, no cloud, no telemetry. The CLI is the primary interface; an optional native SwiftUI menu bar app (fanctl-ui) is a thin read-mostly client over the same daemon socket.
Status: personal prototype. It works on the author's Mac, but AppleSMC keys and write behavior can vary by model and macOS version. Read the safety notes before using write commands.
Most fan tools are GUI-first. fanctl is for people who want terminal control:
fanctl status
fanctl sensors
fanctl profile balanced
fanctl max
fanctl autoWrites are handled by a root LaunchDaemon (fanctld) so you approve installation once, then use fanctl without typing sudo for every command.
fanctl statusShows fan RPM, target RPM, hardware min/max, and current mode.
fanctl sensorsReads a curated list of AppleSMC temperature keys and prints sane values.
fanctl autoStops any active fanctl profile and returns fans to macOS automatic control.
fanctl set 3500Sets all fans to a validated absolute RPM. RPMs below detected min or above detected max are rejected.
fanctl maxSets all fans to their detected maximum RPM.
fanctl profile quiet
fanctl profile balanced
fanctl profile coolProfiles are daemon-managed curves, not one-shot fixed values. fanctld reads temperature every ~2 seconds, maps temperature to target RPM, and applies hysteresis/ramp limits.
Initial behavior:
| Profile | Start ramp | Midpoint | Max by | Idle floor |
|---|---|---|---|---|
| quiet | 62°C | 78°C | 95°C | fan min |
| balanced | 55°C | 72°C | 88°C | min + 15% |
| cool | 48°C | 65°C | 82°C | min + 25% |
Emergency rule: if the control temperature reaches 95°C, fans go to max.
Requirements:
- macOS 13+
- Apple Silicon Mac
- Xcode or Command Line Tools with Swift
Clone/build:
git clone <repo-url> fanctl
cd fanctl
swift build
.build/debug/fanctl-selftestInstall daemon and CLI:
sudo ./install_fanctld.shThis installs:
/usr/local/bin/fanctl/usr/local/libexec/fanctld/Library/LaunchDaemons/io.github.fanctl.fanctld.plist- socket:
/var/run/fanctld.sock - log:
/var/log/fanctld.log
Uninstall:
sudo ./uninstall_fanctld.shfanctl status
fanctl sensors
fanctl profile balanced
fanctl status
fanctl auto
fanctl set 3500
fanctl maxLogs:
tail -f /var/log/fanctld.logfanctl includes a native SwiftUI menu-bar app with a dashboard window, manual fan controls, and settings.
swift build --product fanctl-uiFixture mode is opt-in: without a --fixture* or --render-fixture flag the app
connects to the real fanctld socket. Pass a fixture flag to get canned data and a
read-only transport that never writes fan hardware:
.build/debug/fanctl-uiTo open a visible fixture preview window (useful for screenshots and testing):
.build/debug/fanctl-ui --fixture-windowFixture mode uses a read-only in-memory transport and never opens the production socket for writes, so it is safe for screenshots and UI QA.
| Flag | Values | Purpose |
|---|---|---|
--fixture <state> |
normal, manual, profile, hot, critical, disconnected, no-sensors, sensor-failure, no-fans, malformed, stale, disjoint-range, loading |
Select the canned data state |
FANCTL_FIXTURE=<state> |
same as above | Environment equivalent of --fixture |
--fixture-window |
none | Show the popover in a plain visible window |
--fixture-dashboard <size> |
default (700x620), minimum (540x480) |
Open the dashboard at a fixed size |
--fixture-settings <mode> |
light, dark |
Open the Settings window in a forced appearance |
--fixture-manual-controls |
none | Present the manual-controls sheet |
--fixture-scroll-bottom |
none | Scroll the dashboard to its bottom section |
--fixture-appearance <mode> |
light, dark |
Force light/dark for any fixture window without opening Settings |
--render-fixture |
see below | Render one fixture view to a PNG offscreen and exit |
Example:
.build/debug/fanctl-ui --fixture no-sensors --fixture-window
.build/debug/fanctl-ui --fixture normal --fixture-dashboard minimum
--render-fixture draws a single fixture-backed view into an offscreen window and
writes a PNG, then exits. It needs no Screen Recording permission and never contacts
fanctld.
.build/debug/fanctl-ui --render-fixture --mode menu --state malformed \
--appearance dark --output /tmp/menu_malformed_dark.png| Option | Values |
|---|---|
--mode |
menu, dashboard (700x620), dashboard-min (540x480), dashboard-narrow (540 wide, natural height), settings, manual |
--state |
any --fixture state |
--appearance |
light, dark |
--age <seconds> |
Ages the fetched status so stale states render deterministically |
--output <path> |
PNG destination |
Each render prints measured geometry and pixel statistics, for example:
OK mode=menu state=stale appearance=light frame=360x395 fitting=360x395 clipped=false
idealOverflow=false opaque=1.000 uniform=0.712 edgeL=12 edgeR=12 edgeT=0 edgeB=0
blank=false bytes=70597 path=...
Exit codes: 0 ok, 1 error, 2 content clipped on a dimension the mode promises
to fit, 3 blank or mostly transparent render.
| Script | Purpose |
|---|---|
Scripts/capture_fixtures.sh [dir] |
Renders all 38 representative fixture states to PNG (fixture-only) |
Scripts/verify_shots.py [dir] |
OCRs every shot with the Vision framework, asserts the expected copy is visible and that no text touches a panel edge |
Scripts/ocr_shot.swift <png> |
Dumps every recognised text line with its normalised bounding box |
Scripts/make_app_bundle.sh [path] |
Release build plus a validated menu-bar-only .app bundle |
Scripts/capture_fixtures.sh
Scripts/verify_shots.pyWhen fanctld is running on the default socket, the app connects automatically and shows real fan data:
sudo .build/debug/fanctld &
.build/debug/fanctl-ui- Menu-bar popover with fan status, temperature, and profile controls
- Dashboard window with overview, fan details, sensor readings, and diagnostics
- Manual controls sheet for fixed RPM and maximum speed
- Settings for menu-bar display mode, refresh interval, temperature unit, and appearance
- Light/dark mode support
- Keyboard shortcuts (Cmd+R to refresh, Cmd+D dashboard, Cmd+, settings, Cmd+Q quit, Esc to close sheets)
- Full accessibility labels for VoiceOver
- Respects Reduce Motion (static in-flight indicators) and Increase Contrast (secondary text promoted to primary)
- Connection states: connecting, connected, stale, disconnected
- Error states: connection errors, command errors, unreadable (malformed) daemon output with a raw-response disclosure, no sensors, sensor read failure with fan controls still available, no controllable fans, and non-intersecting fan ranges
- Deterministic fixture mode for testing without hardware
Use the script — it builds, assembles, validates the plist against the bundled binary name, and ad-hoc signs the result:
Scripts/make_app_bundle.shThe equivalent manual steps:
swift build --product fanctl-ui -c release
mkdir -p fanctl-ui.app/Contents/MacOS
cp .build/release/fanctl-ui fanctl-ui.app/Contents/MacOS/
cat > fanctl-ui.app/Contents/Info.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>fanctl-ui</string>
<key>CFBundleIdentifier</key>
<string>io.github.fanctl.ui</string>
<key>CFBundleName</key>
<string>fanctl</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>
EOFLSUIElement keeps the app as a menu-bar-only agent (no Dock icon).
This tool writes private AppleSMC fan keys. That is inherently unofficial.
Guardrails included:
- validates requested RPM against detected fan min/max
- verifies target RPM writes stick
autostops profile loop and returns control to macOS- temp profiles fail if no valid temperature sensors are found
- emergency max at 95°C
- hysteresis and ramp limiting to avoid noisy oscillation
Things still not production-grade:
- socket is local world-writable in the prototype
- no notarized helper / SMAppService install flow
- sensor key list is curated, not exhaustive
- third-party fan apps can fight this daemon
Quit other fan-control tools before testing.
Build:
swift buildSelftest:
.build/debug/fanctl-selftestXCTest exists under Tests/, but fanctl-selftest is the authoritative test path because XCTest was unreliable on the original development machine.
Debug daemon without installing:
rm -f /tmp/fanctld.sock
FANCTLD_SOCKET=/tmp/fanctld.sock .build/debug/fanctldIn another terminal:
FANCTLD_SOCKET=/tmp/fanctld.sock .build/debug/fanctl sensorsfanctl CLI
-> Unix socket /var/run/fanctld.sock
-> fanctld root LaunchDaemon
-> AppleSMC via IOKit
Main modules:
FanCtlCore: command parsing, daemon protocol, SMC formats, fan control, temp curvesFanCtlCLI: user CLIFanCtlDaemon: root daemon and socket serverFanCtlSelfTest: no-hardware fake-backend test executableFanCtlUI: SwiftUI menu-bar app (dashboard, manual controls, settings)FanCtlUILib: UI model, parsers, transport, fixtures (shared with tests)
This project was informed by existing open-source AppleSMC/fan-control work, especially:
MIT. See LICENSE.