DEV Community

Cover image for Building BearMinder: a tiny macOS menubar app connecting Bear to Beeminder in Swift
Brennan K. Brown
Brennan K. Brown

Posted on

Building BearMinder: a tiny macOS menubar app connecting Bear to Beeminder in Swift

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.swift wires everything, calculates today’s delta, and posts.
    • StatusItemController.swift shows the 🐻 menu, last/next sync info, and status dot.
    • SettingsWindowController.swift stores tokens in Keychain and exposes sync frequency, tag filters, and Start at Login.
    • StartAtLoginManager.swift toggles login item via SMAppService (macOS 13+).
    • AppDelegate+URLHandling.swift registers and handles bearminder://… 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 _main linker errors appeared at times.
  • Fixes:
    • Added an explicit AppKit entry point in AppTemplate/Main.swift with @main and NSApplication.shared.run().
    • Ensured the Xcode project includes new sources by regenerating with XcodeGen (Apps/BearMinder/project.yml).
    • Verified target contains AppTemplate/Main.swift and AppTemplate/AppDelegate.swift.
    • Temporarily set LSUIElement=false in Apps/BearMinder/Info.plist to surface a Dock icon during debugging.
// AppTemplate/Main.swift
@main
final class MainApp: NSObject {
    static func main() {
        let app = NSApplication.shared
        app.delegate = AppDelegate()
        app.run()
    }
}
Enter fullscreen mode Exit fullscreen mode

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.swift with NSAppleEventManager for kAEGetURL.
    • Created BearCallbackCoordinator to parse and broadcast callback payloads.
    • Added structured logs to confirm: registered handler → received URL → parsed params.
// 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)
}
Enter fullscreen mode Exit fullscreen mode

3) Bear’s callback shape differs between actions

  • Symptom: bear://x-callback-url/search returned a notes param (JSON array), not a simple ids list.
  • Fix:
    • AppTemplate/BearIntegrationManager.swift now decodes the JSON notes value when present and extracts identifiers, titles, tags, and timestamps.
    • Filters notes to those modified “today” (UTC) via modificationDate.
    • Per note, calls open-note and reads the body from text or note (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
    }
}
Enter fullscreen mode Exit fullscreen mode

4) Beeminder comment formatting

  • Symptom: early versions displayed literal \n and multi-line comments were inconsistent on Beeminder.
  • Fix:
    • Rebuilt the application/x-www-form-urlencoded body using URLComponents.percentEncodedQuery in Sources/BeeminderClient/BeeminderClient.swift.
    • Standardized to a concise single-line summary comment for reliability.
// 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)
Enter fullscreen mode Exit fullscreen mode

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 == currentWordCount and 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.
// 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))
}
Enter fullscreen mode Exit fullscreen mode

6) Persistent Keychain prompts

  • Symptom: macOS asked for Keychain access on every launch.
  • Fix:
    • When writing tokens, we now set kSecAttrAccessibleAfterFirstUnlock in Sources/KeychainSupport/KeychainSupport.swift.
    • Users can also open Keychain Access → find items with Service bear/beeminder (Account token) → Access Control → add BearMinder and “Always Allow”.
// 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)
Enter fullscreen mode Exit fullscreen mode

The working flow today

  1. Automatic background sync runs on a schedule (configurable in Settings), or click 🐻 → Sync Now.
  2. BearIntegrationManager searches broadly and receives a JSON notes array.
  3. It filters to notes modified today (UTC), then fetches each note’s body (optionally filtered by your tags).
  4. AppDelegate.performRealSyncNow() computes today’s delta as sum(max(0, current - yesterday_baseline)).
  5. 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()
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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)