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)

ComponentResponsibilityKey Files
MenuBarManagerOrchestrates state, services, and user actionsCore/MenuBarManager.swift
StatusBarControllerCreates and configures status items and menuCore/Controllers/StatusBarController.swift
HidingServiceControls hide/show via delimiter length toggleCore/Services/HidingService.swift
AccessibilityServiceReads menu bar items and performs AX actionsCore/Services/AccessibilityService.swift (+ extensions)
SearchServiceFinds hidden icons and activates themCore/Services/SearchService.swift
SettingsControllerLoads/saves settings, publishes changesCore/Controllers/SettingsController.swift
PersistenceServiceJSON storage for settings and profilesCore/Services/PersistenceService.swift
TriggerServiceApp launch + low battery triggersCore/Services/TriggerService.swift
NetworkTriggerServiceWiFi SSID triggersCore/Services/NetworkTriggerService.swift
FocusModeServiceFocus Mode triggersCore/Services/FocusModeService.swift
HoverServiceHover/scroll/click reveal triggersCore/Services/HoverService.swift
MenuBarAppearanceServiceMenu bar visual stylingCore/Services/MenuBarAppearanceService.swift
MenuBarSpacingServiceSystem-wide spacing adjustmentsCore/Services/MenuBarSpacingService.swift
UpdateServiceSparkle updatesCore/Services/UpdateService.swift
AppleScriptCommandsAutomation via AppleScriptCore/Services/AppleScriptCommands.swift
SearchWindowControllerFloating Find Icon windowUI/SearchWindow/SearchWindowController.swift
OnboardingFirst-run flowUI/Onboarding/*

Key Flows

App Launch

  1. main.swift starts app and AppDelegate.
  2. AppDelegate sets activation policy, initializes MenuBarManager.
  3. MenuBarManager loads settings, then runs deferred UI setup.
  4. Status items created, services configured, triggers started if enabled.
  5. Initial hide happens after a short delay (unless external monitor rule blocks it).

Show/Hide Toggle (User Click or Hotkey)

  1. User action calls MenuBarManager.toggleHiddenItems().
  2. If auth is required and items are hidden, LocalAuthentication is prompted.
  3. HidingService toggles delimiter length.
  4. MenuBarManager schedules auto-rehide if enabled.
  5. Accessibility cache invalidated so Find Icon is accurate.

Find Icon (Search Window)

  1. User opens Search window (Option-click or hotkey).
  2. SearchWindowController suspends hover triggers.
  3. SearchService queries AccessibilityService cache (or refreshes).
  4. Selecting an app reveals hidden items and attempts virtual activation.
  5. A longer rehide delay is scheduled for search flow.

Automation Triggers (Battery, App Launch, WiFi, Focus, Hover)

  1. Trigger service detects event.
  2. MenuBarManager.showHiddenItemsNow() is called with trigger type.
  3. If auth is required, event will be blocked.
  4. Auto-rehide scheduled unless pinned.
  1. MenuBarManager pushes settings.menuBarAppearance into MenuBarAppearanceService.
  2. If Custom Appearance is off, the overlay stays hidden even when app, space, screen, or accessibility-display observers fire later.
  3. 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.
  4. 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.
  5. 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 --> [*]
StateMeaningEntryExit
LaunchingApp process startsmain.swift, AppDelegateMenuBarManager init
DeferredUISetupUI setup delayed for WindowServer readinessdeferredUISetup()status items ready
UIReadyStatus items exist, settings loadedsetupStatusItem()services configured
RunningNormal operationtriggers, menu, hotkeysquit
TerminatingApp is exitingNSApplication.terminateprocess ends

Hide/Show State (HidingService)

stateDiagram-v2
  [*] --> Expanded
  Expanded --> Hidden: hide()
  Hidden --> Expanded: show()
  Expanded --> Hidden: toggle() + auto-rehide
StateMeaningEntryExit
ExpandedHidden icons are visibledelimiter length = expandedhide()/toggle()
HiddenIcons are pushed off-screendelimiter length = collapsedshow()/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
StateMeaningEntryExit
ClosedNo search windowdefaulttoggle()
AuthCheckOptional Touch ID gatetoggle()auth ok/failed
OpeningWindow created or reusedcreateWindow()makeKeyAndOrderFront
OpenSearch active, triggers suspendedwindow visibleresign key / dismiss
ClosingWindow hides, triggers resumeclose()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
StateMeaningEntryExit
MonitoringTriggers activeservices runningtrigger event
TriggeredEvent detectedTriggerService etcreveal call
RevealingHidden icons shownHidingService.show()schedule rehide
AutoRehideTimer runningscheduleRehide()hide/pin

Onboarding

stateDiagram-v2
  [*] --> NotStarted
  NotStarted --> Showing: first launch
  Showing --> Completed: user finishes
  Completed --> [*]
StateMeaningEntryExit
NotStartedFirst launchhasCompletedOnboarding = falseshow onboarding
ShowingWelcome flow visibleshowOnboardingIfNeeded()complete
CompletedNo onboardinghasCompletedOnboarding = truenone

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
StateMeaningEntryExit
IdleNo auth in progressdefaultreveal request
PromptingLAContext in progressauthenticate()success/fail
AuthorizedReveal allowedauth okcontinue flow
DeniedReveal blockedauth failedrate-limit check
LockedOutTemporary blockmax failures hittimer 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 @MainActor to 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: SUFeedURL in SaneBar/Info.plisthttps://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 SaneBarSetapp scheme 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:

ApproachResult
kAXPositionAttribute (AX)Read-only for menu bar items
NSStatusItem.preferredPositionDoes not exist
UserDefaults NSStatusItem Preferred PositionmacOS ignores manual writes
AXUIElementSetAttributeValueReturns error for status items
WWDC sessionsZero 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:

  1. ExpandshowAll() shield pattern toggles both separators to visual size. Required because the 10000px separator physically blocks Cmd+drag in both directions.
  2. Wait — 300ms for macOS relayout (500ms hits auto-rehide).
  3. Find icon — AX scan for icon frame by bundle ID.
  4. 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)
  5. Cmd+drag — 16-step CGEvent drag over ~240ms with cursor hidden, grab at icon center (midX).
  6. Verify — Re-read AX frame, check icon landed on expected side of separator.
  7. Retry — If verification fails, one retry with updated grab point.
  8. RestorerestoreFromShowAll() + 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 to nil only after cached main geometry and guarded separator-backed fallback are both unavailable.

Key files:

FileRole
Core/MenuBarManager+IconMoving.swiftOrchestration, separator reading, zone routing
Core/Services/AccessibilityService+Interaction.swiftmoveMenuBarIcon(), performCmdDrag(), verification
Core/MenuBarManager.swiftlastKnownSeparatorX cache, isMoveInProgress flag
UI/SearchWindow/MenuBarSearchView.swiftZone-aware context menus, appZone() classifier
UI/SearchWindow/SearchWindowController.swiftisMoveInProgress guards on window close
Tests/IconMovingTests.swift56 regression tests

Known Fragilities

  1. First drag sometimes fails — timing between showAll() completing and icons becoming draggable.
  2. Wide icons (>100px) may need special grab points.
  3. AH-to-Hidden verification is too strict when separators are flush (both at same X). Move works visually but verification reports failure.
  4. Separator reads -3349 during blocking mode — mitigated by lastKnownSeparatorX cache.
  5. Re-hiding after move can undo the move if macOS hasn't reclassified the icon yet.
  6. This will always be fragile. Ice has the same open bugs (#684). Accept it and add retries.

What We Tried That Didn't Work

AttemptWhy 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-visibleMove-to-hidden also blocked by 10000px separator
No AH boundary clampingMove-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 existRegressed 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.