Skip to content

Architecture: HyperBar Standalone Process Extraction #1999

@risenowrise

Description

@risenowrise

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 identifiers
  • HyperBarFormatter — Window count formatting (dot/number styles)
  • BarWidgetConfig, BarStyleConfig — Config value types for parametrized rendering
  • CokeBadgeMode — 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 handling
  • BarItem — 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 wrapping HyperBarState, generation-based change detection
  • BarWidgetManager — Clock, battery, Coke (CPU) widget lifecycle + callbacks
  • BarSnapshotStore — Persists state to ~/.cache/screenshots/bar-state.json (instant startup render)
  • BarConfigReader — Reads HyperBarStyle.yaml for widget/style config

HyperBarApp — Minimal entry point.

  • HyperBarApp.swift@main, PID duplicate detection, NSApplication.shared.run()
  • HyperBarDelegate.swiftNSApplicationDelegate, 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 session
  • KeepAlive.SuccessfulExit: false — restarts on crash (not on clean exit)
  • ProcessType: Interactive — scheduling priority
  • LimitLoadToSessionType: Aqua — only GUI sessions

Build Integration

just deploy hooks:

  1. _bundle_extras — copies built HyperBarApp binary into Helpers/HyperBar.app, generates minimal Info.plist
  2. _post_install — bootouts existing agent, sed-replaces __HYPERBAR_PATH__ in plist template, bootstraps new agent

Startup Sequence

  1. PID duplicate detection via NSRunningApplication.runningApplications(withBundleIdentifier: "vx.HyperBar")
  2. Load snapshot from bar-state.json for instant render before first DNC push
  3. Register DNC observers (stateChanged, configChanged)
  4. Create BarWindow + BarView with snapshot state
  5. Start BarWidgetManager (clock, battery, CPU)
  6. Wait for live state pushes from main process

Design Decisions

Why standalone process?

  1. Crash isolation — Bar survives HyperSpace crashes. launchd restarts the bar independently.
  2. Independent lifecycle — Bar starts with user session, doesn't need HyperSpace running to show last-known state.
  3. Clean module boundary — Forces all bar rendering to be parametrized (no global config reads). BarSurface takes BarStyleConfig; BarRuntime takes BarWidgetConfig.
  4. 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 (renderState is 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, HyperBarApp
  • Sources/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 integration
  • Sources/Core/command/impl/BarActionCommand.swift — action dispatch
  • Sources/Shared/cmdArgs/impl/BarActionCmdArgs.swift — CLI parser
  • App/vx.HyperBar.plist — launchd template
  • justfile_bundle_extras, _post_install hooks

Metadata

Metadata

Assignees

No one assigned

    Labels

    binThe issue will be deleted

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions