Skip to content

Repository files navigation

fanctl

Made with KeCHe

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.

Why

Most fan tools are GUI-first. fanctl is for people who want terminal control:

fanctl status
fanctl sensors
fanctl profile balanced
fanctl max
fanctl auto

Writes are handled by a root LaunchDaemon (fanctld) so you approve installation once, then use fanctl without typing sudo for every command.

Features

Fan status

fanctl status

Shows fan RPM, target RPM, hardware min/max, and current mode.

Temperature sensors

fanctl sensors

Reads a curated list of AppleSMC temperature keys and prints sane values.

Automatic Apple control

fanctl auto

Stops any active fanctl profile and returns fans to macOS automatic control.

Fixed RPM

fanctl set 3500

Sets all fans to a validated absolute RPM. RPMs below detected min or above detected max are rejected.

Max

fanctl max

Sets all fans to their detected maximum RPM.

Temperature-driven profiles

fanctl profile quiet
fanctl profile balanced
fanctl profile cool

Profiles 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.

Install

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-selftest

Install daemon and CLI:

sudo ./install_fanctld.sh

This 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.sh

Usage

fanctl status
fanctl sensors
fanctl profile balanced
fanctl status
fanctl auto
fanctl set 3500
fanctl max

Logs:

tail -f /var/log/fanctld.log

macOS UI App

fanctl includes a native SwiftUI menu-bar app with a dashboard window, manual fan controls, and settings.

Build

swift build --product fanctl-ui

Launch (fixture/demo mode)

Fixture 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-ui

To open a visible fixture preview window (useful for screenshots and testing):

.build/debug/fanctl-ui --fixture-window

Fixture flags (debug/QA only)

Fixture 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

Offscreen rendering and screenshot QA

--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.

QA scripts

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.py

Launch (with daemon)

When fanctld is running on the default socket, the app connects automatically and shows real fan data:

sudo .build/debug/fanctld &
.build/debug/fanctl-ui

Features

  • 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

Creating an .app bundle

Use the script — it builds, assembles, validates the plist against the bundled binary name, and ad-hoc signs the result:

Scripts/make_app_bundle.sh

The 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>
EOF

LSUIElement keeps the app as a menu-bar-only agent (no Dock icon).

Safety notes

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
  • auto stops 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.

Development

Build:

swift build

Selftest:

.build/debug/fanctl-selftest

XCTest 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/fanctld

In another terminal:

FANCTLD_SOCKET=/tmp/fanctld.sock .build/debug/fanctl sensors

Architecture

fanctl 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 curves
  • FanCtlCLI: user CLI
  • FanCtlDaemon: root daemon and socket server
  • FanCtlSelfTest: no-hardware fake-backend test executable
  • FanCtlUI: SwiftUI menu-bar app (dashboard, manual controls, settings)
  • FanCtlUILib: UI model, parsers, transport, fixtures (shared with tests)

Credits

This project was informed by existing open-source AppleSMC/fan-control work, especially:

License

MIT. See LICENSE.

About

Small macOS Apple Silicon fan-control CLI and daemon

Resources

Contributing

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages