Skip to content

fix: show custom profiles in the menu bar and CLI (#28) - #47

Open
sammcj wants to merge 2 commits into
ProducerGuy:mainfrom
sammcj:fix/curve-floor-and-custom-profiles
Open

sammcj wants to merge 2 commits into
ProducerGuy:mainfrom
sammcj:fix/curve-floor-and-custom-profiles

Conversation

@sammcj

@sammcj sammcj commented Sep 1, 2026

Copy link
Copy Markdown

What

FanProfile.loadAll() reads custom profiles from ~/Library/Application Support/ThermalForge/profiles, but nothing calls it. MenuBarView and watch both use FanProfile.builtIn, so a saved custom profile never appears. Second half of #28; the install self-deletion half is already fixed on main.

Behaviour change

None on a machine with no custom profile files, except one thing.

Curve.targetPercent is byte-identical to main. Tick loop, ramp governors and daemon untouched. Ordering sorts by maxRPMPercent ascending, reproducing the built-in order (0, 0.60, 0.85, 1.0); a test asserts orderedByCeiling(builtIn) == builtIn.

The exception: watch --profile smart used to throw a ValidationError because Smart isn't in builtIn. It now works.

Changes

  • AppState publishes availableProfiles, read once at launch. The picker rebuilds on every status update, so it can't touch the filesystem.
  • Launch restore resolves the saved id against that list, so a custom profile comes back instead of falling back to Silent.
  • loadAll(from:) and save(to:) take a directory. The existing tests wrote into the real Application Support directory and deleted afterwards, destroying a user's own balanced.json.
  • The profiles directory resolves through SUDO_UID when running as root, as install already does, cross-checked against the uid. Without it the root CLI reads /var/root and finds nothing.
  • Profiles from disk are screened by Curve.validationError, the same way CalibrationData is screened on load. Hand-editing JSON is the only way to make one today and nothing validated it. Unreadable files are logged instead of disappearing.
  • silent and smart are reserved ids because the code branches on both. A custom smart.json would have most of its curve ignored by Smart's adaptive path; a custom silent.json would show one profile in the picker while the monitor ran another.
  • Ordering, so a profile quieter than Balanced doesn't sort after Max. Most separable commit if you'd rather decide ordering yourself.

Safety

The daemon speaks only RPM and knows nothing about profiles, so the 95°C override and heartbeat watchdog cover custom profiles the same as built-ins. Validation rejects curves that engage above the safety threshold or set alwaysOn, and enforces the 5°C hysteresis rule so a hand-written file can't cycle the fans on sensor noise.

Overlap with open PRs

#44 replaces this Picker with Button rows. The two changes are orthogonal: #44 changes the control, this changes what the rows are built from. Whichever lands first, the other is a small rebase, and I'm happy to do that rebase rather than make you resolve it.

Two notes

#46 proposes a settings UI covering custom curves, per-profile enable/disable and moving Smart inline, and someone has offered to prototype it. This PR doesn't prejudge that; it surfaces the JSON loadAll() already reads. If #46 lands, the ordering here is the first thing to redo.

The README sells against tools that require manual configuration, and this adds a section on hand-writing JSON. loadAll(), save() and the profiles directory are already yours; this only makes them reachable. Drop the README commit if you disagree, the code stands without it.

Testing

61 tests, swift build and swift test clean. Every filesystem test uses a UUID scratch directory under temporaryDirectory, so the suite no longer touches the real profiles directory.

FanProfile.loadAll() reads custom profiles from
~/Library/Application Support/ThermalForge/profiles, but nothing called it. The
menu bar picker and `watch` both used FanProfile.builtIn, so a saved custom
profile was never offered anywhere. Issue ProducerGuy#28 reported this; the install
self-deletion half was fixed, this half wasn't.

- AppState publishes availableProfiles, read once at launch. The picker is
  rebuilt on every status update, so it must not hit the filesystem.
- Launch restore resolves the saved id against that list via
  selectable(id:from:), so a custom profile comes back instead of falling back
  to Silent.
- `watch` accepts any custom profile id, and lists what's available on error.
  It also now accepts `smart`, which it rejected before because Smart isn't in
  builtIn -- a small CLI addition rather than a side effect.
- loadAll(from:) and save(to:) take a directory. Tests used to write into the
  real Application Support directory and delete afterwards, which would destroy
  a user's own balanced.json when running the suite.
- Running with elevated privileges makes the effective user root, whose home is
  /var/root, so the profiles directory resolves through SUDO_UID (as `install`
  already does) and cross-checks the resolved account. Without it the root CLI
  looks in the wrong home and finds nothing.

Profiles loaded from disk are screened before being offered, the same way
CalibrationData is screened on load, since hand-editing the JSON is the only way
to make one today and nothing else validated it. Unreadable and undecodable
files are logged rather than vanishing. `silent` and `smart` are reserved
because the code branches on both by id: a custom smart.json would have most of
its curve ignored by Smart's adaptive path, and a custom silent.json would leave
the picker showing one profile while the monitor ran another.

Ordering: loadAll appended, so a profile quieter than Balanced landed after Max
and read as the most aggressive entry. Sorting by maxRPMPercent ascending
reproduces the built-in order exactly (0, 0.60, 0.85, 1.0), so anyone with no
custom profiles sees the list unchanged -- there's a test asserting that.

No change to the curve maths, the tick loop, or the daemon. Curve.targetPercent
is untouched, and the daemon speaks only RPM, so the 95C override and the
heartbeat watchdog cover custom profiles exactly as they cover the built-ins.
Covers the directory, the JSON shape, what makes a curve valid, and that the
daemon safety override applies to custom profiles unchanged. The example
overrides the built-in Balanced rather than inventing a new profile name.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant