SaneBar Architecture
July 2, 2026 · View on GitHub
README · ARCHITECTURE · DEVELOPMENT · PRIVACY · SECURITY
This document explains how SaneBar is structured, how it moves menu bar icons, and how the major services interact. It is written to be useful to any developer (Swift, macOS, or otherwise).
Goals and Non-Goals
Goals
- Provide reliable hide/show of menu bar icons.
- Programmatically move icons between zones (visible/hidden/always-hidden) via CGEvent Cmd+drag.
- Keep all data on-device; avoid telemetry and external dependencies for core behavior.
- Make behavior predictable (clear triggers, clear state).
- Keep the app safe to run at login and in headless/test contexts.
Non-goals
- No cloud services for core features.
- No direct manipulation of other app windows.
System Context
SaneBar is a macOS menu bar app that relies on:
- NSStatusItem for the menu bar UI.
- Accessibility (AXUIElement) to read and interact with menu bar items.
- Sparkle for updates (appcast feed).
- CoreWLAN and Focus Mode files for automation triggers.
The app itself is a single process with modular services that all route through MenuBarManager.
Architecture Principles
- Single orchestration point: MenuBarManager is the source of truth for state.
- Services are small and focused (one responsibility per service).
- User intent wins: manual reveal pins visibility until explicit hide.
- Safe defaults: start expanded, validate, then hide.
- Main-thread safety: UI state changes are @MainActor.
Core Components (What does what)
| Component | Responsibility | Key Files |
|---|---|---|
| MenuBarManager | Orchestrates state, services, and user actions | Core/MenuBarManager.swift |
| StatusBarController | Creates and configures status items and menu | Core/Controllers/StatusBarController.swift |
| HidingService | Controls hide/show via delimiter length toggle | Core/Services/HidingService.swift |
| AccessibilityService | Reads menu bar items and performs AX actions | Core/Services/AccessibilityService.swift (+ extensions) |
| SearchService | Finds hidden icons and activates them | Core/Services/SearchService.swift |
| SettingsController | Loads/saves settings, publishes changes | Core/Controllers/SettingsController.swift |
| PersistenceService | JSON storage for settings and profiles | Core/Services/PersistenceService.swift |
| TriggerService | App launch + low battery triggers | Core/Services/TriggerService.swift |
| NetworkTriggerService | WiFi SSID triggers | Core/Services/NetworkTriggerService.swift |
| FocusModeService | Focus Mode triggers | Core/Services/FocusModeService.swift |
| HoverService | Hover/scroll/click reveal triggers | Core/Services/HoverService.swift |
| MenuBarAppearanceService | Menu bar visual styling | Core/Services/MenuBarAppearanceService.swift |
| MenuBarSpacingService | System-wide spacing adjustments | Core/Services/MenuBarSpacingService.swift |
| UpdateService | Sparkle updates | Core/Services/UpdateService.swift |
| AppleScriptCommands | Automation via AppleScript | Core/Services/AppleScriptCommands.swift |
| SearchWindowController | Floating Find Icon window | UI/SearchWindow/SearchWindowController.swift |
| Onboarding | First-run flow | UI/Onboarding/* |
Key Flows
App Launch
main.swiftstarts app and AppDelegate.- AppDelegate sets activation policy, initializes MenuBarManager.
- MenuBarManager loads settings, then runs deferred UI setup.
- Status items created, services configured, triggers started if enabled.
- Initial hide happens after a short delay (unless external monitor rule blocks it).
Show/Hide Toggle (User Click or Hotkey)
- User action calls MenuBarManager.toggleHiddenItems().
- If auth is required and items are hidden, LocalAuthentication is prompted.
- HidingService toggles delimiter length.
- MenuBarManager schedules auto-rehide if enabled.
- Accessibility cache invalidated so Find Icon is accurate.
Find Icon (Search Window)
- User opens Search window (Option-click or hotkey).
- SearchWindowController suspends hover triggers.
- SearchService queries AccessibilityService cache (or refreshes).
- Selecting an app reveals hidden items and attempts virtual activation.
- A longer rehide delay is scheduled for search flow.
Automation Triggers (Battery, App Launch, WiFi, Focus, Hover)
- Trigger service detects event.
- MenuBarManager.showHiddenItemsNow() is called with trigger type.
- If auth is required, event will be blocked.
- Auto-rehide scheduled unless pinned.
Menu Bar Appearance Overlay
MenuBarManagerpushessettings.menuBarAppearanceintoMenuBarAppearanceService.- If Custom Appearance is off, the overlay stays hidden even when app, space, screen, or accessibility-display observers fire later.
- If Custom Appearance is on, the overlay tracks the active menu bar screen and re-evaluates visibility on app activation, space changes, screen changes, and transparency changes.
- The overlay is suppressed for:
- true fullscreen content windows on the active screen
- narrow third-party full-width top-host strips that occupy the menu bar region Large desktop windows that start below the menu bar are not treated as fullscreen; Custom Appearance stays visible during ordinary app launches and maximized windows.
- The overlay is never supposed to re-enable itself; fullscreen/app-switch events may hide or show an already-enabled overlay, but they must not override the saved setting.
State Machines
App Lifecycle
stateDiagram-v2 [*] --> Launching Launching --> DeferredUISetup: MenuBarManager init DeferredUISetup --> UIReady: status items created UIReady --> Running: services configured Running --> Terminating: quit Terminating --> [*]
| State | Meaning | Entry | Exit |
|---|---|---|---|
| Launching | App process starts | main.swift, AppDelegate | MenuBarManager init |
| DeferredUISetup | UI setup delayed for WindowServer readiness | deferredUISetup() | status items ready |
| UIReady | Status items exist, settings loaded | setupStatusItem() | services configured |
| Running | Normal operation | triggers, menu, hotkeys | quit |
| Terminating | App is exiting | NSApplication.terminate | process ends |
Hide/Show State (HidingService)
stateDiagram-v2 [*] --> Expanded Expanded --> Hidden: hide() Hidden --> Expanded: show() Expanded --> Hidden: toggle() + auto-rehide
| State | Meaning | Entry | Exit |
|---|---|---|---|
| Expanded | Hidden icons are visible | delimiter length = expanded | hide()/toggle() |
| Hidden | Icons are pushed off-screen | delimiter length = collapsed | show()/toggle() |
Search Window
stateDiagram-v2 [*] --> Closed Closed --> AuthCheck: toggle (if auth required) AuthCheck --> Opening: auth ok AuthCheck --> Closed: auth failed Opening --> Open: window shown Open --> Closing: lose focus or dismiss Closing --> Closed
| State | Meaning | Entry | Exit |
|---|---|---|---|
| Closed | No search window | default | toggle() |
| AuthCheck | Optional Touch ID gate | toggle() | auth ok/failed |
| Opening | Window created or reused | createWindow() | makeKeyAndOrderFront |
| Open | Search active, triggers suspended | window visible | resign key / dismiss |
| Closing | Window hides, triggers resume | close() | Closed |
Automation Reveal (Triggers)
stateDiagram-v2 [*] --> Monitoring Monitoring --> Triggered: app/battery/focus/wi-fi/hover Triggered --> Revealing: showHiddenItemsNow() Revealing --> AutoRehide: scheduleRehide() AutoRehide --> Hidden: delay elapsed AutoRehide --> Expanded: pinned by user
| State | Meaning | Entry | Exit |
|---|---|---|---|
| Monitoring | Triggers active | services running | trigger event |
| Triggered | Event detected | TriggerService etc | reveal call |
| Revealing | Hidden icons shown | HidingService.show() | schedule rehide |
| AutoRehide | Timer running | scheduleRehide() | hide/pin |
Onboarding
stateDiagram-v2 [*] --> NotStarted NotStarted --> Showing: first launch Showing --> Completed: user finishes Completed --> [*]
| State | Meaning | Entry | Exit |
|---|---|---|---|
| NotStarted | First launch | hasCompletedOnboarding = false | show onboarding |
| Showing | Welcome flow visible | showOnboardingIfNeeded() | complete |
| Completed | No onboarding | hasCompletedOnboarding = true | none |
Auth Gate (Touch ID / Password)
stateDiagram-v2 [*] --> Idle Idle --> Prompting: auth required Prompting --> Authorized: success Prompting --> Denied: failure/cancel Denied --> LockedOut: too many failures LockedOut --> Idle: lockout expires Authorized --> Idle: continue flow
| State | Meaning | Entry | Exit |
|---|---|---|---|
| Idle | No auth in progress | default | reveal request |
| Prompting | LAContext in progress | authenticate() | success/fail |
| Authorized | Reveal allowed | auth ok | continue flow |
| Denied | Reveal blocked | auth failed | rate-limit check |
| LockedOut | Temporary block | max failures hit | timer expires |
Persistence
Settings and profiles are stored as JSON in Application Support:
- Settings:
~/Library/Application Support/SaneBar/settings.json - Profiles:
~/Library/Application Support/SaneBar/profiles/*.json
Profiles are individual files; the service enforces a max of 50 profiles.
Concurrency Model
- UI and services are mostly
@MainActorto avoid NSStatusItem threading issues. - Background work (caches, timers, monitoring) is driven by Tasks and Combine.
- Accessibility caching uses async refresh to keep Find Icon responsive without blocking UI.
Permissions and Privacy (What is actually used)
- Accessibility: required for menu bar item discovery and interaction.
- Apple Events: required for AppleScript commands.
- LocalAuthentication: Touch ID/password gating for hidden icons.
- CoreWLAN: WiFi SSID triggers.
- Focus Mode: reads local Focus Mode files for name detection.
- Launch at Login: SMAppService.
See PRIVACY.md for details and rationale.
Updates and Distribution
SaneBar is free, MIT-licensed, and community-maintained (sunset June 2026).
- Sparkle handles updates:
SUFeedURLinSaneBar/Info.plist→https://sanebar.com/appcast.xml. - Historical releases were notarized ZIP-first direct-download/Sparkle
artifacts hosted at
dist.sanebar.com/updates/SaneBar-X.Y.Z.zip, mirrored permanently on GitHub Releases. Sparkle appcast enclosures and website download routes must point to the same versioned ZIP. - Updates are EdDSA-signed; the public key ships in Info.plist
(
7Pl/8cwfb2vm4Dm65AByslkMCScLJ9tbGlwGGx81qYU=). The private key stays with the original maintainer, so forks need their own keys and feed URL. - The Mac App Store lane never shipped; the Setapp lane is retired (see
Setapp/README.md). The
SaneBarSetappscheme still builds for legacy reasons.
Licensing (historical)
The paid era's license machinery (Core/Services/LicenseService.swift, trial
logic, upsell UI) is still in the tree, permanently short-circuited: the
production build passes freeBuildUnlock: true, which sets isPro = true
before any keychain/network/trial path runs. Every downstream isPro gate is
therefore constant-true in shipped builds. It is kept because the isPro
plumbing threads through the battle-tested move/zone workflows and old paid
installs still carry keychain license state — removing it wholesale is riskier
than the dead weight is worth. Tests exercise the historical paths by passing
freeBuildUnlock: false.
Error Handling and Recovery
- Accessibility permission changes are monitored and broadcast.
- HidingService and SearchService guard against nil status items and missing permissions.
- Authentication is rate-limited after repeated failures.
- Deferred UI setup avoids crashes when WindowServer is not ready.
Live-Anchor Structural Recovery Contract
SaneBar must not trust persisted menu-bar state until live AppKit/AX anchors prove the current structural attachment. Saved Visible/Hidden/Always Hidden positions, autosave names, currentHost defaults, cached separator frames, and warm geometry are hints, not proof.
The recovery contract is:
- prove the main SaneBar status item and separator status-item anchors are live before marking geometry warm
- prove the current structural snapshot still has the expected zone boundaries before replaying hidden-state intent
- treat missing, unattached, or blocking-mode anchors as recovery input, not as valid layout state
- rebuild SaneBar-owned anchors or open the Health fallback when live proof cannot be established
- never use cached geometry alone to silently restore a hidden state after startup, reboot, wake, display changes, or poisoned defaults
The full root-cause history behind this contract lives in docs/GEOMETRY_TRUST_REVIEW_2026-06-10.md — read it before changing any recovery code.
Testing Strategy
- Unit tests live under
Tests/. - Primary entry point for verification:
./Scripts/SaneMaster.rb verify. - Search/trigger flows are largely integration-tested via MenuBarManager + services.
Icon Moving Pipeline
Why CGEvent Cmd+Drag (There Is No Alternative)
There is no Apple API for programmatic menu bar icon positioning. This was verified exhaustively:
| Approach | Result |
|---|---|
kAXPositionAttribute (AX) | Read-only for menu bar items |
NSStatusItem.preferredPosition | Does not exist |
UserDefaults NSStatusItem Preferred Position | macOS ignores manual writes |
AXUIElementSetAttributeValue | Returns error for status items |
| WWDC sessions | Zero results on this topic |
Every app that moves icons uses the same hack: simulate Cmd+drag via CGEvent. Common approaches include CGEvent + dual event taps with retries, private APIs (which break across macOS versions), or simply not moving icons at all.
Sources: NSStatusItem docs
Three-Zone Architecture
Icons live in three zones separated by two NSStatusItem delimiters:
[Always-Hidden zone] [AH separator] [Hidden zone] [Main separator] [Visible zone] [System items]
← leftmost rightmost →
- Visible: Right of main separator. Always shown.
- Hidden: Between separators. Shown on click/hover, auto-rehidden.
- Always-Hidden: Left of AH separator. Only shown via Find Icon window.
NSStatusItems grow leftward — right edge stays fixed, left edge extends. When hidden (length=10000), items are pushed far off-screen left (~x=-3349).
How Icon Moving Works
Orchestrator: MenuBarManager+IconMoving.swift
Drag engine: AccessibilityService+Interaction.swift (performCmdDrag)
Move sequence:
- Expand —
showAll()shield pattern toggles both separators to visual size. Required because the 10000px separator physically blocks Cmd+drag in both directions. - Wait — 300ms for macOS relayout (500ms hits auto-rehide).
- Find icon — AX scan for icon frame by bundle ID.
- Calculate target — Direction-dependent:
- To visible:
max(separatorRightEdge + 1, mainIconLeftEdge - 2)— just left of SaneBar icon - To hidden:
max(separatorOrigin - offset, ahSeparatorRightEdge + 2)— right of AH separator - AH-to-hidden: Right of AH separator (uses
moveIconFromAlwaysHiddenToHidden)
- To visible:
- Cmd+drag — 16-step CGEvent drag over ~240ms with cursor hidden, grab at icon center (
midX). - Verify — Re-read AX frame, check icon landed on expected side of separator.
- Retry — If verification fails, one retry with updated grab point.
- Restore —
restoreFromShowAll()+hide()to collapse separators back.
Physical Cmd+drag is only valid for explicit user actions and explicit automation commands. Passive startup/wake/screen recovery may audit visibility intent, rebuild SaneBar-owned anchors, and warm caches, but must not invoke cursor-moving third-party item drags.
Geometry fallback guardrails:
- Separator recovery may estimate the main icon edge from the separator's right edge, but only when the separator is still present in visual mode.
- Do not reuse separator caches to invent a main-icon boundary when the separator itself is gone or still in blocking mode.
- The stale-frame
getMainStatusItemLeftEdgeX()path should degrade tonilonly after cached main geometry and guarded separator-backed fallback are both unavailable.
Key files:
| File | Role |
|---|---|
Core/MenuBarManager+IconMoving.swift | Orchestration, separator reading, zone routing |
Core/Services/AccessibilityService+Interaction.swift | moveMenuBarIcon(), performCmdDrag(), verification |
Core/MenuBarManager.swift | lastKnownSeparatorX cache, isMoveInProgress flag |
UI/SearchWindow/MenuBarSearchView.swift | Zone-aware context menus, appZone() classifier |
UI/SearchWindow/SearchWindowController.swift | isMoveInProgress guards on window close |
Tests/IconMovingTests.swift | 56 regression tests |
Known Fragilities
- First drag sometimes fails — timing between
showAll()completing and icons becoming draggable. - Wide icons (>100px) may need special grab points.
- AH-to-Hidden verification is too strict when separators are flush (both at same X). Move works visually but verification reports failure.
- Separator reads -3349 during blocking mode — mitigated by
lastKnownSeparatorXcache. - Re-hiding after move can undo the move if macOS hasn't reclassified the icon yet.
- This will always be fragile. Ice has the same open bugs (#684). Accept it and add retries.
What We Tried That Didn't Work
| Attempt | Why It Failed |
|---|---|
show() instead of showAll() | Doesn't trigger proper relayout, icons stay off-screen |
Target = separatorX + offset (fixed overshoot) | Overshoots past SaneBar icon into system area |
| Only expanding for move-to-visible | Move-to-hidden also blocked by 10000px separator |
| No AH boundary clamping | Move-to-hidden overshoots past AH separator |
| 30ms drag timing (6 steps × 5ms) | macOS ignores — too fast for CGEvent to register |
| Implementing "fixes" from audit without verifying bugs exist | Regressed working code (Feb 8 incident) |
Risks and Tradeoffs
- The hide/show technique relies on NSStatusItem length behavior; macOS changes could affect it.
- Icon moving uses CGEvent Cmd+drag simulation — inherently fragile, may break on macOS updates. No alternative exists.
- Menu bar spacing uses private defaults keys (system-wide effect, logout usually required).
- Accessibility permission is mandatory for most features (including icon moving).
- Focus Mode detection depends on local system files that may change across macOS versions.
Operations & Scripts Reference
The checked-in project helper directory is Scripts/ with a capital S.
See Scripts/README.md for the full catalog: the everyday
build/test commands that work on any clone, and the maintainer-only release
tooling kept for reference.