Sometimes the best way to learn a new language is to solve your own problem.
I'm primarily a JAMstack developer. I build static sites with Eleventy, Jekyll, and Hugo. But I had a problem: I write daily in Bear and I wanted to automatically track my word counts in Beeminder without manual logging.
The catch? Both Bear by Shiny Frog and Beeminder have APIs... but they're not web APIs. They're native APIs. Which meant I needed to learn Swift.
So I did. 🧑💻
What I learned building Bearminder:
→ Swift isn't as scary as I thought
→ macOS AppKit has surprisingly good documentation
→ Keychain integration is actually straightforward
→ Native apps feel fast compared to web apps
→ XcodeGen makes project management way more sane
The result?
A tiny macOS menubar app that counts my daily words in Bear and syncs them to Beeminder. Runs hourly or on-demand. Everything stays local. MIT licensed.
The lesson:
When you're building tools for yourself first, the stakes are lower and the learning is more fun. You're not trying to capture a market—you're just trying to scratch an itch. And if it helps one other person? Bonus.
For me, this was a reminder that good developers aren't defined by their tech stack. We're defined by our willingness to learn whatever solves the problem in front of us.
If you're a Bear user who also uses Beeminder (niche, I know), the code's on GitHub: https://github.com/brennanbrown/bearminder. If you're a web dev curious about native development, maybe this post is your sign to try it.
Why a menubar app?
We wanted something that:
- Stays out of your way while you write in Bear.
- Posts just today’s words to a Beeminder goal.
- Runs on demand or on a schedule, handles auth securely, and is easy to tweak.
The result: a tiny AppKit app with a 🐻 in the menu bar and a Settings window for tokens, goal, sync frequency, tag filters, and a Start at Login toggle.
Architecture at a glance
- App target lives in
Apps/BearMinder/(generated by XcodeGen). - App layer code lives in
AppTemplate/:-
AppDelegate.swiftwires everything, calculates today’s delta, and posts. -
StatusItemController.swiftshows the 🐻 menu, last/next sync info, and status dot. -
SettingsWindowController.swiftstores tokens in Keychain and exposes sync frequency, tag filters, and Start at Login. -
StartAtLoginManager.swifttoggles login item viaSMAppService(macOS 13+). -
AppDelegate+URLHandling.swiftregisters and handlesbearminder://…callbacks.
-
- Core code is a Swift Package in
Sources/(Models,KeychainSupport,BeeminderClient,BearClient,Persistence,SyncManager).
Early roadblocks (and how we fixed them)
1) Menubar UI not showing, lifecycle ambiguity
- Symptom: build succeeded but the app didn’t show a menubar item and
_mainlinker errors appeared at times. - Fixes:
- Added an explicit AppKit entry point in
AppTemplate/Main.swiftwith@mainandNSApplication.shared.run(). - Ensured the Xcode project includes new sources by regenerating with XcodeGen (
Apps/BearMinder/project.yml). - Verified target contains
AppTemplate/Main.swiftandAppTemplate/AppDelegate.swift. - Temporarily set
LSUIElement=falseinApps/BearMinder/Info.plistto surface a Dock icon during debugging.
- Added an explicit AppKit entry point in
// AppTemplate/Main.swift
@main
final class MainApp: NSObject {
static func main() {
let app = NSApplication.shared
app.delegate = AppDelegate()
app.run()
}
}
2) No callbacks from Bear
- Symptom: clicking “Sync Now” produced no runtime logs, only build logs.
- Fix:
- Registered the URL event handler in
AppDelegate+URLHandling.swiftwithNSAppleEventManagerforkAEGetURL. - Created
BearCallbackCoordinatorto parse and broadcast callback payloads. - Added structured logs to confirm: registered handler → received URL → parsed params.
- Registered the URL event handler in
// AppDelegate+URLHandling.swift
func registerURLHandler() {
NSAppleEventManager.shared().setEventHandler(
self,
andSelector: #selector(handleGetURLEvent(event:replyEvent:)),
forEventClass: AEEventClass(kInternetEventClass),
andEventID: AEEventID(kAEGetURL)
)
}
@objc
func handleGetURLEvent(event: NSAppleEventDescriptor, replyEvent: NSAppleEventDescriptor) {
guard let s = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue,
let url = URL(string: s) else { return }
_ = callbackCoordinator.handle(url: url)
}
3) Bear’s callback shape differs between actions
- Symptom:
bear://x-callback-url/searchreturned anotesparam (JSON array), not a simpleidslist. - Fix:
-
AppTemplate/BearIntegrationManager.swiftnow decodes the JSONnotesvalue when present and extracts identifiers, titles, tags, and timestamps. - Filters notes to those modified “today” (UTC) via
modificationDate. - Per note, calls
open-noteand reads the body fromtextornote(Bear versions differ here).
-
// BearIntegrationManager.swift (parseNotesJSON)
private func parseNotesJSON(_ raw: String) -> [NoteSearchMetadata] {
guard let decoded = raw.removingPercentEncoding,
let data = decoded.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else { return [] }
return array.compactMap { dict in
guard let id = dict["identifier"] as? String, !id.isEmpty else { return nil }
var meta = NoteSearchMetadata(id: id, title: dict["title"] as? String)
if let tagsRaw = dict["tags"] as? String { meta.tags = parseTags(tagsRaw) }
meta.modified = parseDate(dict["modificationDate"] as? String)
meta.created = parseDate(dict["creationDate"] as? String)
return meta
}
}
4) Beeminder comment formatting
- Symptom: early versions displayed literal
\nand multi-line comments were inconsistent on Beeminder. - Fix:
- Rebuilt the
application/x-www-form-urlencodedbody usingURLComponents.percentEncodedQueryinSources/BeeminderClient/BeeminderClient.swift. - Standardized to a concise single-line summary comment for reliability.
- Rebuilt the
// BeeminderClient.makeRequest
var comps = URLComponents()
comps.queryItems = [
URLQueryItem(name: "auth_token", value: token),
URLQueryItem(name: "value", value: String(datapoint.value)),
URLQueryItem(name: "comment", value: datapoint.comment),
URLQueryItem(name: "requestid", value: datapoint.requestID),
URLQueryItem(name: "timestamp", value: String(Int(datapoint.timestamp)))
]
request.httpBody = comps.percentEncodedQuery?.data(using: .utf8)
5) Daily totals vs deltas: what’s the right baseline?
- Goal: Post only today’s new words.
- Challenges we ran into:
- A first attempt used a moving baseline within the day, which could zero out later syncs.
- For brand new notes created today, a bad baseline sometimes initialized to the current count, yielding delta 0.
- Fix:
- In
AppTemplate/AppDelegate.performRealSyncNow()we compute today’s delta against yesterday’s end-of-day count for each note (UTC). If none exists, baseline = 0. - We added a corrector: if a today record exists where
previousWordCount == currentWordCountand there’s no yesterday record, we treat baseline as 0 for the computation and rewrite today’s tracking entry accordingly. - We skip posting when the daily delta is 0 to avoid overwriting a positive datapoint with zero.
- In
// AppDelegate.performRealSyncNow (delta computation)
let today = DateUtility.today(); let yesterday = DateUtility.yesterday()
var totalDelta = 0
for note in notes {
let yest = try? store.loadNoteTracking(noteID: note.id, date: yesterday)
let todayTrack = try? store.loadNoteTracking(noteID: note.id, date: today)
var baseline = yest?.currentWordCount ?? 0
if baseline == 0, let t = todayTrack, t.previousWordCount == t.currentWordCount { baseline = 0 }
let delta = max(0, note.wordCount - baseline)
totalDelta += delta
try? store.saveNoteTracking(NoteTracking(noteID: note.id, date: today, previousWordCount: baseline, currentWordCount: note.wordCount))
}
6) Persistent Keychain prompts
- Symptom: macOS asked for Keychain access on every launch.
- Fix:
- When writing tokens, we now set
kSecAttrAccessibleAfterFirstUnlockinSources/KeychainSupport/KeychainSupport.swift. - Users can also open Keychain Access → find items with Service
bear/beeminder(Accounttoken) → Access Control → add BearMinder and “Always Allow”.
- When writing tokens, we now set
// KeychainSupport.KeychainStore
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
kSecValueData as String: Data(password.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
The working flow today
- Automatic background sync runs on a schedule (configurable in Settings), or click 🐻 → Sync Now.
-
BearIntegrationManagersearches broadly and receives a JSONnotesarray. - It filters to notes modified today (UTC), then fetches each note’s body (optionally filtered by your tags).
-
AppDelegate.performRealSyncNow()computes today’s delta assum(max(0, current - yesterday_baseline)). - The app posts only today’s delta to Beeminder with a concise one‑line summary comment and a date‑based
requestid.
// Start at Login (macOS 13+)
if #available(macOS 13.0, *) {
if enabled { try? SMAppService.mainApp.register() }
else { try? SMAppService.mainApp.unregister() }
}
// Background timer (SyncManager)
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(30))
t.setEventHandler { [weak self] in Task { await self?.syncNow() } }
t.resume()
Insights and takeaways
-
Make the entry point explicit. Menubar apps avoid some lifecycle niceties; an explicit
@main+NSApplication.shared.run()removes ambiguity. - Regenerate your Xcode project when you add sources under XcodeGen. Otherwise you’ll chase phantom linker/visibility issues.
- Log runtime, not just builds. Xcode can hide important runtime logs unless you focus the run console.
- APIs drift. The Bear URL API returns different param shapes between actions and versions; code to the observed payload and keep fallbacks.
- UTC everywhere. Day boundaries and cross-timezone behavior are safer and more predictable with UTC.
- Be conservative with posting. Skip posting 0s to avoid clobbering good data.
Newer lessons
- Unify manual and scheduled sync. Drive both through the same performer so behavior is identical.
- Small, helpful notifications. Notify only on repeated failures; otherwise keep noise low.
- Offline-first mindset. Queue datapoints on failure and flush on the next success.
// Post + offline queue (AppDelegate)
do {
_ = try await beeminder.postDatapoint(dp, perform: true)
try await flushQueuedDatapoints()
} catch {
try? store.enqueueDatapoint(dp)
notify(title: "BearMinder", body: "Queued today's datapoint to retry later.")
}
private func notify(title: String, body: String) {
let c = UNMutableNotificationContent(); c.title = title; c.body = body
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: c, trigger: nil))
}
What’s next
- Sparkle auto‑updater and signed releases.
- Code signing + hardened runtime.
- Enhanced backoff and retry strategies.
- Performance: keep idle memory tiny; streamline sync work.
If you want to tinker, see the Developer Guide in README.md and the TODO in TODO.md.
Top comments (0)