Why CreativeNotch doesn't poll
All writingA notch app lives in the background. It sits under the menu bar the whole time your Mac is on, and you spend almost none of that time looking at it. That makes its idle cost the only cost that really matters. A menu bar app that’s quick when you open it and busy when you don’t is a bad deal, because you hardly ever open it.
The easy way to build one is to keep everything running. You use a global event monitor to know where the cursor is, a repeating timer to refresh system stats, and an audio tap to drive a visualiser. Each of those is cheap on its own, each is the first thing a search turns up, and none of them ever stops.
So before I built the first feature, I wrote down one rule.
Nothing runs when it isn’t needed, and that rule is enforced in one central place instead of being left to each feature.
The second half is the part that matters. Everybody already agrees with “don’t waste cycles”, and nobody can be held to it. A rule enforced in one place is something you can actually check.
The ban list tells you where to look
docs/ARCHITECTURE.md bans four things by name. They are a Timer that
always runs, a global event monitor that’s always installed, checking the
cursor position on a loop, and tapping the audio. Next to the list is the
sentence that makes it useful rather than just strict.
If you believe you need one, you almost certainly need a notification you have not found yet.
That turns the ban into a hint. Nearly every time I reached for something on
the list, macOS already had an event for what I wanted, and I just hadn’t found
it yet. An NSTrackingArea costs nothing while the cursor is somewhere else.
NSWorkspace and NotificationCenter announce most changes worth reacting to.
An NSMenuDelegate can refresh a menu when it opens, instead of refreshing
forever in case a menu might open.
Scheduling one thing for a known moment in the future is allowed, and the countdown timer is the example. The rule isn’t “never schedule work”. It’s that nothing runs when it isn’t needed, and a countdown you started on purpose is needed by definition. What it must never do is schedule anything while no countdown is running.
The exception is the interesting part
One feature really does poll. NSPasteboard has no change notification. The
only way to know something was copied is to read changeCount and compare it
with the last value you saw. There’s no event to find. I looked.
So clipboard history gets a poller, and the poller is designed to cost as little as possible. It checks every 0.75 seconds while you’re working, slows to every 3 seconds after two quiet minutes, never goes faster than every 2 seconds in Low Power Mode, and stops completely while your screen is locked or your Mac is asleep.
When it starts again, it catches up on the change count without saving whatever was copied in the meantime. That isn’t about speed. It’s there so that unlocking your Mac doesn’t grab a password you copied on another device. The app also checks for the markers password managers put on what they copy, the concealed, temporary and generated types, before reading anything. So it never holds one of your secrets, not even for a moment.
What matters most isn’t that the poller is careful. It’s that this one exception is what forced a central gate to exist, and that gate is what made the rule enforceable everywhere else.
Four features, three ways to join the gate
SystemActivity is that central gate. Four features use it, and they each use
it in one of three ways.
The clipboard poller and the music helper are paused whenever you’re not active. That’s the obvious approach, and if it were the only one, the gate would just be an on and off switch.
The battery feature is never paused. It’s driven by
IOPSNotificationCreateRunLoopSource and
NSProcessInfo.processInfoPowerStateDidChange, and both are notifications. A
listener that’s registered but never fires costs nothing while idle. Pausing it
would mean missing the charger being plugged in while the lid was shut, and
then being wrong when you open it. So the battery feature keeps listening, and
holds back the peek, the little pop down that interrupts you, instead. Changes
that happen behind the lock screen are dropped rather than shown later. A peek
is meant for the moment it happens, and telling you “unplugged” ten minutes
late would make it a notification, which this app isn’t.
The countdown timer splits the difference. Its drawing is paused, but its deadline never is.
So the lesson I’d take away is that joining the gate doesn’t have to mean being switched off. A gate that only knows how to stop things gets worked around by the first feature that can’t afford to stop, and then you have two ways of saving power instead of one.
What the rule bought
The battery feature was the first one that needed no exception at all. There’s no timer, no poller and no permission prompt. Both of its sources are notifications, and every decision built on top of them is plain logic that’s easy to test.
It shipped with an estimate of time remaining, and I took it out. That wasn’t
to keep things simple. IOKit’s estimate really is unreliable. It swings by 55%
over an hour, it reports “not available” as 0 in one place and -1 in
another, and it jumps in steps of 5 and 10 minutes, which makes any sensible
tolerance useless near the end. I built a settling period and a rule that
consecutive readings had to agree, and I needed every piece of it. That was
the sign something was wrong.
In its place is a simple status line that says charging, not charging, fully charged or on battery. IOKit reports those directly, and it can’t get them wrong.
The side effect is the part I didn’t see coming. The estimate was the value that changed on nearly every IOKit notification, 43 of them in 39 minutes on an idle Mac. With it gone, the battery feature ignores almost all of those because they carry nothing new, and only wakes the rest of the app when something you could actually notice has changed.
Removing the feature made the app cheaper to run and more honest. That’s the case for writing the rule down before the first feature instead of optimising later. If I’d waited until the estimate became a performance problem, it would already have been a feature people used, and I’d have been tuning it instead of deleting it.