Sail · field notes

How macOS 27 broke charge limiters, and the mechanism that still works

Every battery limiter for Apple Silicon used to flip a handful of SMC keys. Current firmware has removed or locked all of them. The manual charge limit built into powerd is the one path left, and it turns out to accept any value, not just the 80–100 % System Settings shows.

October 2026 · measured on a MacBook Air (M3), macOS 27.0.1, firmware 20457.1.29

I wanted two things from a charge limiter: sailing mode (charge to 70, hold, do not top up again until the battery drains to 60) and heat protection (pause charging while the battery is hot). After the macOS 27 update the limiter I was using stopped holding its limit, so I went looking for what actually controls charging on current firmware. This is what I found, and what Sail is built on.

1. The SMC keys are gone

For years limiters wrote to the SMC: CH0B/CH0C to stop charging, CH0I to cut wall power, later CHTE on newer chips, and BCLM on Intel. A read-only enumeration of the SMC on firmware 20457 shows:

KeyResult
CH0B CH0C CHTE CH0I BCLM CHWAAbsent (SMC result 0x84, key not found)
bfF0 bfD0 bfE0 CH0J CHLS (firmware limit)Present, but every access returns kIOReturnNotPrivileged, even as root. Gated behind a private entitlement.
CHIE (disable adapter input)Present and writable by root, but the Mac then runs from the battery and keeps draining through sleep. A blunt instrument.
TB0T TB1T TB2T (battery thermistors)Readable by any user, °C as float. The only temperature source: the IORegistry battery entry exposes none.

So the two classic approaches, "stop charging" and "write a firmware limit", are both closed to third parties. What is left is whatever Apple's own UI uses.

2. The native limit lives in powerd

macOS 26.4 added a manual charge limit to System Settings → Battery. Behind it is a private framework, PowerUI.framework, with a class PowerUISmartChargeClient that talks over XPC to PowerUIAgent (root). The useful selectors, found with the Objective-C runtime:

enableMCL:   setMCLLimit:error:   getMCLLimitWithError:   isMCLCurrentlyEnabled:
isMCLSupported   smartChargingUIState:chargeLimit:chargingOverrideAllowed:withError:

Two facts matter. First, the XPC entry point works from an ordinary user process with no entitlements. Second, it validates the value: anything from 80 to 100 is accepted (not only the five steps the UI shows), anything below 80 is rejected with error code 4.

powerd persists the result in /Library/Preferences/com.apple.powerd.charging.plist as a ChargeCtrlPolicy owned by PowerUIAgent, which is why the limit outlives the client and survives sleep: firmware enforces it, not the app.

3. Below 80 % the floor is only in the front door

PowerUIAgent keeps its own state in root's preferences domain com.apple.smartcharging.topoffprotection. The limit is the key mclLimitValue. Write it as root and post the Darwin notification that PowerUIAgent listens for, and the 80 % floor disappears:

defaults write com.apple.smartcharging.topoffprotection mclLimitValue -int 60
notifyutil -p com.apple.smartcharging.defaultschanged

Within seconds getMCLLimit reports 60 and the policy follows. This is also how the current version of the limiter I had been using reaches values below 80: its root helper writes exactly these preferences.

4. What a limit actually does

I expected "limit" to mean "stop charging here". It means more: powerd steers the battery to the value, in both directions.

Battery vs limitBehaviourSignals
belowcharges, stops at the limituiState 13; at the limit NotChargingReason = 0x01000000
atholds, amperage 0uiState 14
abovedischarges to the limit (−600 mA, adapter input drops to ~0.3 W)uiState 16

Measured on the reference machine: 75 → 80 charged and stopped cleanly; 82 with limit 80 drained; 82 with limit 82 held; as root, 79 → 78 drained and 78 → 79 charged back up. One primitive, set limit n, covers charging, holding and discharging for any n in 1…100.

The consequence for a controller: never set a value below the current charge unless you want a discharge. "Hold" is limit = current percent.

5. Sailing mode on top of this

phase charging : limit = upper          # powerd stops at upper
   percent >= upper          -> holding
phase holding  : limit = percent        # follow the battery down, never top up
   percent <= lower          -> charging
heat hold      : limit = percent while battery temp >= Tmax (hysteresis on the way back)
safety floor   : below 20 % always charge

The app is a desired-state loop: every 30 s and on every power event it samples the battery, asks this policy for the limit, reads back what powerd enforces, and writes only on a difference. It only has to be awake at phase transitions; between them firmware does the work.

Sail menu with Sailing mode and Heat protection sliders Sail setup window with compatibility checks and self-test

6. What it cannot do

Try it

Sail is free and open source (GPL-3.0), Swift, no dependencies. Apple Silicon, macOS 26.4 or newer.

Download the DMG Source on GitHub
brew install --cask jiraffekiller/tap/sail-battery

The full design document with every measurement is in the repository: docs/DESIGN.md.