Three problems that surprised me building Barista
In the last post I wrote about what Barista is and why I built it. This one is about the building part: three problems that took far longer to figure out than they should have, and what I learned from each.
1. The app that died before it started
Barista's weather widget uses WeatherKit, Apple's weather service. Adding it was supposed to be a small change: enable the service for the app, add one entitlement, call the API. Instead, the next development build refused to launch. No crash report, no error, nothing in the logs under the app's name. The process was simply killed the moment it started (exit code 137, a SIGKILL).
The cause turned out to be a chain of things I didn't know:
- As soon as an app carries any
com.apple.developer.*entitlement (WeatherKit is one), macOS fully checks its provisioning profile at launch. Without such an entitlement, that check is mostly skipped, which is why every earlier build had worked fine. - A development profile only works on the Macs listed in it.
- On Apple Silicon Macs, the ID the developer portal needs is the Provisioning UDID, not the Hardware UUID. Both appear in System Information, and the Hardware UUID looks like the obvious choice, so that's the one I had registered.
So my own Mac wasn't in the profile, and the system killed the app before any of my code ran. The fix was registering the right ID, which you can find with:
system_profiler SPHardwareDataType | grep "Provisioning UDID"
One more catch: I build Barista with Swift Package Manager and a shell script rather than Xcode, and Xcode normally adds two entitlements (the application identifier and team identifier) behind the scenes. Without them the same check fails too, so they now sit explicitly in the entitlements file.
TestFlight and App Store builds never had the problem, because distribution profiles aren't tied to specific Macs. Which made it even more confusing: the "real" builds worked and only my local ones died.
2. Writing a small email client
I didn't want the email widget to depend on Mail.app, so Barista talks to mail servers itself, using IMAP to read and SMTP to send. The client is written on top of Apple's Network framework and only implements the handful of commands the widget needs.
It only supports servers that accept a TLS connection straight away (ports 993 and 465). The older way, where a connection starts unencrypted and upgrades to TLS partway through (STARTTLS), has no clean path in Apple's NWConnection, and the direct approach is the more secure one anyway. When someone adds a server that only supports STARTTLS, the setup says so plainly instead of failing with a vague error.
A few lessons from the first weeks:
- Sequence numbers move. IMAP gives each message in a mailbox a position number and a separate, stable ID (the UID). I originally marked messages as read using the position number, remembered from when the list was loaded. But if anything removes a message in between (a server-side filter, Gmail sorting mail into categories, your phone), every position after it shifts, and the server marks the wrong message as read. Switching every action to UIDs fixed it.
- Not all mail is UTF-8. Older mail and some languages still use legacy encodings like Windows-1252, ISO-8859-7 (Greek) or Shift_JIS. They showed up as
=E9artifacts or garbled text until the client started reading the declared charset of each message and header and decoding with it. - Reconnecting is slow. At first, every page of the inbox opened a fresh connection: TCP, TLS handshake, login, fetch, logout. That's 500 to 800 ms per click. Keeping one logged-in connection per account brought paging down to around 250 ms. Later I did the same for opening a message, and added a small in-memory cache so recently fetched messages open instantly.
Dataindices can surprise you. I buffered server responses in Swift'sData, trimming bytes off the front as they were parsed. After enough trimming, the indices returned by oneDatamethod stopped lining up with what another expected, which crashed on long responses. Switching the buffer to a plain[UInt8]array, whose indices always start at zero, made the problem go away.
3. One process, many widgets
Having all the widgets in one app is the whole point of Barista, but it has a downside: if one widget crashes, the whole app goes down with it. And some crashes can't be caught in Swift. An early example was a text-formatting tool that, given a bare value like 42 instead of a full JSON document, triggered an Objective-C exception that try? doesn't catch.
I can't prevent every crash, but I can stop the same widget from taking the app down again and again. So Barista has a small crash quarantine:
- Right before a widget does something risky (starting up, or building its popover), Barista writes a small note saying "this widget is doing this now", and clears it afterwards.
- When the app quits normally, it records a clean exit.
- On the next launch, if the last exit wasn't clean and a note was still there, Barista knows which widget was busy when things went wrong.
That widget gets switched off before it can run, and a banner in Settings explains what happened, with a button to turn it back on. A popover crash quarantines the widget immediately, since popovers open one at a time and it's clear who was responsible. A crash during start-up needs to happen twice in a row, because many widgets start together, and one unlucky force-quit shouldn't blame an innocent widget.
None of this is visible when things work, which is exactly how it should be. But it means a bug in one widget stays a bug in one widget, instead of an app that won't start.
There were plenty of smaller puzzles too (getting text fields to accept typing inside a menu bar popover deserves its own post), but these three are the ones I'll remember.