Fan — manual fan speed control for Omarchy
A bar widget for Apple SMC laptops (applesmc) that shows live fan RPM and
lets you take the fans off their automatic curve.
Built for and tested on a MacBook Pro 13,3 running Omarchy 4.0.0.
<img src="preview.png" alt="The Fan panel open below the bar, showing mode buttons, a speed slider, per-fan RPM and CPU temperature" width="360">Install
omarchy plugin add https://github.com/moerdowo/omarchy-mac-fan-control.git --enable --yes
sudo ~/.config/omarchy/plugins/io.github.moerdowo.fan/install-privileges.sh
The first command clones the plugin to ~/.config/omarchy/plugins/io.github.moerdowo.fan/
and puts the widget in the right bar section. The second grants the two
privileges the controls need — read Setup before running it, and read
install-privileges.sh itself if you'd rather not take my word for what it
writes. Plugins run as unsandboxed code inside omarchy-shell.
What controls these fans
Three things can drive an Apple SMC fan, and they do not cooperate:
- the SMC itself, which has its own thermal curve baked into firmware
- mbpfan, a userspace daemon that pins the SMC into forced mode and
rewrites
fanN_outputfrom CPU temperature once a second - anything else writing
fanN_output, which mbpfan overwrites on its next tick
On a machine where mbpfan is running — it is on this one, enabled by default — setting an RPM by hand lasts about a second. So manual mode here means stopping mbpfan and owning the fans, and auto mode means starting it again. If mbpfan is not installed, auto instead clears the forced-mode bit and hands the fans back to the SMC's own curve.
Setup
Two privileges are needed so that dragging the slider never pops an auth dialog. Install them once:
sudo ~/.config/omarchy/plugins/io.github.moerdowo.fan/install-privileges.sh
That writes:
| File | Grants |
|---|---|
/etc/udev/rules.d/90-applesmc-fan.rules |
group wheel write access to the applesmc fan attributes |
/etc/polkit-1/rules.d/49-mbpfan.rules |
group wheel may start/stop mbpfan.service without a password |
The polkit rule is scoped to that one unit and to local, active sessions. Until the script is run, the panel still shows live RPM and temperature — it just displays a setup notice instead of the controls.
To undo everything, see Removing it.
Requirements
| Needs | Why |
|---|---|
An Apple SMC laptop with the applesmc kernel module loaded |
The widget reads and writes /sys/devices/platform/applesmc.*/fan* |
Omarchy Quattro (omarchy-shell) |
The plugin is a Quickshell bar widget |
wheel group membership |
Both privilege rules are granted to wheel |
python3 |
bin/fanstate, which is how the saved mode is read and written |
mbpfan is optional. If it is installed and running, auto mode hands the
fans back to it; if it is not, auto clears the SMC's forced-mode bit instead.
Nothing else is pulled in — bin/fanctl is a shell script, bin/fanstate is
a standard-library-only Python script beside it, and the plugin bundles no
third-party code. Without python3 the fans still work from the panel and the
CLI; only the saved mode is lost, so restore and the suspend heal have
nothing to act on.
Removing it
Give back the privileges first, then remove the plugin:
sudo ~/.config/omarchy/plugins/io.github.moerdowo.fan/uninstall-privileges.sh
omarchy plugin remove io.github.moerdowo.fan --yes
The first command deletes the udev and polkit rules the setup step wrote,
reloads udev, restores root-only ownership on the fan attributes this session
already relaxed, starts mbpfan.service again so the fans go back under
automatic control, and deletes the saved mode in
~/.local/state/omarchy-fan/ (it prints a note if another account on the
machine has its own copy). The second removes the widget from the bar and
deletes ~/.config/omarchy/plugins/io.github.moerdowo.fan/. Nothing is left
outside those three locations — no units, no files on PATH. If you symlinked
fanctl into ~/.local/bin, delete that symlink too.
The saved mode is the one piece of state the plugin writes, and it is worth
clearing deliberately: a manual target left behind is a setting a future
install would find and could act on. fanctl forget removes it at any time
without touching the privilege rules.
Run the uninstall script before removing the plugin: it lives inside the plugin directory and goes away with it. If you removed the plugin first, the two rule files can be deleted by hand:
sudo rm -f /etc/udev/rules.d/90-applesmc-fan.rules /etc/polkit-1/rules.d/49-mbpfan.rules
sudo udevadm control --reload-rules
rm -rf ~/.local/state/omarchy-fan
Using it
The widget sits in the right bar section.
| Interaction | Effect |
|---|---|
| left click | open the panel |
| right click | toggle max ↔ auto |
| scroll | ±5% |
In the panel: Auto / Manual / Max picks the mode, the slider sets the
speed, and the readout shows each fan's real RPM plus CPU package
temperature. j/k move between sections, h/l adjust, Enter
activates, Esc closes.
The bar glyph is tinted whenever the fans are held off their thermal curve, so a manual setting you forgot about is visible at a glance.
0% is not "off". Percent spans each fan's min..max range — 2160–5927
RPM on the left fan, 2000–5489 on the right. 0% is the slowest the hardware
will run.
CLI
The panel shells out to bin/fanctl for everything, so the same control is
available from a terminal:
fanctl status # JSON: mode, percent, per-fan RPM, CPU temp
fanctl set 60 # hold both fans at 60% of their range
fanctl max # 5927 / 5489 RPM
fanctl auto # hand back to mbpfan (or to the SMC)
fanctl restore # re-apply the saved mode
fanctl forget # drop the saved mode and go back to auto
set, max and auto are you asking for something, and are obeyed as asked.
restore and the panel's status --heal re-apply a setting you are not
present for, so they are gated — see Suspend and
Safety.
Symlink it onto your PATH if you want it there:
ln -s ~/.config/omarchy/plugins/io.github.moerdowo.fan/bin/fanctl ~/.local/bin/fanctl
Suspend
The SMC drops out of forced mode across a suspend, so a manual setting is
lost on resume. Rather than install a sleep hook, the panel's status poll
calls fanctl status --heal, which notices the drift and re-applies the
saved mode. Worst case the fans idle for one poll interval after you open
the lid.
That heal runs unattended on a timer, so it is deliberately timid. It will not act when:
- mbpfan is running. Something with a temperature reading already has the fans; stopping it to reinstate a manual target would remove the only thermal supervision on the machine without anyone asking.
- the saved target predates the current boot. A target is stamped with the
boot it was set in. Anything older — including a mode left over from a
previous install of this plugin — is a stale wish, not the state the machine
is expected to be in, and only an explicit
fanctl restorewill act on it. - the CPU is at or above the failsafe temperature. See below.
Safety
Manual mode means nothing is watching the temperature — not the SMC, not mbpfan. Holding the fans at 0% under a sustained load is a real way to cook the machine. The CPU package temperature is shown in the panel for that reason. Auto is the safe default; use manual for a known workload and put it back afterwards.
Because that is true, anything that puts the fans into manual mode without
you asking right then has a temperature failsafe. If the CPU package is at or
above 85 °C, neither status --heal nor restore will hold the fans below
maximum: they hand the fans back to mbpfan (or to the SMC), and the panel says
THERMAL FAILSAFE with the bar glyph tinted until you pick a mode. The
threshold is FANCTL_FAILSAFE_TEMP in the environment if you want a different
one.
The failsafe does not second-guess a mode you just chose — fanctl set 0
and the panel slider do what you tell them, hot or not. It only governs the
unattended paths.
The kernel's own thermal throttling still applies regardless of fan mode.
The saved mode
~/.local/state/omarchy-fan/mode is the only thing the plugin writes, and
--heal and restore decide from it whether to stop mbpfan and hold the fans
unattended — so it is read as something another process running as you could
have got to first. Checking a path and then opening it is two operations
against a name, and between them the name can be made to mean something
else: the file replaced, or any directory on the way to it. Mode 0700 does not
answer that; it keeps other accounts out, and the process doing the swapping
would be this one.
The shell cannot close that gap — it has no redirection that opens
O_NOFOLLOW or O_NONBLOCK, and no openat, renameat or unlinkat. So
the filesystem work lives in bin/fanstate, which fanctl starts once and
keeps alive for the run. It walks the path a component at a time, each step an
openat on the descriptor of the one before it and each descriptor validated
by fstat, and then reads, writes, replaces and deletes relative to the
descriptor that walk ended on. The directory is never named again, so a swap
between two operations reaches a name nothing is looking at. Files open
O_NOFOLLOW | O_NONBLOCK and are checked on the descriptor open returned, so
a symlink or a FIFO left at the name yields nothing rather than the wrong
bytes or a stalled panel; writes land by renameat from an O_EXCL
temporary. fanctl then acts on the result only if it reads as exactly auto
or manual: and a percentage in range.
A path component that anyone but you can write to is refused rather than used,
so a group- or world-writable $XDG_STATE_HOME means no saved mode — the
panel and the CLI still work, restore and the heal simply have nothing to
act on.