-
-
Notifications
You must be signed in to change notification settings - Fork 454
Description
Overview
HyperBar has been extracted from the HyperSpace monolith into a standalone helper process. Previously, the bar was hosted by Summon (as "SummonDash"), creating cross-process fragility where HyperSpace depended on a separate app for its primary UI surface. Now HyperSpace fully owns its bar.
The standalone process is bundled at HyperSpace.app/Contents/Helpers/HyperBar.app, managed by launchd for crash recovery, and communicates with the main process via DistributedNotificationCenter (state push) and CLI socket (action dispatch).
Architecture
SPM Module Graph
BarContracts (7 files) — pure types, zero dependencies
↓
BarSurface (5 files) — NSPanel + CALayer rendering
↓
BarRuntime (5 files) — actor state, widgets, snapshot persistence
↓
HyperBarApp (2 files) — @main executable, launchd-managed
All four modules are separate SPM targets. Core depends on BarContracts for shared types. The standalone HyperBarApp links all four plus SwiftLib.
Module Contents
BarContracts — The shared boundary. No rendering, no state, no IO.
HyperBarState— Codable workspace/window state (~2-4KB JSON)BarIPC— DNC notification names, userInfo keys, action identifiersHyperBarFormatter— Window count formatting (dot/number styles)BarWidgetConfig,BarStyleConfig— Config value types for parametrized renderingCokeBadgeMode— CPU badge state enum
BarSurface — Pure rendering. No IPC, no state management.
BarWindow— Borderless NSPanel (non-activating, always-on-top, screen-aware positioning)BarView— NSView with CALayer rendering, hit detection, drag handlingBarItem— Data model for rendered items (workspace badges, float badges, widgets)BarStyles— Style cache built from config (colors, fonts, alpha values)BarAnimator— Smooth show/hide/reveal transitions
BarRuntime — Widget lifecycle and state persistence.
BarStateActor— Thread-safe actor wrappingHyperBarState, generation-based change detectionBarWidgetManager— Clock, battery, Coke (CPU) widget lifecycle + callbacksBarSnapshotStore— Persists state to~/.cache/screenshots/bar-state.json(instant startup render)BarConfigReader— ReadsHyperBarStyle.yamlfor widget/style config
HyperBarApp — Minimal entry point.
HyperBarApp.swift—@main, PID duplicate detection,NSApplication.shared.run()HyperBarDelegate.swift—NSApplicationDelegate, DNC observers, click→CLI dispatch
IPC Design
State Push: HyperSpace → HyperBar (DNC)
Core/HyperBar.pushState()
→ WindowRegistry.rebuild()
→ buildState() → HyperBarState
→ JSONEncoder.encode(state)
→ DispatchQueue.global(.utility).async {
DNC.post("vx.HyperSpace.barStateChanged",
userInfo: [stateKey: jsonString],
deliverImmediately: true)
}
Debounced at 50ms via schedulePushState() to coalesce rapid events. State is always pushed — both in-process render and DNC happen on every change.
Config reload posts vx.HyperSpace.barConfigChanged (no payload) so the standalone process re-reads HyperBarStyle.yaml.
Action Dispatch: HyperBar → HyperSpace (CLI socket)
The standalone bar doesn't send DNC back. Instead it spawns a CLI subprocess:
HyperBarDelegate.sendBarAction("focus-workspace", args: ["dev"])
→ Process("/path/to/hyperspace", ["bar-action", "focus-workspace", "dev"])
CLI path resolution: ~/.local/bin/swift/hyperspace first, falls back to /Applications/vx/HyperSpace.app/Contents/MacOS/HyperSpaceCLI.
This routes through the standard socket server. BarActionCommand in Core handles 6 actions:
| Action | Purpose |
|---|---|
focus-workspace |
Navigate to workspace |
float-toggle |
Toggle floating window visibility |
window-drop |
Move window to target workspace |
workspace-reorder |
Reorder workspace within pool |
context-close-ws |
Close all windows in workspace |
context-rename-ws |
Rename workspace |
Why DNC + CLI (not bidirectional DNC)
- State push (DNC): Fire-and-forget, no response needed, ~2-4KB JSON is well within DNC limits. The main process doesn't need to know if the bar received it.
- Action dispatch (CLI): Needs the full command infrastructure (tree mutation, layout, focus). Routing through the socket server reuses all existing command validation, error handling, and state refresh. Adding DNC→command dispatch would duplicate this.
- No auth needed: DNC is localhost-only. CLI subprocess inherits user privileges. Both are local-only transports on a single-user desktop app.
Process Lifecycle
Bundle Layout
HyperSpace.app/Contents/
MacOS/HyperSpaceApp
Helpers/HyperBar.app/Contents/
MacOS/HyperBarApp
Info.plist (CFBundleIdentifier: vx.HyperBar, LSUIElement: true)
launchd Management
~/Library/LaunchAgents/vx.HyperBar.plist:
RunAtLoad: true— starts with user sessionKeepAlive.SuccessfulExit: false— restarts on crash (not on clean exit)ProcessType: Interactive— scheduling priorityLimitLoadToSessionType: Aqua— only GUI sessions
Build Integration
just deploy hooks:
_bundle_extras— copies builtHyperBarAppbinary intoHelpers/HyperBar.app, generates minimalInfo.plist_post_install— bootouts existing agent, sed-replaces__HYPERBAR_PATH__in plist template, bootstraps new agent
Startup Sequence
- PID duplicate detection via
NSRunningApplication.runningApplications(withBundleIdentifier: "vx.HyperBar") - Load snapshot from
bar-state.jsonfor instant render before first DNC push - Register DNC observers (
stateChanged,configChanged) - Create
BarWindow+BarViewwith snapshot state - Start
BarWidgetManager(clock, battery, CPU) - Wait for live state pushes from main process
Design Decisions
Why standalone process?
- Crash isolation — Bar survives HyperSpace crashes. launchd restarts the bar independently.
- Independent lifecycle — Bar starts with user session, doesn't need HyperSpace running to show last-known state.
- Clean module boundary — Forces all bar rendering to be parametrized (no global config reads). BarSurface takes
BarStyleConfig; BarRuntime takesBarWidgetConfig. - Future: independent updates — Bar can be updated without restarting the WM.
Why not a standalone toggle?
The original plan included hyperbar.standalone: true/false for fallback rendering. This was skipped because:
- The standalone bar doesn't render independently yet (
renderStateis a TODO stub) - A toggle would disable the working in-process bar with nothing to replace it
- Both modes coexist naturally: in-process renders, standalone receives state for future use
Why BarContracts as the shared boundary?
Core needs to serialize HyperBarState and post DNC notifications. The standalone app needs to deserialize the same types. BarContracts is the minimal shared surface — pure Codable types, string constants, and formatting helpers. No AppKit, no rendering, no state management.
Why custom CmdArgs parser for bar-action?
Standard cmdParser/parseSpecificCmdArgs doesn't handle variable trailing args (action name + 0-N action-specific args). parseBarActionCmdArgs manually parses the raw args array, validates the action name against BarActionKind, and passes remaining args through.
Current State
Both in-process and standalone bar coexist. Core always renders locally AND pushes state via DNC. The standalone process receives and persists state but doesn't yet render independently. Next step: implement renderState() in HyperBarDelegate to drive BarView from deserialized HyperBarState, then optionally disable in-process rendering.
Related Files
Package.swift— SPM targets for BarContracts, BarSurface, BarRuntime, HyperBarAppSources/BarContracts/— 7 files (shared types)Sources/BarSurface/— 5 files (rendering)Sources/BarRuntime/— 5 files (state + widgets)Sources/HyperBarApp/— 2 files (executable)Sources/Core/bar/HyperBar.swift— state push integrationSources/Core/command/impl/BarActionCommand.swift— action dispatchSources/Shared/cmdArgs/impl/BarActionCmdArgs.swift— CLI parserApp/vx.HyperBar.plist— launchd templatejustfile—_bundle_extras,_post_installhooks