A small, native macOS menu bar app that shows how much power your Mac is drawing from
its battery right now, how long the battery will last at that rate, and which of your
processes are burning the most energy. While the battery charges it shows the charging
power and its own time to full instead. Both estimates start from the current value and
get steadier the longer the app runs. While no charger or other source is connected it
also tells how long the Mac has been running on battery since it was last unplugged. The menu bar item is drawn as a battery whose fill
follows the charge level, with the figures inside it, so it can stand in for Apple's own
battery item. Its click menu also carries a few settings: keep the Mac, its display or its
disk awake while Restwatt runs, keep the Mac awake with the lid closed, switch Apple's
Energy Mode (Automatic, Low Power, High Power) for the current power source, and start or
stop iCloud Drive, iCloud Photos and OneDrive syncing. An Open at Login item lets
Restwatt start again by itself after a restart or a new login.
Pure Swift on AppKit, IOKit, libproc and ServiceManagement. No Electron, no web view, no dependencies, no network. It writes two small files: its settings and a statistics file that lets the estimate and a per-process energy total for the day survive a restart (see "How the estimate works" and "Privacy").
Both screenshots are from 0.1.0, before the battery-shaped item, the Energy Mode
group, the Open at Login item and the On battery for rows were added.
Menu bar title while the battery is draining: the current draw in watts and the
smoothed time to empty as h:mm, for example 7.9 W 8:12. Until the first gauge
reading arrives it shows --:--. The state is decided by the direction of the energy
flow through the battery, not by whether something is plugged in:
- Energy leaves the battery and no external source is connected:
7.9 W 8:12. - Energy leaves the battery although an external source is connected (a power bank or a
small adapter that delivers less than the Mac uses): the same figures plus the marker
weak source, for example2.3 W 27:47 weak source. The watts are what the battery still supplies on top of the source, not the whole system draw. - Energy enters the battery:
Chargingplus Restwatt's own smoothed time to full, for exampleCharging 0:22; justCharginguntil the first estimate exists. - An external source is connected and the net flow is inside a small dead band around
zero:
On AC, orOn AC 100 %once the gauge reports the battery as fully charged. - The source was just plugged in or unplugged and the gauge has not caught up yet: only
the charge level, for example
95 %(see "Which state is shown" below).
Without a battery it reads No battery.
Battery shape. While a battery is readable, the item is not plain text but a drawn
image in the shape of a battery: a rounded outline with a small nub on the right, a fill
that starts at the left edge and grows with the charge level (a charge level outside 0 to
100 is clamped), and the text above inside the outline, unchanged. No percentage figure is
added; the fill is the level. Outline and text take the label colour of the menu bar's
current appearance, light or dark; the fill is translucent so the text over it stays
readable, and its colour follows the convention of Apple's own item: red at 20 % or less
while the battery drains (on battery or with a weak source), green while charging or when
the battery is fully charged on external power, yellow while Low Power Mode is in effect
(the state macOS reports to the process, not the row marked in the menu), and the label
colour otherwise (draining above that level, on AC and not charging, a plug event waiting
for the gauge). The image is drawn again on every sample, whenever the item's appearance
changes and whenever Low Power Mode goes on or off; it is not a template image, so macOS
does not recolour it. The button keeps the text as its accessibility label, so VoiceOver
reads 7.9 W 8:12 as before. Without a battery the item stays plain text. The geometry
(fill width from the percent, outline, nub, text position) and the colour rule are pure
logic in RestwattCore (BatteryGlyph) with unit tests; only the AppKit drawing is app
code, so the look itself (contrast, vertical position in the menu bar, whether the redraw
on an appearance change fires promptly) has no automated test and is judged by eye; the
next sample redraws the item in any case.
With the level shown in Restwatt's item you can hide Apple's battery item: System
Settings > Control Center > Battery, turn off Show in Menu Bar. Apple's battery menu
also carries the Energy Mode switch; Restwatt's click menu has the same switch (see
"What it can do"), so nothing is lost by hiding it. Restwatt does not change that setting
itself.
Mouseover on the item opens a popover with the details, without a click. It appears
after a short delay while the pointer rests on the item and closes shortly after the
pointer leaves both the item and the popover (the delays are the showDelay and
hideDelay constants in Sources/RestwattApp/StatusItemController.swift). The popover
is a two-column label/value grid in the normal label colour, so it follows a light or
dark menu bar; the numbers use monospaced digits, and the figures to read at a glance,
the current draw, both time-left values and, on battery, the time since the unplug, are
set larger and bolder:
Battery 95 %, 63.0 Wh
Drawing now 7.1 W
Time left at current draw 8:49
Time left, smoothed 8:12
Smoothing 42 min observed, confidence high
macOS estimate 7:46
On battery for 0:31
Since 12:17, from 100 %
Top processes (your processes, CPU energy only, estimate)
Discord Helper (Renderer) (2 processes) 0.42 W
com.apple.WebKit.WebContent 0.02 W
Restwatt 0.01 W
Visible total 0.63 W over 105 processes, unaccounted 6.51 W
Today (your processes, CPU energy only, estimate)
Discord Helper (Renderer) 1.02 Wh
node 310 mWh
Restwatt 12 mWh
Total today 1.74 Wh over 4:12 sampled
The Today block under the live list is the same per-process picture summed over the
current calendar day: the CPU energy attributed to each process name since local midnight,
in watt-hours (whole milliwatt-hours below one watt-hour, x.xx Wh from there), the
three largest names in the popover, and a Total today line with the energy of every
counted name and how long was sampled that day as h:mm. It survives a restart of the
app (see "How the estimate works"), so on a launch later in the day it is there from the
first tick, next to a live list that is still collecting its first interval. The block is
absent until the first interval of the day has been sampled.
On battery for answers "how long have we been off power": the wall-clock time since the
last external source was disconnected, sleep included, as h:mm. Since under it names
the clock time of the unplug (24-hour HH:mm in the Mac's time zone, yesterday 22:30
for the day before, 2026-09-20 22:30 for anything older) and the charge at that
moment, for example 12:17, from 100 %. The two rows follow macOS estimate in the popover and in the click menu, and
follow the Power row while a plug event waits for the gauge. They are absent while any
external source is connected, a weak source that still drains the battery included, and
nothing like them is shown on AC: the time does not count "since plugged in". The menu bar
title is unchanged. The duration is rounded down to whole minutes, never up, is never
negative, is capped at > 99 h, and is as of the latest sample, like every other
row.
When Restwatt does not know the exact moment, it shows an honest lower bound instead of a
guess: On battery for at least 0:05 and Since 14:05 or earlier, without a charge
(the charge at the real unplug is unknown). That happens after a launch on battery until
the power log has been read, and whenever the log cannot settle it (see "Where the unplug
time comes from" below). Restwatt never shows a time it neither watched nor read from the
log, and never a remembered time the log contradicts or this session's first battery
sample refutes; what it cannot see at all is
listed under "What it cannot know".
With a weak source the same rows appear, Drawing now being what the battery still
supplies, followed by a Power source row that says connected, but it delivers less than the Mac uses. While charging the rows are Charging at, Time to full at current power, Time to full, smoothed with the same Smoothing note, and macOS estimate
for the gauge's own time to full (or not yet available when the gauge does not know
one):
Battery 75 %, 51.7 Wh
Charging at 49.1 W
Time to full at current power 0:22
Time to full, smoothed 0:22
Smoothing 0 min observed, confidence low
macOS estimate 0:50
Source rating 96 W
Source rating is the rated power of the connected source as the gauge reports it
(AdapterDetails.Watts); the row appears in every state with a source connected, except
while the source is changing, and is omitted when the gauge does not report a rating. On
AC power inside the dead band a single Power row says On AC, fully charged or
On AC, not charging; while the source is changing it says power source changed, waiting for the gauge. Before the first estimate the time row reads waiting for the first gauge reading.
The popover does not take focus away from the app you are working in and is rebuilt from
the latest sample whenever it is shown or a new sample arrives while it is visible. It
closes when the click menu opens. The menu bar item is hosted by the system, not by a
window the app owns, so Restwatt cannot be told directly when the pointer enters it.
Instead it watches pointer movement system-wide and checks each position against the
item's current frame; only the resulting enter and leave transitions drive the popover.
The rows and the enter/leave logic (PointerRegionTracker) come from the unit-tested
RestwattCore layer; showing and hiding the popover itself is AppKit code without an
automated test, so please open an issue if the popover does not appear or does not go
away for you.
Click on the item opens a menu with the same rows, the top five processes, the five
largest Today names with the total, the settings section described under "What it can
do", the version number and
Quit Restwatt. The information lines are display-only: they are drawn in the normal
label colour but do not highlight under the pointer, a click on them does nothing and
leaves the menu open, and keyboard navigation skips them; only the checkmark items and
Quit Restwatt respond. The menu is rebuilt from the latest sample each time it opens,
and Cmd-Q inside the menu quits.
The click menu ends with a settings section: three headings with checkmark items under
them. It replaces two shell scripts that used to switch the same things by hand, and the
Energy Mode group of Apple's battery menu. Every
checkmark shows the state Restwatt reads from the system when the menu opens and again
after each click (the assertion it holds, pmset -g, pmset -g cap, pmset -g custom,
launchctl print, the running
applications), never a stored intention: a click that did not take effect leaves the
checkmark where it was and puts the reason in an indented line under the item.
Power
[x] Keep awake
[ ] Keep display awake
[ ] Keep disk awake
[x] Stay awake with the lid closed
system-wide, needs administrator, restored when Restwatt quits
Energy Mode
Power Source: Battery
[ ] Automatic powermode 0
[x] Low Power powermode 1
[ ] High Power powermode 2
Sync
[x] iCloud Drive running
[x] iCloud Photos idle, starts on demand
[ ] OneDrive not running
Keep awake, Keep display awake and Keep disk awake are process-scoped. Each
holds one IOKit power assertion in Restwatt's own process (PreventUserIdleSystemSleep,
PreventUserIdleDisplaySleep, PreventDiskIdle, the mechanism caffeinate uses),
needs no privileges, and appears in pmset -g assertions as Restwatt: Keep awake and
so on while it is held. Turning a toggle off releases the assertion; quitting or killing
Restwatt releases every assertion with the process, so these three cannot outlive the
app. The choice is remembered in the settings file and re-acquired at the next launch.
If the system refuses one of them at launch, that toggle shows off with the reason under
it, the other remembered toggles are acquired all the same, and the choice stays in the
file: the checkmark follows what Restwatt holds, the file keeps what you chose, and the
next launch tries again without a click. Turning the toggle off is what changes the file;
a refused click from off changes nothing, so no file appears for it.
Keep awake prevents idle sleep only: closing the lid still puts the Mac to sleep. If
you run a LaunchAgent of your own that keeps caffeinate -d alive, Keep display awake
is redundant with it; Restwatt leaves such an agent alone and neither loads nor unloads
it.
Stay awake with the lid closed is different: it is a system setting, it needs administrator rights, and it survives the app. Turning it on runs
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 standby 0 disablesleep 1
and turning it off runs, in this order,
pmset -a disablesleep 0
pmset -b displaysleep 2 sleep 10 disksleep 10 hibernatemode 3 standby 1
pmset -c displaysleep 10 sleep 30 disksleep 10 hibernatemode 3 standby 1
pmset -a standbydelaylow 10800 standbydelayhigh 86400
The "off" values are one chosen saver profile (the one the replaced script wrote), not a
captured factory default and not whatever your Mac had before: Restwatt does not save or
restore earlier pmset values. Both command lines are constants in RestwattCore and
the unit tests pin them word for word, so the text above cannot drift from the code. To
run pmset as root, Restwatt tries sudo -n pmset ... for each call of the profile in
turn, which succeeds only when sudo needs no password for your account and fails at once
otherwise. Restwatt tells the two ways a call can fail apart: sudo refusing to run
without a password (sudo -n exits with status 1 and reports a password is required or
a terminal is required on its own stderr) is a denial; any other failure is pmset
itself, which ran as root and refused its arguments, for example a key this hardware does
not support. A denial, and any other failure sudo reports on its own account (exit
status 1 with a line prefixed sudo: on stderr), opens the native macOS administrator
dialog (osascript, do shell script ... with administrator privileges), once per
profile and only for the calls sudo did not get to run, chained into that single dialog;
the calls that already succeeded are not run again, and when the dialog is declined the
toggle names the sudo -n command line that actually ran together with the dialog's
answer. A pmset failure (its own pmset: message, an empty stderr or any other exit
status) stops the profile at that call, opens no dialog, re-runs nothing, and puts the
failing command line, its exit status and its own message under the toggle; the calls
before it stay applied. Nothing but pmset with these
fixed arguments and the six fixed Energy Mode lines below ever runs with administrator
rights, no command line is built from user
data, and Restwatt never creates, edits or recommends a rule that lets sudo skip the
password. Cancelling the dialog, or any other failure, leaves the checkmark off, as
observed, with the reason under the item. The denial texts are sudo's documented wording;
the unit tests pin them, a live denial was not recorded during development.
Two safety facts, stated plainly:
- The lid-closed setting outlives the app, so Restwatt takes it back. The menu says
so under the toggle. The record comes first: when you turn the toggle on, Restwatt
writes "armed by Restwatt" into its settings file before
pmsetruns as root, and if that file cannot be saved the awake profile is not written at all and the toggle says so (not written, the settings file could not be saved). So there is never adisablesleep 1of Restwatt's without a record of it, whatever happens in between. When Restwatt quits normally (Quit Restwattor Cmd-Q) while it had turned the setting on, it writes the saver profile first (on a Mac where sudo asks for a password that is one administrator dialog at quit; cancelling it leaves the setting on, and Restwatt keeps the record, so it still owes the reset). A crash, akillor a Force Quit skips that quit step: the Mac stays unable to sleep until Restwatt runs again, and the reset happens at that next launch. At every launch Restwatt reads its record andpmset -gand handles the four combinations: record on andSleepDisabled 1is Restwatt's own setting, so it writes the saver profile then; record on andSleepDisabled 0means the write never landed (the record was saved, then the dialog was cancelled or the app died beforepmsetran) or somebody else already reset it, so the record is dropped quietly with no write; no record andSleepDisabled 1is somebody else's setting and is left alone; no record andSleepDisabled 0is nothing to do. The same quiet drop happens after a click whose write did not land, so a cancelled dialog does not leave a stale record behind: the record a click wrote ahead follows the outcome of that write, and when no call of the awake profile ran as root it is dropped at once, even whenpmset -gcannot be read right after, so Restwatt owes no reset and writes nothing at quit. Only when at least one call landed does the record stay, and quit or the next launch takes the setting back. That launch write does not depend on anything else Restwatt does at launch; a power assertion the system refuses to re-acquire does not skip it. When a record from an earlier run is on butpmset -gcannot be read, the record stays and the quit reconcile tries again. Restwatt only ever takes back what it set itself. ASleepDisabled 1that another tool or script set is left alone and shown as on with the noteset outside Restwatt; turning that toggle off by hand writes the saver profile all the same. The record, not the last writer, decides: if you turn the toggle on in Restwatt and then run your ownpmsetscript on top, Restwatt still writes the saver profile at quit. So the toggle effectively means "while Restwatt runs"; for a Mac that stays awake without Restwatt, usepmsetyourself. - Sync off stays off until something turns it on. The same holds for the Energy
Mode, see below.
iCloud Drive(com.apple.bird) andiCloud Photos(com.apple.cloudphotod) are switched withlaunchctl bootstrappluskickstartand withlaunchctl bootoutin your login session domain (gui/<uid>);OneDriveis started hidden and without activating it through LaunchServices (whatopen -gja OneDrivedoes) and stopped by the quit request the running application receives. These are immediate actions on the running system. Restwatt does not store the sync choice and does not touch sync at launch or quit, so once Restwatt is gone the services stay in whatever state they were left in. The menu shows that state to the right of each item (running,idle, starts on demand,off,not running), read fromlaunchctl printand the list of running applications. To get sync back, click the item again or runlaunchctl bootstrapyourself; launchd is also expected to load the two system agents again at the next login, but that comes from thelaunchctlmanual and was not measured.bootoutdoes not disable a service, so a reboot or re-login does not keep sync off either.
Energy Mode is the switch from Apple's battery menu (Automatic, Low Power, High
Power), for the power source the Mac is on right now. The group names that source the way
Apple's menu does (Power Source: Battery or Power Source: AC) and marks the row whose
value pmset -g custom reports for it; the source itself comes from the header of
pmset -g cap. Both are read when the menu opens and again after each click; nothing is
stored in the settings file, nothing is armed, and nothing is reset when Restwatt quits,
because the mode is a setting Apple exposes to the user and is meant to outlive any app.
The mode of the other source is neither shown nor changed, as in Apple's menu. High Power is offered only when pmset -g cap lists highpowermode for the current source
or the value read is already 2, so a Mac without it does not get a row that pmset
would refuse; on the development Mac (an Apple silicon MacBook Pro with High Power) it
is listed, whether it is absent on a Mac without High Power was not measured. When the
current source has no powermode line the row Automatic is marked and a note under the
group says pmset lists no powermode for Battery Power, read as Automatic; a value
outside 0, 1 and 2 marks nothing and a note says powermode 7 is not a known energy mode. When the source cannot be read (pmset -g cap failed or names a source Restwatt
does not address, such as UPS power) the group says Power Source: unknown, marks
nothing, names the reason, and a click is refused without running anything as root
(not written while the power source could not be read).
Clicking an unmarked row runs exactly one of these six fixed commands as administrator,
through the same path as Stay awake with the lid closed (sudo -n first, the
administrator dialog only when sudo refuses to run without a password, a pmset failure
shown under the row without a dialog; see above for how the two are told apart):
pmset -b lowpowermode 0
pmset -b lowpowermode 1
pmset -b lowpowermode 2
pmset -c lowpowermode 0
pmset -c lowpowermode 1
pmset -c lowpowermode 2
-b is used on battery and -c on AC; never -a, so only the current source changes.
Clicking the marked row runs nothing. Success is the value read back, not the exit
status: after the write Restwatt reads pmset -g custom again, and when the current
source still shows another value the row says so, for example could not change: pmset accepted lowpowermode 2 but reports powermode 1 for Battery Power, and the checkmark
stays on the observed value. A pmset refusal reads could not change: /usr/bin/pmset -b lowpowermode 2 exit 1: followed by pmset's own message; a re-read that fails after the
write is reported as such (written, but the energy mode could not be re-read). The six
command lines are constants in RestwattCore and the unit tests pin them word for word.
About the values: pmset reports the mode as powermode, while the key it accepts for
writing is lowpowermode (the key pmset -g cap lists and outside documentation uses);
the app writes lowpowermode and reads powermode. On the development Mac the value
1 = Low Power was confirmed against Apple's own menu (pmset -g custom showed
powermode 1 for Battery Power while Apple's menu had Low Power selected on battery);
0 = Automatic and 2 = High Power are what that Mac reports and what outside documentation
says, but were not confirmed against Apple's menu there, and no write with
lowpowermode was run on that Mac during development. Until that is confirmed each row
shows the raw value it reads or writes (powermode 0, powermode 1, powermode 2)
next to its label, so a wrong assumption would be visible rather than silent: a write
that does not land shows up under the row, and a mapping other than assumed shows up as
a checkmark on a row whose raw value you can compare with pmset -g custom yourself.
A few practical notes. Starting OneDrive and asking it to quit both return before the
application has finished, so right after the click the checkmark can still show the old
state; it is read again when the menu opens next. Whether a launchctl call succeeded is
judged by the state read back afterwards, not by its exit code. A job that launchd is
still starting or stopping can show in neither end state right after the calls, so
Restwatt reads launchctl print again every 0.2 seconds until 2 seconds have passed and counts
the click as done as soon as the wanted state shows. The wait runs on the main thread, so
Restwatt does not respond during it. Turning a service off only counts when launchd no
longer lists it; a state that could not be read is not taken as off. When the wanted
state has not shown by the end of the wait, the reason line names each call that failed
with its exit code and message (could not change: launchctl bootout exit 5: Boot-out failed: 5: Input/output error), or, when every call succeeded, the state launchd
reported (could not change: launchctl succeeded, but launchd reports it running after 2 s). A state line from launchctl print that Restwatt does not know is quoted as it
is, both in that reason (no known state (launchctl print reports state = ...)) and
next to the item (state unknown: launchctl print reports state = ...).
A reason line under a sync item, under Stay awake with the lid closed after a failed
turn-on, or under an Energy Mode row disappears as soon as a later read (when the menu
opens, or after a click on one of these settings) shows the state the failed click wanted, for example a service
that finished starting only after the wait. For the Energy Mode the mode has to show for
the power source the click wrote. Every other reason line stays until a later click on
that item succeeds: a failed turn-off of Stay awake with the lid closed (the saver
profile writes more settings than the SleepDisabled value read back can confirm), a
click refused because a read failed, and a refused keep-awake assertion.
If the settings file cannot be written, the action still happens and a line at the
bottom of the section says settings could not be saved with the reason; the one
exception is turning Stay awake with the lid closed on, which needs its record saved first and is refused otherwise
(see safety fact 1). If pmset -g cannot be read, the lid-closed toggle shows off with
could not read pmset and the reason, and nothing is written at launch. Clicking it in
that state refuses to write the awake profile (not written, could not read pmset plus
the reason), because a setting written blind could never be switched off from the menu
again; only when Restwatt's own record says it turned the setting on does the click write
the saver profile, the safe direction. If OneDrive cannot be started under any of its
bundle identifiers, the reason shown is the one for the primary identifier
(com.microsoft.OneDrive-mac), not the last fallback's. The privileged path, the
launchctl switching and the OneDrive control were tested against doubles that pin the
exact commands and simulate the resulting system state; they were not exercised against a
live system during development, and clicking the items is not automated. Please open an
issue if a toggle misbehaves on your Mac.
Open at Login is its own small group below the settings section, above the version
and Quit Restwatt. It is off until you click it: Restwatt never adds itself as a login
item on its own. The wording is Apple's (System Settings > General > Login Items &
Extensions). The item uses Apple's login item service for the app itself
(SMAppService.mainApp), so the app is listed in that System Settings pane like any
other app that opens at login.
[x] Open at Login
The checkmark is the status macOS reports for the app, read each time the menu opens and again after a click; Restwatt stores nothing about it, neither in its settings file nor anywhere else. macOS reports one of four statuses:
- enabled: checked, the app opens at login; a click removes the login item.
- not registered, or not found: unchecked; a click adds the login item.
- requires approval: the item is registered, but macOS waits for you to allow it. The
item is unchecked and a note under it says
needs approval in System Settings, Login Items; a click does not register again but opens that pane of System Settings, where you allow it.
If adding or removing the login item fails, the reason macOS gives appears in an indented
line under the item (could not change: followed by it) and the checkmark stays on the
status read back from the system, the same as for the toggles above. The line disappears once a later read shows a
different status, for example after you allowed the item in System Settings, or when a
later click succeeds.
Before you turn it on, put the app where it is going to stay, normally /Applications
(make install, see "Build and install"). macOS registers the app at the path it is
running from, so enabling the item from dist/Restwatt.app inside the repository
registers that copy. macOS may show a notification when a login item is added.
The status-to-checkmark rule, the click decision for each of the four statuses, a failed
register and a failed unregister are pure logic in RestwattCore (LoginItem.swift),
unit-tested against a double. The call into SMAppService itself is app code that is only
compiled, not tested: registering the real Restwatt was not exercised during development
(the service was checked with a separate throwaway app on the development Mac, where
register and unregister worked for an ad-hoc signed bundle), and the look of the item is
judged by eye.
Restwatt reads the battery gauge (AppleSmartBattery in the IOKit registry) every
30 seconds (Sampling.interval in Sources/RestwattCore/BatteryMonitor.swift), plus one
extra reading 5 seconds after the launch tick (Sampling.secondSampleDelay, see
"Footprint"). From each reading it derives:
- Draw in watts: the gauge's own
BatteryPowerfigure. If that figure disagrees with voltage times amperage by more than 10 %, voltage times amperage wins. - Remaining energy in Wh: remaining charge (mAh) times the present voltage. It is deliberately not derived from the percentage, because the gauge's full-charge capacity drifts between readings.
- Missing energy in Wh: full-charge capacity minus remaining charge, times the present voltage, never below zero (the full-charge capacity can briefly sit below the remaining charge while it drifts).
- Time left at current draw: remaining Wh divided by the current draw. This is the "if it keeps drawing like right now" figure.
- Time to full at current power: missing Wh divided by the current charging power, the same figure in the other direction.
The smoothed time uses an adaptive exponentially weighted moving average of the power flowing through the battery. One estimator serves both directions: while the battery drains it is fed with the draw and the remaining energy, while it charges with the charging power and the missing energy.
- The first sample is taken as is, so the first estimate equals the one at current power.
- Every later sample moves the smoothed power by a fraction that depends on a time constant. The time constant is half the observation window so far, clamped between 60 seconds and 1800 seconds (30 minutes). Early on, the estimate follows the power closely; after half an hour a single noisy gauge reading barely moves it, while a lasting change in your workload still shows up within a few multiples of 30 minutes.
- A confidence level is shown next to it:
lowbelow 5 minutes of observation,mediumbelow 30 minutes,highfrom 30 minutes on.
Duplicate gauge readings (same UpdateTime) are not fed into the estimator, so polling
more often than the gauge refreshes does not distort the average. The smoothing restarts
whenever the shown state changes: when the direction of the flow flips between draining
and charging, when a weak source is plugged into a Mac running on battery (the draw
changes meaning from "whole system" to "what the source does not cover"), and around a
plug or unplug event.
The estimate survives a restart of the app. For each of the three flow states that have an estimator (draining on battery, draining with a weak source, charging) Restwatt keeps the smoothed power, the observation window behind it (and with it the confidence and the time constant) and the sample count in its statistics file, together with the wall-clock time of the last tick that showed that state. The file is written at most once per tick and when the app quits (see "Footprint"). The first time a flow state is shown in a session, at launch or later (launch on AC, unplug ten minutes in), the estimator picks up where that state left off; later switches within the session start fresh, as described above. A fresh start does not erase the file, though: the remembered entry of a state is kept as long as resuming it right now would give a longer observation window than the fresh estimator carries, and the fresh estimator is written once its window is the longer. Plugging in for a minute and unplugging again therefore restarts the smoothing on screen, while the hour of observation stays in the file (and keeps ageing by the gap rule below) until the new run has observed more; in the test suite the remembered hour survives a two-minute flip and hands over after 30 minutes of fresh observation. Whether the remembered state is trusted is decided by one rule, each branch of which has a unit test:
- Direction. Only the entry of the state now shown is used. A remembered charging estimate is never applied to draining; it stays in the file for the next charge.
- Reboot. The file carries the boot time of the Mac (
kern.boottime). If the current boot time differs from it by more than a minute, every remembered estimate is discarded when the file is loaded, whichever state is shown: the workload after a reboot is a new one, and the first write of the new session no longer carries the entries of the old boot (a unit test loads two remembered states across a simulated reboot and finds neither of the old entries in the rewritten file; the resumed state reappears only as a fresh entry of the new session). When the boot time is unknown on either side, the gap alone decides. - Gap. The pause between the last remembered tick and now (wall clock) is subtracted
from the remembered observation window, second for second, starting from at most one
hour of observation (beyond an hour the time constant has stopped growing, so more is
not worth remembering). A window that ends at zero or below is discarded; a clock that
ran backwards discards it too. What remains is the smoothed power with the shortened
window, so a lower confidence and a smaller time constant. In the test suite: an hour
observed and a 45-minute pause leaves 15 minutes of observation and
mediumconfidence; a restart after 3 seconds keeps practically everything; a pause of an hour or more (quit, sleep, reboot) forgets the estimate even after a whole day of observation. - Resume. The first gauge sample after a resume only sets the current values and the
session's time anchor; it does not move the smoothed power, because no in-session
interval exists yet. From the second sample on the smoothing continues with the time
constant the shortened window implies. Until that first sample the time row reads
waiting for the first gauge reading, as on a cold start. TheSmoothingrow counts the remembered observation in its minutes; nothing marks it as remembered. - Plausibility. Only figures that could be measurements are read back. An entry is
dropped when a figure is not a number, negative or beyond a generous physical ceiling
(1000 W smoothed power, a year of observation and one sample per second over that
year for an estimator entry; 24000 Wh per figure and a week of sampled seconds for the
day's statistic), and so is an entry under a flow state Restwatt does not know. The
day is dropped as a whole when its key is not of the
YYYY-MM-DDshape or its own totals are absurd; a single absurd name is dropped alone. The ceilings and the drop rules are held by unit tests, and a test drives absurd values (negative, far beyond the ceilings, not a number, infinite) through every popover, menu and tooltip line to make sure nothing traps: what is not a measurement is shown as zero.
Inside a session the estimators run on the system uptime clock, which stands still
during sleep and restarts at boot; only the file carries wall-clock times. For the
estimate, sleep within a session is not detected. The On battery for rows are the one
place where a wall-clock gap between two samples matters: it decides whether an unplug
counts as watched (see "Where the unplug time comes from"). A missing or unreadable file,
or an entry with nonsensical values, means a cold start without any error message.
Below a power of 0.1 W no time is shown in either direction. Times above 5999 minutes
are shown as > 99 h.
With no external source connected the battery is draining, whatever the sign of the
gauge's current. With a source connected the net flow decides: a battery draw of 0.5 W
or more means weak source draining, a charging power of 0.5 W or more means charging,
anything in between is On AC and claims no power and no time. The dead band exists so
that a trickle around zero cannot flip the display back and forth; there is no further
hysteresis. The gauge's IsCharging flag does not override the sign, because a source
that delivers less than the Mac uses can leave the battery draining while the flags say
otherwise.
Plugging in or unplugging triggers an immediate re-sample, but the gauge's current and
power figures only change together with its UpdateTime, which can be up to a gauge
refresh later. A reading whose ExternalConnected flag flipped while its UpdateTime
stayed the same provably predates the change, so Restwatt shows only the charge level
(95 %, row power source changed, waiting for the gauge) until the gauge moves on,
instead of a stale direction (a fresh charger shown as weak source, or a negative draw
right after unplugging). A flip that arrives together with a new UpdateTime is trusted
as is.
No battery key in the IOKit registry and no IOPowerSources value carries the time of the last unplug, so Restwatt keeps it itself, from two sources.
Watched live. Every sample reads ExternalConnected. A sample with an external source
followed by one without, at most 60 seconds apart on the wall clock
(BatteryPeriodTracker.maximumObservationGap, two sampling intervals), is the unplug
itself: its time and the charge of the first battery sample become the record. Plugging in
or unplugging already triggers an immediate re-sample (see "Which state is shown"), so
the recorded time is the moment of the unplug up to that re-sample. Every sample with an
external source connected, a weak one included, clears the record. The record is kept in
the statistics file (see "Privacy"), so it survives a restart of the app and a reboot: the
wall clock does not stop for either.
Asked once from the power log. When Restwatt did not watch the unplug, it reads the power management log once, with the read-only command
pmset -g log
That is the case when the app starts on battery (first launch, a relaunch, a reboot on
battery), when the last sample with a source lies more than 60 seconds before the first
battery sample (the Mac slept in between, for example lid closed on the charger, unplugged,
opened elsewhere), and when the charge rose across such a gap although both samples were
on battery (plugged in and unplugged again while asleep). Until the answer arrives the
rows show the lower bound "since the first battery sample of this session". The read runs
at most once per battery period and session: never per sample, never when the popover or
the menu opens, never again after an answer. It runs off the main thread at utility
priority, launches /usr/bin/pmset directly without a shell, and streams the output line
by line, holding only the current line. A line that grows past
PowerLog.maximumLineLength bytes without a line break is dropped and fails the read. A
read that takes longer than 30 seconds (PowerLog.readTimeout) is stopped with a
terminate signal and killed if it is still running PowerLog.killGrace seconds later; a
read that hit that deadline counts as failed even when pmset then exits normally, since
its output may be cut off anywhere. A stopped read, a non-zero exit, an overlong line and
a tool that cannot be started all count as a failed read (what applies then is described
below). At most one read runs at a time: a request that comes while a read runs waits,
several waiting requests collapse into the latest, and it starts when the running read
ends.
From the log only lines with a timestamp and a power source count (Using AC is external,
Using Batt or Using BATT is the battery; cut-off lines are skipped, and a charge counts
only when its reading is complete). The start of the current period is the first battery
line after the last external line. It is shown as exact, with the first complete charge
reading within 5 minutes of that line, when the last external line lies at most 5 minutes
(PowerLog.preciseBracket) before it; otherwise, and when the log reaches back to no
external line at all, it is shown as a lower bound without a charge. That exact start is
checked like a remembered record: it needs a known charge at the log's start, or it is
only a lower bound at that line (14:05 or earlier), and when the first battery sample of
this session reads a higher charge than the last charge the log saw in the period, the Mac
charged after that line without the log showing it, and the lower bound "since the first
battery sample of this session" applies instead. A boot inside a battery period (the powerd process is started line) is
a stretch nobody watched: the period only runs on across it when the charge before and
after it is known and fell. An unchanged charge vets nothing, because a Mac held at 100 %
or at a charge limit reads the same after charging while it was off. Otherwise the period
starts again with the first battery line after the boot. Only a charge that rose across
the boot proves the Mac charged while off, so only then does the last line before the boot
bracket that restart (exact within 5 minutes as above); with an unknown or unchanged
charge the unplug may lie anywhere before the boot and the restart is only a lower bound.
When no battery line follows the boot
before the log ends, the log knows no current period and the lower bound stays. When the
last power source line of the log is not the battery, the log does not describe the
current period and the lower bound stays either.
A record from an earlier session is never shown on trust. After a launch on battery the
remembered exact unplug is not shown before the log read has answered, and after a read
that succeeds it is shown only when the log confirms it: the log must reach back to
an external line no later than 60 seconds after the remembered unplug
(UnplugRecord.futureTolerance), no external source may come after that line, and no
boot after that line may be one the log does not vet. A boot counts as vetted only when a
battery line stands before it and after it and the charge is known on both sides and fell
(an unchanged charge, at 100 % or at a charge limit, counts as unvetted); a boot directly after the external line, a boot the log ends on and a boot the
period restarts after all count as unvetted, because the Mac may have charged while it
was off. Only then does the record keep its charge, which the log may lack. Otherwise the
log's own start applies, as exact or as a lower bound by the rules above: when the log
shows a source later, when it does not reach back to any external line, or when an
unvetted boot follows the external line. Independently of the log, the first battery
sample of the session drops the record as soon as it reads a higher charge than the one
remembered at the unplug (the Mac charged in between); the log's own start then applies
once it arrives. When the read fails (a timeout included), it observed no plug-in, and a
reboot alone does not invalidate the record: a remembered exact unplug that the first
battery sample of the session did not refute stays as it is, but only when its charge is
known and below 100 %. At a full battery a charge while the Mac was off cannot show up as
a higher first sample, so that check proves nothing and the lower bound applies; a charge
limit below 100 % is not known to Restwatt and remains a gap. Without such a record the
lower bound stays. A read that succeeds but does not end on the battery, or finds no
battery period, keeps the lower bound.
A launch on AC clears the record at once. A record more than 60
seconds in the future (UnplugRecord.futureTolerance, a clock that was set back) is
dropped when the file is loaded, and a clock set back while on battery restarts the rows
as a lower bound from the present.
Why not only the honest lower bound: that variant costs nothing, but after every launch on
battery it would say at least 0:05 while the answer is in the system log. The log read
was chosen because it costs once per unwatched unplug, in the background. On the
development Mac one full read took about 5 to 6.5 seconds of wall clock in several
measurements, and the pmset process itself peaked at about 230 MB of resident memory
while it ran; Restwatt holds only the piece of output it is reading, never the whole log. Nothing is read per sample.
- The gauge decides when it updates. On the Mac Restwatt was developed on, all gauge values changed together about once a minute, so the "current draw" is always the gauge's latest average, never an instantaneous reading.
- Restwatt cannot see the future. Both figures assume the draw stays as it is (current) or as it has been on average (smoothed). Opening a video call will invalidate either.
- macOS keeps its own time-to-empty estimators, and they disagree with each other. The
details show the one
pmset -g battshows, labelledmacOS estimate, for comparison. While charging the same row shows the gauge's ownAvgTimeToFull, when it knows one. - The time to full is linear. Restwatt divides the missing energy by the smoothed charging power and knows nothing about the charge curve: near a full charge the charger reduces the current and the last part takes longer than the arithmetic suggests. So while the power is still high, Restwatt's figure is shorter than macOS's (in the measured 96 W reading in the test suite, 0:22 next to the gauge's 0:50); it catches up as the power falls, because the smoothing follows it. Both figures are shown so you can compare.
- Weak sources were not measured. The charging path was verified against a gauge
reading taken on a 96 W USB-C charger. The
weak source, slow-charge and near-zero states are covered by synthetic unit-test fixtures only; nobody had a power bank or a small adapter at hand. The design relies on the sign of the gauge's current and onExternalConnectedbeing set while a source is connected, not on the flags, but that the gauge behaves that way on a weak source is an expectation, not a measurement.AdapterDetails.Wattsmay be absent on a source that does not negotiate USB-C PD; theSource ratingrow is then simply omitted. - The time on battery counts sleep, and it cannot see everything the log does not.
On battery foris wall-clock time since the unplug, so a night asleep on battery counts; Restwatt does not show a separate awake time. A charger connected and removed while the Mac was asleep or switched off is only noticed when the charge rose in between (across a gap between samples, or across a boot in the log) or when the log recorded it. Across a boot in the log an unchanged charge also counts as not vetted, but a short plug-in at 100 % across sleep that changes nothing on the gauge can stay invisible, and the rows then count from the earlier unplug. How far backpmset -g logreaches is up to macOS; when the unplug is older than the log, the rows show a lower bound. The log path was exercised against the realpmseton the development Mac (plugged in at the time); the unit tests use synthetic lines in the same shape as that log, on an invented date, not lines copied from it; a launch on battery with the real app and a live unplug were not observed during development. - The process list is a partial picture and an estimate. It ranks the processes your
user account may inspect by the kernel's per-process CPU energy counter
(
ri_energy_njfromproc_pid_rusage), aggregated by process name, as average watts over the last interval. Root and system processes, the display, the GPU and the rest of the SoC are invisible to an unprivileged app. TheVisible totaland theunaccountedremainder make that gap explicit; on the Mac Restwatt was developed on, the visible processes accounted for well under a watt of the several watts the battery delivered. If no process reported measurable energy over the last interval, the list says so instead of listing anything. TheTodayblock sums the same partial picture over the day: every visible name's watts times the interval length, added to that name's total for the current local calendar day (the interval that spans midnight counts to the new day, and a new day starts from zero). The file keeps the 20 largest names; whatever falls below is folded into theTotal todayline, so the total stays right while a small process that later grows starts again from zero. Root processes, the GPU and the display are as invisible here as in the live list.
- macOS 14 or newer (declared once in
Package.swiftand in the app'sLSMinimumSystemVersion). - An Apple silicon Mac with a built-in battery. The meaning of the battery gauge keys
(
CurrentCapacityas a percentage, theBatteryDatadictionary) was only verified on Apple silicon; on an Intel Mac the app is expected to showNo batteryrather than wrong numbers. On a desktop Mac the item showsNo battery. - The
High Powerrow of the Energy Mode group appears only on a Mac whosepmset -g caplistshighpowermodefor the current power source (or that already reports the value 2);AutomaticandLow Powerare always offered. - To build: Xcode 16 or newer (Swift 6.0 toolchain). No third-party packages.
make app # swift build -c release, assembles dist/Restwatt.app, ad-hoc signs it
make run # builds and opens the app
make install # copies dist/Restwatt.app to /Applicationsmake app calls scripts/make-app.sh, which writes only below .build/ and dist/,
fills packaging/Info.plist.template with the version from the VERSION file, sets
LSUIElement (no Dock icon) and signs the bundle ad hoc. There is no Developer ID
signature and no notarization: Gatekeeper may ask you to confirm the first launch
(right-click the app, choose Open, or allow it under System Settings > Privacy &
Security).
Quit the app from its menu (Quit Restwatt). Quitting also takes back Stay awake with the lid closed if Restwatt had turned it on (see "What it can do"); on a Mac where sudo
asks for a password that shows the administrator dialog once. The Energy Mode is not
taken back: it stays as you last set it, in Restwatt or in Apple's menu.
To have Restwatt start again after a restart or a new login, run make install first so the
app lives in /Applications, open it from there, and click Open at Login in its menu
(see "What it can do"). macOS registers the copy that is running, and it may show a
notification that a login item was added. Quitting Restwatt does not remove the login
item; click Open at Login again, or remove it in System Settings > General > Login Items
& Extensions.
Sampling happens on one repeating timer with a fixed interval of 30 seconds, plus a
single one-shot timer that takes a second reading 5 seconds after the launch tick and
then never fires again; there is no background work between ticks except the one power
log read described below. The one-shot exists
because the process list needs two readings of the kernel's energy counters: without it
the list said collecting the first interval for a full 30 seconds after every launch,
now it does so for those few seconds. The power log read is the one pmset -g log run
for an unplug Restwatt did not watch (see "Where the unplug time comes from"): off the
main thread, at most once per battery period and session, stopped after 30 seconds. The
one-shot is AppKit code without a unit test;
in one smoke run the statistics file appeared within 12 seconds of launch and after
67 seconds had counted 5 + 30 + 30 seconds, so the 30-second pace is unchanged after it.
The statistics file is written at most once per tick, only when its contents changed, and
once more when the app quits (usually a no-op, it covers a tick whose write failed); a
failed write is retried at the next tick and never shown. Measured with ps -o %cpu,rss,cputime on the assembled
0.1.0 bundle running idle on an Apple silicon MacBook while discharging: %cpu reported
0.0 in every 30-second sample over a 6-minute window, the process accumulated about
0.8 s of CPU time over 70 minutes of running, and resident memory stayed at about
46 MB. That is one measurement on one machine, not a guarantee.
- No network access, no telemetry, no analytics, no crash reporting.
- Restwatt writes two files, both under
~/Library/Application Support/Restwatt/. - The settings file
~/Library/Application Support/Restwatt/settings.jsonis written only when a setting changes: a launch that touches no setting does not create it. A click does write it, and turningStay awake with the lid closedon writes it beforepmsetruns (the record comes first, see "What it can do"). The file holds the on/off choice of the threeKeep ... awaketoggles and the flag that Restwatt itself turned onStay awake with the lid closed; a missing or unreadable file means everything off. No sync choice is stored. Deleting the file resets the settings; do it whileStay awake with the lid closedis off. Deleting it while the toggle is on loses the record: the setting stays on the Mac, the next launch treats theSleepDisabled 1as set outside Restwatt and takes nothing back, and quitting writes nothing either; turn the toggle off by hand in the menu or run the saverpmsetcalls yourself. - The statistics file
~/Library/Application Support/Restwatt/statistics.jsonis written from the first tick that has something to remember (an estimate, an unplug, a completed process interval, which the second sample 5 seconds after launch delivers, or a file that loading already changed, as after a reboot), at most once per tick and at quit, atomically (see "Footprint"). On AC inside the dead band there is no estimate, so the launch tick alone does not create the file. It is a statistic, not a log: it never grows with time. It contains, as plain JSON with sorted keys, exactly this: for each of the three flow states the smoothed power in watts, the observation window and sample count, and the wall-clock time (unix seconds) of the last tick in that state; for the current local calendar day (YYYY-MM-DD) up to 20 process names asproc_namereports them (no arguments, no paths, no pids), each with its CPU energy in watt-hours, one figure for the folded-in remainder and the seconds sampled that day; while the Mac is on battery, the current unplug (unplug: its time as unix seconds, the charge at that moment when known, and whether the time isexactor alowerBound), removed again by the first sample with an external source; the boot time of the Mac and the time of the write, both as unix seconds; and a format version. Nothing else: no per-sample history, no battery balance, no earlier day, no earlier unplug. The unit tests pin the exact text of a small document and keep the largest possible one (20 names, three estimator entries, an unplug record, large numbers) under 4096 bytes; the one written in a smoke run was about 1.4 KB. A missing or unreadable file means a start from zero without any message; a file that is valid JSON but carries absurd figures loses those entries only (see "What Restwatt remembers"; an unplug record with a time that is not a finite number from 1970 on, a charge that is not a whole number from 0 to 100, or an unknown precision is dropped on its own), and a file with more than 20 names is folded to 20 on reading, the smallest into the remainder, exactly as a tick folds them. A file whose format version is newer than the one this Restwatt writes is read as empty and never written: the session keeps its statistic in memory, and the newer file is left for the newer Restwatt. Deleting it resets the remembered estimate and the day's statistic, nothing else; Restwatt does not read it for anything but its own display. Without a battery reading nothing is recorded or written. - Restwatt changes the system only when you click a setting, plus the two safety writes
described under "What it can do" (the saver profile at quit and at launch, only when
Restwatt itself had turned the lid-closed setting on). It runs
pmsetas administrator for that toggle and for the Energy Mode rows (one fixedpmset -b lowpowermode Norpmset -c lowpowermode Nline per click, nothing stored, nothing written at launch or quit),launchctl bootstrap,kickstartandbootoutin your own login session domain for iCloud Drive and iCloud Photos, and launches or asks OneDrive to quit through LaunchServices. A click onOpen at Loginadds or removes Restwatt as a login item throughSMAppService, without administrator rights; macOS keeps that registration, Restwatt stores nothing about it. Nothing else runs with administrator rights. Restwatt itself starts no shell: it launchessudo,pmset,launchctlandosascriptdirectly with fixed argument lists. The one place a shell runs is the administrator dialog, where macOS itself executes the fixedpmsetline through its script runner (osascript,do shell script ... with administrator privileges); that line reaches the dialog only after sudo refused to run without a password, and it carries only thepmsetcalls not yet applied. Every token of that line is checked against a fixed alphabet before it is used, and every command line is built from constants in the source. - From the battery registry entry only these keys are used:
UpdateTime,Voltage,Amperage,CurrentCapacity,IsCharging,ExternalConnected,FullyCharged,AvgTimeToEmpty,AvgTimeToFull, insideBatteryDatathe keysBatteryPower,RemainingCapacity,FullChargeCapacity,DesignCapacity, and insideAdapterDetailsonlyWatts(the rated power of the connected source). From IOPowerSources only the time-to-empty estimate is read. Serial numbers and manufacturer data are not read, logged or shown. For the Energy Mode grouppmset -g capandpmset -g customare read (without privileges) when the menu opens and after a click; from their output only the source header, thepowermodelines and the capability keys are used, and the fill colour of the item asks macOS whether Low Power Mode is in effect. None of this is stored. - For an unplug it did not watch, Restwatt reads
pmset -g logonce (without privileges, see "Where the unplug time comes from"). Of its output only the timestamps, the power source (Using AC,Using Batt), the charge in those lines and thepowerd process is startedlines are looked at; only the resulting unplug record is kept, in the statistics file. - From processes only the pid, the name and the rusage counters are read.
- Restwatt watches pointer movement system-wide only to notice when the pointer rests on its menu bar item. It looks at the pointer position alone, not at clicks, keys or the windows underneath, and stores nothing.
Package.swift SwiftPM manifest: tools 6.0, macOS 14, no dependencies
VERSION the one place the version lives (Semantic Versioning)
Sources/RestwattCore/ pure logic, no AppKit or IOKit, unit-tested
BatterySnapshot.swift data model, PowerState from the net flow, reader protocols
PowerMath.swift draw, remaining and missing Wh, dead band, minutes
EnergyFlowEstimator.swift adaptive EWMA and confidence, draining and charging, resume from Memory
ProcessEnergyRanker.swift per-process energy deltas, aggregation by name, ranked report
EnergyStatistics.swift statistics file model: staleness rule, day statistic, JSON codec, path
EnergyMemory.swift loads the statistics file, resumes and remembers estimators, records the day and the unplug
BatteryPeriod.swift unplug record, the on-battery period, watched vs unwatched unplug, log answer rules
PowerLog.swift streaming pmset -g log scanner: power source lines, bracket, boot rule, timeout constant
BatteryMonitor.swift one tick: read, settle, dedupe, estimate, rank, remember; Sampling constants
Formatting.swift every user-visible string
PointerRegionTracker.swift pointer enter/leave transitions for the menu bar item
DetailRow.swift label/value rows for the popover and the menu
BatteryGlyph.swift battery-shaped item: layout from text size and percent, fill colour rule
EnergyMode.swift Apple's Energy Mode values, power sources, what one refresh observed
PowerSettings.swift settings model, sync services, energy mode key, stored settings, JSON codec
SystemCommands.swift fixed pmset (including the read-only pmset -g log), launchctl, sudo and osascript argument vectors
SystemStateParser.swift parsers for pmset -g, pmset -g custom, pmset -g cap and launchctl print
SettingsReconciler.swift stored fact vs observed state -> actions (launch, quit, click)
SettingsCoordinator.swift runs the actions behind protocols, owns the settings snapshot
SettingsRow.swift the settings section rows of the click menu
LoginItem.swift Open at Login: the four statuses, row and click decision, coordinator
Sources/RestwattApp/ the menu bar app
main.swift NSApplication bootstrap, accessory activation policy
AppDelegate.swift timers, power-source notification, settings reconcile, wall clock, power log read, wiring
StatusItemController.swift NSStatusItem with the battery-shaped image, redraw triggers, pointer monitor, popover, menu with settings
MenuBarBatteryRenderer.swift draws the battery image for the button's appearance; appearance-change subview
DetailPopover.swift NSPopover with the detail rows in a two-column grid
MenuRowView.swift view behind the display-only rows of the click menu
IOKitBatteryReader.swift AppleSmartBattery registry and IOPowerSources
LibprocProcessReader.swift proc_listallpids and proc_pid_rusage (RUSAGE_INFO_V6)
IOKitPowerAssertions.swift IOPMAssertionCreateWithName and IOPMAssertionRelease
ProcessCommandRunner.swift Process with a fixed executable and arguments, no shell
PmsetPowerLogReader.swift the one background pmset -g log read: utility priority, streamed, timeout
WorkspaceApplicationController.swift NSWorkspace launch and NSRunningApplication quit request
ApplicationSupportFile.swift one file under Application Support: read whole, written atomically
FileSettingsStore.swift settings.json through ApplicationSupportFile
FileStatisticsStore.swift statistics.json through ApplicationSupportFile
ServiceManagementLoginItem.swift SMAppService.mainApp status, register, unregister, Login Items pane
Tests/RestwattCoreTests/ XCTest suite; runs without a battery or privileges
packaging/Info.plist.template bundle metadata, version filled in from VERSION
scripts/make-app.sh assembles and ad-hoc signs dist/Restwatt.app
Makefile build, test, app, run, install, clean
.github/workflows/ci.yml swift build, swift test, make app on macOS runners
docs/screenshots/ the two README screenshots (popover, click menu)
CHANGELOG.md Keep a Changelog
CLAUDE.md binding conventions for contributors and agents
swift build
swift testRestwattCore has no AppKit or IOKit import; hardware, process and system access sit
behind the BatteryReading, ProcessReading, ClockReading, WallClockReading,
PowerAssertionHolding, CommandRunning, SettingsStoring, StatisticsStoring and
ApplicationControlling protocols with test doubles (the wall clock and boot time as
ManualWallClock, the statistics file as MemoryStatisticsStore, the calendar injected
as a value), so the tests run on any Mac and on CI, launch no process and write nothing
outside memory. The suite pins the menu bar strings and detail rows of
every power state (and that the rows carry the same figures as the plain text lines),
the state decision on the net flow including both edges of the dead band, the settling
after a plug or unplug event, the estimator behaviour in both directions (including a
test that fails when the time constant is not adaptive), the fixtures being one measured
96 W charger reading plus clearly marked synthetic weak-source readings,
the ranker's handling of pid reuse, the pointer enter/leave transitions behind the hover
popover, the battery-shaped item (the fill width for 0, 20, 50 and 100 percent and the
clamp outside that range, the outline, nub and text position from a given text size, the
image height staying inside the menu bar, and the fill colour of every power state with
red beating yellow and charging beating everything), the settings section (the exact
pmset, launchctl, sudo and osascript
argument vectors of the two scripts it replaces, the six Energy Mode vectors as fixed
arrays that never contain -a, the pmset -g and launchctl print
parsers against measured output, the pmset -g custom parser against the measured dump
of the development Mac and the pmset -g cap parser, the reconcile decisions at launch,
quit and click including the radio rows (the marked row runs nothing, an unreadable
source refuses the write), the
coordinator against a scripted double of the system (for the Energy Mode: one vector
through sudo -n, the dialog fallback with that one vector, a cancelled dialog, a pmset
refusal without a dialog, an accepted write whose read-back disagrees, and every
unreadable or unknown case), and the menu rows including the Energy Mode group), the statistics
that survive a restart (every branch of the staleness rule at its edges, a resumed
estimator not moving on its first sample and smoothing with the remembered window from
the second, a second launch continuing where the first left off, the first entry into a
state resuming while later switches restart, the day statistic rolling over at local
midnight in an injected time zone, eviction to 20 names with the remainder folded into the
total, the JSON codec's round trip and pinned text with unknown keys ignored and garbage
meaning a start from zero, the largest document under 4096 bytes, at most one write per
tick and none without a change, a failed write retried, and the Today rows and lines
with their example figures), the time on battery (the pmset -g log scanner against
synthetic lines in the shape of a real log, cut-off lines that never invent a charge, the
5-minute bracket at both edges, the boot rule with a fallen, unchanged, risen and unknown
charge, a restart after a boot that is only a lower bound unless the charge rose, chunk
boundaries, whole lines only, an overlong line and a read that hit the deadline failing
even with exit 0, one read at a time with waiting requests collapsed into the latest; a
watched unplug, a
plug-in and a weak source clearing it, the 60-second observation gap at its edge, a launch
on battery refined by the log, the log's own exact start only with a known charge that
the first sample does not exceed, a remembered record kept when the log confirms it (also
across a boot with a fallen charge) and never shown across a later plug-in, across a boot
with an unchanged charge at 100 %, across a boot the log restarts the
period after, across a boot directly after the last external line or one the log ends on,
when the first sample reads a higher charge, or when the log does not reach back to an
external line, a failed read keeping a remembered record the first sample did not refute
and otherwise the lower bound, a stale read keeping the lower bound, one
read per period, a reboot keeping the record, a record in the future dropped; the codec
with the unplug key and its absurd values; the exact row texts), and a few repository invariants: VERSION matches the
latest CHANGELOG release, this README states the sampling interval and the second-sample
delay, names the settings and the statistics file, quotes the pmset -g log read and
gives its timeout, bracket, observation gap and future tolerance next to their constants,
quotes every pmset call the app
can run (the two profiles and the six Energy Mode vectors), states the red-fill threshold
as the constant the tint test holds and names the lowpowermode write key together with
the powermode line pmset reports it under, no source file names a shell or a
password-free sudo rule, and no file contains an em dash or en dash, and neither the test sources nor this README nor
the CHANGELOG carry a power log line dated 2026 (the scanner fixture is synthetic and says
so).
CI runs on GitHub Actions (macos-latest and macos-15, the lower edge for
swift-tools-version: 6.0) on every push and pull request and needs no secrets.
MIT, see LICENSE.

