| title | OmniWM Architecture Guide |
|---|
This document is for contributors who want to understand OmniWM's internals. It is not a user guide (see Documentation Home) or IPC/CLI reference (see IPC-CLI.md). For contribution process, see the Contribution Guide.
Prerequisites: Familiarity with Swift, macOS development concepts (AppKit, AXUIElement, CGWindowID), and basic tiling window manager concepts.
- 1. Project Structure
- 2. Startup & Bootstrap
- 3. Core Mental Model
- 3.1 The Four-Stage Pipeline
- 3.2 Window Identity
- 3.3 Window Lifecycle
- 3.4 Stage 2 — WorldStore, the Single Writer
- 3.5 Stage 3 — The Effector & Refresh Pipeline
- 3.6 Stage 4 — Surface Reconciliation
- 3.7 Echo Classification & Intents
- 3.8 Layout Engines as Pure State Machines
- 3.9 The Ungated Animation Tier
- 3.10 Thread Safety Model
- 4. Key Subsystems
- 4.1 WMController — The Coordinator
- 4.2 World State: WorldStore, WorkspaceManager, WindowState
- 4.3 Niri Layout Engine (Scrolling Columns)
- 4.4 Dwindle Layout Engine (BSP)
- 4.5 Focus Lifecycle
- 4.6 Input Handling
- 4.7 Window Rules Engine
- 4.8 IPC System
- 4.9 Accessibility Layer
- 4.10 Spaces & Native Fullscreen
- 4.11 Surface System
- 4.12 Animation System
- 4.13 Clipboard History
- 4.14 Additional Features
- 5. Data Flow Diagrams
- 6. Common Contribution Patterns
- 7. Glossary
- 8. Design Decisions & Terminology Changes
OmniWM is built with Swift Package Manager (Swift 6.3, strict concurrency, language mode v6). There are four first-party targets plus one binary target, with a clear dependency graph:
OmniWMIPC (zero dependencies — shared IPC protocol models)
^ ^
| \
OmniWMCtl OmniWM + GhosttyKit (CLI tool) (main library)
^
|
OmniWMApp (@main entry point)
| Target | Purpose | Dependencies |
|---|---|---|
OmniWMIPC |
Shared IPC data models and wire format | None |
OmniWMCtl |
CLI tool (omniwmctl) |
OmniWMIPC |
OmniWM |
Core window manager library | OmniWMIPC, GhosttyKit, system frameworks |
OmniWMApp |
Executable wrapper with SwiftUI scene | OmniWM |
The OmniWM library (~77K LOC) is organized by pipeline stage and subsystem:
Sources/
├── OmniWM/ Main library
│ ├── App/ Bootstrap, delegate, updater, owned-window facade (5 files)
│ ├── Core/
│ │ ├── AppInfoCache.swift App icon/name cache
│ │ ├── CommandPaletteMode.swift Command palette mode enum
│ │ ├── PrivateAPIs.swift Private API declarations via @_silgen_name
│ │ ├── Intake/ STAGE 1 — EventIntake, EventInterpreter, FactResolver (3)
│ │ ├── Intent/ IntentLedger, DeadlineWheel — echo classification (2)
│ │ ├── World/ STAGE 2 — WorldStore, the single writer (1)
│ │ ├── Reconcile/ Reducer, plans, snapshots, invariants, trace (12)
│ │ ├── Workspace/ WorkspaceManager, WindowModel, WindowState (6)
│ │ ├── Controller/ STAGE 3 — WMController, handlers, refresh pipeline (17)
│ │ ├── Ax/ AXManager, per-app threads, frame ledger (11)
│ │ ├── Surface/ STAGE 4 — SurfaceReconciler, WorldView, SurfaceScene (4)
│ │ ├── Border/ Border config, applier, server-side border window (3)
│ │ ├── Spaces/ SpaceTracker, SpaceTopology (2)
│ │ ├── Layout/
│ │ │ ├── DNode.swift WindowToken, WindowHandle identity types
│ │ │ ├── LayoutBoundary.swift EffectPlan + layout snapshot/geometry types
│ │ │ ├── LayoutTopology.swift Read-only layout structure projection
│ │ │ ├── SideHiding.swift Off-screen placement geometry
│ │ │ ├── Niri/ Scrolling-columns layout engine (31 files)
│ │ │ └── Dwindle/ Binary-partition layout engine (5 files)
│ │ ├── Animation/ Springs, cubic easing, viewport motion, policy (7)
│ │ ├── Config/ SettingsStore, TOML codec, runtime state, rules (22)
│ │ ├── Rules/ Window rule evaluation engine (1)
│ │ ├── Input/ Action catalog, bindings, Carbon hotkeys (9)
│ │ ├── Monitor/ Display detection, OutputId, restore assignments (5)
│ │ ├── Overview/ Expose-style workspace overview (9)
│ │ ├── Clipboard/ Clipboard history service/store/models (3)
│ │ ├── Menu/ Menu extraction for Menu Anywhere (3)
│ │ ├── SkyLight/ Private SkyLight/CGS wrappers (2)
│ │ ├── Sleep/ Sleep prevention manager (1)
│ │ ├── LockScreen/ Lock screen detection (1)
│ │ └── Support/ Utility types & extensions (3)
│ ├── IPC/ IPC server, connections, routing, broker (9)
│ ├── QuakeTerminal/ Drop-down terminal, Ghostty integration (12)
│ └── UI/ SwiftUI/AppKit settings, bars, palette, status (37)
├── OmniWMApp/ 2 files: @main entry + settings redirect
├── OmniWMCtl/ 7 files: CLI parser, IPC client, renderer
└── OmniWMIPC/ 6 files: models, wire format, socket path
OmniWM has a single third-party Swift package and otherwise builds on system frameworks:
swift-toml— the only third-party package; used exclusively byCore/Config/SettingsTOMLCodec.swiftto read/writesettings.toml. The import is deliberately confined to that one file so the dependency stays swappable.- System frameworks: AppKit, ApplicationServices, Carbon, Metal/MetalKit (Ghostty surface only), QuartzCore, ScreenCaptureKit, IOKit.pwr_mgt, os.
- SkyLight: a private Apple framework for low-latency window-server access, linked via
-framework SkyLightand additionallydlopen/dlsym-loaded for SLS* symbols. - GhosttyKit: a local binary xcframework at
Frameworks/GhosttyKit.xcframework(prepared outside git) providing the Quake Terminal. - System libraries: libz, libc++ (required by GhosttyKit).
swift build # Debug build
make format # Rewrite formatting with SwiftFormat
make lint # Run SwiftLint
make check # format-check + lint + audit + build
make verify # Full gate run before any commit lands
./Scripts/package-app.sh release true # Checks, build, sign, notarizeThe application starts in Sources/OmniWMApp/OmniWMApp.swift:
@main OmniWMApp (SwiftUI App)
└─ @NSApplicationDelegateAdaptor → AppDelegate
└─ applicationDidFinishLaunching()
└─ bootstrapApplication() → finishBootstrap()
AppBootstrapPlanner.decision() (Core → App/AppBootstrapPlanner.swift) is now degenerate: AppBootstrapDecision has a single case .boot, and decision() always returns .boot. The earlier first-run / settings-migration branching was removed under the clean-break purge — OmniWM has no external users and carries no migration paths.
AppDelegate.finishBootstrap() (App/AppDelegate.swift) builds the object graph in dependency order:
OmniWMStoragePaths.live— resolves on-disk locations.RuntimeStateStore— JSON store for non-settings runtime state (runtime-state.json).SettingsStore—@MainActor @Observable, loaded from~/.config/omniwm/settings.toml.UserDefaultsis not used for settings; TOML is the single source of truth.HiddenBarController— menu-bar collapse/expand management.WMController— central coordinator (see 4.1); passed the clipboard-history directory.AppCLIManagerandUpdateCoordinator— CLI exposure plus GitHub release polling/popup.StatusBarController— menu-bar UI and manual update checks.IPCServer— started only ifipcEnabledis set.- Automatic update checks — started last, only after bootstrap succeeds.
applicationWillTerminate flushes the window-restore catalog, settings, and runtime state, then stops the IPC server.
WMController.setEnabled(true) drives ServiceLifecycleManager.start():
- Polls for accessibility permission (blocks until granted).
- Once trusted,
startServices()connects all event plumbing:eventIntake.open(sink: eventInterpreter)— opens the intake buffer and wires the drain sink.spaceTracker.start()— begins space-topology tracking.AXEventHandlersetup — SkyLight/CGS event observation viaCGSEventObserver.HotkeyCenter— Carbon hotkey registration.MouseEventHandler— CGEvent taps.DisplayConfigurationObserver— display reconfiguration.- App activation/termination/hide/unhide observers and
NSWorkspace.activeSpaceDidChange(which posts.activeSpaceChangedinto the intake). - An initial full-rescan refresh.
OmniWM is fundamentally reactive. Every signal — a window appearing, a hotkey, a mouse gesture, an IPC command, a timer firing — is funnelled through one pipeline with four named stages and exactly one mutation point.
┌──────────────────────────────────────────────────────────────────────┐
│ TRANSPORTS │
│ CGSEventObserver (SkyLight) HotkeyCenter (Carbon) MouseEventHandler│
│ per-app AXObservers IPCApplicationBridge DeadlineWheel │
│ DisplayConfigurationObserver FactResolver ServiceLifecycle │
└───────────────────────────────┬──────────────────────────────────────┘
│ EventIntake.post(IntakeEvent)
v
┌───────────────────────────────────────────────────────────────────────┐
│ STAGE 1 — INTAKE (Core/Intake, Core/Intent) │
│ EventIntake: one lock-guarded ordered buffer, monotonic global seq, │
│ coalesces mouse/CGS-frame bursts, drains ONCE per cycle via │
│ CFRunLoopPerformBlock on the main run loop. │
│ EventInterpreter: the drain sink — a pure switch that DISPATCHES each │
│ stamped event to the owning WMController sub-handler. │
│ IntentLedger: classifies AX focus echoes (echoOf / lateEcho / │
│ external) so our own actions aren't mistaken for the user's. │
│ FactResolver: gathers one off-main fact (activation focus) and │
│ re-enters the intake. │
└───────────────────────────────┬──────────────────────────────────────┘
│ WorkspaceManager.recordReconcileEvent(WMEvent)
v
┌───────────────────────────────────────────────────────────────────────┐
│ STAGE 2 — WORLD (Core/World, Core/Reconcile, Core/Workspace) │
│ WorldStore.commit(WMEvent): the SINGLE synchronous writer. │
│ EventNormalizer → StateReducer (pure) → resolve → InvariantChecks. │
│ Owns WindowModel, focus, viewports, monitor sessions, space │
│ topology, and BOTH layout engines — all private; seq is bumped. │
│ Output: an ActionPlan (state deltas). │
└───────────────────────────────┬──────────────────────────────────────┘
│ requestRelayout(reason:) / EffectPlan
v
┌───────────────────────────────────────────────────────────────────────┐
│ STAGE 3 — EFFECTOR (Core/Controller, Core/Ax, Core/Layout) │
│ LayoutRefreshController: schedules/coalesces refreshes, drives the │
│ engines under a build scope to build an EffectPlan, drops stale │
│ plans via seq/InvalidationMarks, executes frame diffs. │
│ AXManager → AppAXContext: writes CGRects on per-app run-loop threads.│
│ AXFrameApplicationLedger: dedup / verify / retry / learn quantum. │
└───────────────────────────────┬──────────────────────────────────────┘
│ noteWorldChanged()
v
┌───────────────────────────────────────────────────────────────────────┐
│ STAGE 4 — SURFACE (Core/Surface, Core/Border) │
│ SurfaceReconciler: derives every auxiliary surface (focus border, │
│ workspace bars, tab rails, native-fullscreen placeholders) from a │
│ read-only WorldView facade, diffs against the applied scene, and │
│ applies only what changed. │
└───────────────────────────────────────────────────────────────────────┘
Two properties are load-bearing:
- One buffer, one drain, one writer. All transports enqueue into a single
EventIntakebuffer that drains once per main-run-loop cycle in seq order; all state mutation flows throughWorldStore.commit. Sub-handlers never mutate world state directly. - The interpreter dispatches; it does not classify or commit.
EventInterpreteris a pure switch that routes eachIntakeEventto aWMControllersub-handler. Echo classification lives inIntentLedger; commits happen inWorldStorereached viaWorkspaceManager.recordReconcileEvent.
Windows are identified at three levels, each serving a different purpose:
// 1. WindowToken — value type, used as dictionary keys everywhere
// Core/Layout/DNode.swift
struct WindowToken: Hashable, Sendable {
let pid: pid_t // Process ID
let windowId: Int // SkyLight/CGS window ID
}
// 2. WindowHandle — reference type, identity-compared (===)
// Core/Layout/DNode.swift
final class WindowHandle: Hashable {
var id: WindowToken // re-pointed on rekey
// hash/equality use ObjectIdentifier (reference identity)
}
// 3. AXWindowRef — accessibility bridge to the actual window
// Core/Ax/AXWindow.swift
struct AXWindowRef: Hashable, @unchecked Sendable {
let element: AXUIElement // Accessibility handle for read/write
let windowId: Int // equality/hash by windowId only
}Why three layers?
WindowTokenis a lightweightSendablevalue type that survives relayouts and works as a dictionary key without holding any AX resource. When an app destroys and recreates a window,WindowModel.rekeyWindowre-points everything from the old token to the new one so identity is preserved.WindowHandleprovides reference identity for layout-tree holders; it is re-pointed during rekey so a holder keeps a stable handle even as the token changes. (Its legacyinit(id:pid:axElement:)still exists but thepid/axElementarguments are ignored — the handle no longer carries a live AX element.)AXWindowRefis the bridge to the macOS Accessibility APIs and holds the heavyweightAXUIElement. It is stored onWindowState.axRef.
Creation (see the full trace in 5.2):
CGSEventObserverreceives.created(windowId, spaceId)from SkyLight and posts.cgs(...)intoEventIntake.- After the drain,
EventInterpreterroutes it toAXEventHandler.handleCGSEvent→handleCGSWindowCreated→processCreatedWindow→trackPreparedCreate, which reads AX attributes and runs the rules. WindowRuleEngine.decision(facts)produces aWindowDecision(.managed/.floating/.unmanaged/ deferral).- If tracked,
WorkspaceManager.addWindowcallsrecordReconcileEvent(.windowAdmitted(...)), which commits the event throughWorldStore. The commitupserts the window into the privateWindowModel, reduces to anActionPlan, and runs invariants. AXEventHandlerthen callslayoutRefreshController.requestRelayout(reason: .axWindowCreated, ...)to schedule the effector.
Destruction:
CGSEventObserver/ per-app AX observer reports the window gone; the event drains toAXEventHandler.- A
.windowRemovedcommit removes the entry fromWindowModeland the engine node. requestRelayout(routewindowRemoval) re-lays out and runs focus recovery if the destroyed window was focused.
Managed Replacement:
Some apps (Ghostty, browsers) destroy and recreate windows during internal operations. AXEventHandler correlates a destroy+create pair via ManagedReplacementMetadata and emits a .windowRekeyed event so the new window inherits the old one's workspace, mode, and position instead of being admitted fresh.
WorldStore (Core/World/WorldStore.swift) is the heart of the architecture: the only path that mutates window-manager state. It is @MainActor and owns, as private properties, everything that constitutes the "world":
@MainActor final class WorldStore {
private let model = WindowModel() // per-window registry (private!)
private(set) var seq: UInt64 = 0 // monotonic mutation counter
private(set) var focus = FocusSessionSnapshot()
private(set) var viewports: [WorkspaceDescriptor.ID: ViewportState] = [:]
private(set) var scratchpadToken: WindowToken?
private(set) var monitorSessions: [Monitor.ID: MonitorSession] = [:]
private(set) var spaceTopology = SpaceTopology()
private(set) var niriEngine: NiriLayoutEngine? // layout engines are
private(set) var dwindleEngine: DwindleLayoutEngine? // PRIVATE to the world
// ... InvalidationMarks bookkeeping
}The commit pipeline. commit(_:monitors:snapshot:resolvePlan:) is synchronous. Each call:
- Bumps
seq(seq &+= 1). - Applies the window mutation in the
.beforePlanphase (e.g.model.upsert). - Runs
EventNormalizer.normalize(fills missing monitor/workspace/from fields from the existing entry). - Runs
StateReducer.reduce(event:existingEntry:currentSnapshot:monitors:)— a pure function — to produce anActionPlan. - Lets the caller resolve/augment the plan (
resolvePlan), then applies any.afterPlanmutation. - Runs
InvariantChecks.validate(snapshot:)on the committed snapshot. - Records a
ReconcileTxninto the privateReconcileTraceRecorder(a bounded 256-entry ring exposed via IPC for debugging).
Reads vs. writes. WorldStore exposes a large read-accessor surface (entry(for:), windows(in:), focus, …) that delegates to the private WindowModel. Every mutator is guarded by assertInCommit (commitDepth > 0), so nothing can mutate the world outside a commit.
Engine mutation sanction. The two layout engines are private to the world. They may only be mutated when isEngineMutationSanctioned is true — i.e. inside commit or inside withEngineBuildScope { … }. The build scope exists because plan-building (Stage 3) must call into the engines (syncWindows/removeWindows/restoreInitialPlacements) without that being a state commit. The build scope sets each engine's isMutationSanctioned flag and the engines assert on any out-of-scope mutation.
Staleness machinery (InvalidationMarks). Because plan-building is asynchronous (Stage 3 awaits between workspaces), a plan can be built against a world that a newer commit has already moved past. WorldStore tracks per-domain seq watermarks (workspace / layout / focus / fullscreen) via noteInvalidation(...). The effector stamps each plan with a plannedSeq and calls isSeqCurrent(plannedSeq, for:domains:) before applying; a plan built before a relevant mutation is dropped rather than applied stale.
Invariants — .trace vs .assert. InvariantChecks.validate returns violations carrying a Severity. Most invariants default to .assert, which triggers an assertionFailure in debug builds (e.g. duplicate_window_token, focused_token_missing, the observed/desired/restore workspace-mismatch checks). Exactly three checks are intentionally softened to .trace (log-only): layout_token_missing, layout_token_wrong_workspace, and selection_unresolved. These three describe the one-cycle window where the engine tree can briefly lag WindowModel because plan-building runs outside commit — see §8 for why closing that window is deferred.
LayoutRefreshController (Core/Controller/LayoutRefreshController.swift) is the effector: it turns world state into actual window frames.
Scheduling. It owns a single-slot scheduler (activeRefresh + pendingRefresh): if a refresh is in flight, incoming requests merge into the pending slot and fire when the active one completes. Each RefreshReason (Core/Controller/RefreshReason.swift, ~27 cases) maps to a RefreshRequestRoute and a per-reason debounce policy.
Two route enums.
RefreshReason.RefreshRequestRoutehas five cases includingfullRescan.LayoutRefreshController.RefreshRouteis a distinct four-case enum used internally for execution (nofullRescan). They are not the same type.
| Route | When | What it does |
|---|---|---|
fullRescan |
Startup, app launch/terminate, space change, display change | Full enumeration + relayout |
relayout |
Config change, window created, frame changed | Recompute from current state (debounced) |
immediateRelayout |
Commands, gestures, workspace switch | Synchronous relayout |
visibilityRefresh |
App hidden/unhidden | Show/hide only |
windowRemoval |
Window destroyed | Remove + relayout + focus recovery |
Plan-building is async. buildRelayoutEffectPlan awaits NiriLayoutHandler.layoutWithNiriEngine (and the Dwindle equivalent), which run syncWindows/removeWindows/restoreInitialPlacements on the engines inside workspaceManager.withEngineBuildScope. It is async because it Task.yields and checkCancellations between workspaces so a newer event can pre-empt a long layout pass. The layout engines return raw [WindowToken: CGRect] frame maps; the handlers wrap those into a WorkspaceLayoutPlan → WorkspaceLayoutDiff → EffectPlan (Core/Layout/LayoutBoundary.swift).
Frame application. executeEffectPlan hands each plan's diff to LayoutDiffExecutor, which calls AXManager.applyFramesParallel. After an accepted layout plan it calls surfaceReconciler.noteWorldChanged() to hand off to Stage 4.
Auxiliary UI — the focus border, per-monitor workspace bars, tabbed-column rails, and native-fullscreen placeholder panels — is no longer pushed ad hoc by individual managers. SurfaceReconciler (Core/Surface/SurfaceReconciler.swift) derives all of it in one place:
- State-mutating paths call
surfaceReconciler.noteWorldChanged()(ornoteRestackOccurred()). These are coalesced into a singleCFRunLoopPerformBlockdrain on the main run loop. - On drain,
runReconcilebuilds a freshWorldView(a read-only facade over the world), andSurfaceDerivation.deriveproduces aDesiredSurfaceScene(optional border, tab rails, placeholders, bars). - The desired scene is diffed (by value equality) against the last applied scene; only changed surfaces are touched, routed to
BorderSurfaceApplier,WorkspaceBarManager.apply(_:),TabbedColumnOverlayManager, andNativeFullscreenPlaceholderManager.
The reconciler is not called from inside WorldStore.commit; it reads current state at drain time through a freshly constructed WorldView, not a captured commit snapshot.
When OmniWM activates an app or focuses a window, macOS emits an AX focus-changed event — an echo of our own action. Without bookkeeping, the system can't tell that echo apart from the user genuinely clicking another window. The Intent subsystem (Core/Intent/) solves this.
IntentLedgeris a@MainActorring buffer (capacity 256) ofIntentrecords.IntentKindhas exactly five cases:activateApp,focusPolicyLease,focusWindow,replacementFocus,sameAppCloseProbe. Each record carries the global intakeseqat issue time, a lifecyclephase(pending/confirmed/superseded/expired/cancelled), and retry state.classifyFocusObservation(token:)returns anEchoClassification:.echoOf(intent)when an open intent targets the token,.lateEcho(intent)when a recently-retired intent (within a 1-second window) matches, otherwise.external. The consumer isAXEventHandler, which treats.echoOf/.lateEchoas confirmation of our pending request and only processes.externalas a genuine user focus change.DeadlineWheelis a main-actor timing wheel keyed byIntentID: it arms a singleTaskthat sleeps until the nearest deadline, then posts.intentExpired(intentId:)back intoEventIntake(it does not fire callbacks).AXEventHandler.handleIntentExpireddecides what to do — e.g. a still-activefocusWindowintent drives a focus retry rather than expiring. Activation-settle deadlines are 100ms. TheDeadlineWheelserves focus/activation/lease intents only; AX frame-write retries are a separate mechanism (see 4.9).FactResolvergathers the one fact that can't be read on the main actor cheaply: the focused window of an activating app. It readskAXFocusedWindow(+ fullscreen flag) off-main on the app's AX thread, then re-enters the pipeline viaEventIntake.post(.activationFactsResolved(...)).
Both engines follow the same contract:
- They own their own tree state — per-workspace
NiriRoottrees for Niri, per-workspaceDwindleNodetrees for Dwindle. - They are owned privately by
WorldStoreand may only be mutated under commit/build-scope sanction. - Given a workspace's snapshot, monitor geometry, gaps, and (for Niri) a
ViewportState, they compute a[WindowToken: CGRect]frame map. - They never touch windows — no AX calls, no frame writes, no
@Observable, no actor isolation. They are plainfinal classtypes that run on the main actor only because their owner does.
The Controller-layer handlers (NiriLayoutHandler/DwindleLayoutHandler) translate the engines' frame maps into EffectPlans; the engines themselves never build an EffectPlan. Note that ViewportState is stored in WorldStore.viewports, not inside the Niri engine — the engine receives it as a call parameter.
There is one deliberate exception to "all mutation goes through commit": per-frame animation.
LayoutRefreshController owns a CADisplayLink per display (via NSScreen.displayLink(target:selector:)). On each tick (displayLinkFired, at displayLink.targetTimestamp) it fans out to NiriLayoutHandler.tickScrollAnimation, the Dwindle tick, closing animations, and surfaceReconciler.reconcileAnimationTick. These ticks advance spring/gesture math and push interpolated frames to AX outside WorldStore.commit — committing 60–120 times per second would be both wasteful and impossible (commit is synchronous and seq-bumping). The committed ViewportState offset is the anchor; the animation adds a transient delta on top. When motion settles, the handler finalizes and stops the display link.
AnimationDriver (Core/Animation/) owns only the per-workspace viewport scroll motion (gesture or spring). Per-window and per-column animations live inside NiriLayoutEngine (tickAllWindowAnimations/tickAllColumnAnimations); Dwindle node animations use CubicAnimation.
@MainActor is the default. Nearly everything — UI, event handling, layout computation, the world, the reconciler — runs on the main actor.
Exceptions, all explicitly bounded:
- Per-app AX threads.
AppAXContextruns a dedicatedNSThread+CFRunLoopper application. AllAXUIElementreads/writes for that app happen there. State pinned to the thread is wrapped inThreadGuardedValueand checked against a@TaskLocal appThreadToken(apreconditionin debug). The bridge back to the main actor isThread.runInLoop(async/await +CheckedContinuation, 2-second timeout). - The intake buffer.
EventIntakeholds its buffer in anonisolated OSAllocatedUnfairLock, soEventIntake.post(...)is callable from any transport thread; the drain re-enters the main actor viaCFRunLoopPerformBlock+MainActor.assumeIsolated. - IPC actors.
IPCApplicationBridge,IPCConnection,IPCEventBroker, andIPCConnectionRegistryare Swift actors; they hop to@MainActorfor any window-management work. - Clipboard store.
ClipboardHistoryStoreis a Swift actor; pasteboard reads happen on a utilityDispatchQueue.
File: Sources/OmniWM/Core/Controller/WMController.swift
WMController is a @MainActor @Observable coordinator. After the redesign it owns the plumbing and the handlers, but not the window-manager state — that lives behind WorkspaceManager → WorldStore. Its job is wiring callbacks, applying settings, resolving workspace placement for new windows, and being the host object every lazy sub-handler captures as controller: self.
Pipeline objects it owns: eventIntake, eventInterpreter, factResolver, intentLedger, deadlineWheel, spaceTracker, surfaceReconciler.
Sub-handlers it owns:
| Handler | Responsibility |
|---|---|
axEventHandler |
CGS/AX events → admissions, focus confirm/retry, native-fullscreen detection |
commandHandler |
Routes HotkeyCommand to layout/workspace calls; enforces the layout-compatibility guard |
mouseEventHandler / mouseWarpHandler |
CGEvent tap, focus-follows-mouse, gestures; cursor warp |
workspaceNavigationHandler |
Workspace switch / window-to-workspace moves |
windowActionHandler |
Close, fullscreen, float toggle |
serviceLifecycleManager |
Observer setup, permission polling, service start/stop |
layoutRefreshController |
Refresh scheduling, the display-link loop, frame application (owns niriLayoutHandler/dwindleLayoutHandler) |
focusNotificationDispatcher |
Publishes focus-change events to IPC subscribers |
Core managers it owns directly: settings: SettingsStore, workspaceManager: WorkspaceManager, axManager: AXManager, windowRuleEngine: WindowRuleEngine, hotkeys: HotkeyCenter, motionPolicy: MotionPolicy, animationClock: AnimationClock, plus surface managers (workspaceBarManager, nativeFullscreenPlaceholderManager) and feature controllers (overview, quake, clipboard).
The layout engines are not owned by
WMController.WMController.niriEngine/dwindleEngineare pass-through accessors that ultimately reachWorldStore's private engines.
WorkspaceManager (Core/Workspace/WorkspaceManager.swift) is the authoritative state facade. It owns the only WorldStore instance (private let world = WorldStore()), the workspace descriptors (workspacesById / workspaceIdByName), the monitor list, gaps, the native-fullscreen record store, and the persisted-restore catalog. It exposes the commit entry point and a large derived-read surface, and emits onSessionStateChanged / onRuntimeInvalidation / onGapsChanged.
WorkspaceManager
├── workspacesById / workspaceIdByName Workspace descriptors (id = UUID)
├── monitors + indexes, gaps / outerGaps
├── nativeFullscreenRecordsByOriginalToken Native-fullscreen records
├── bootPersistedWindowRestoreCatalog Relaunch restore intent
└── world: WorldStore (private) THE single writer
├── model: WindowModel (private) [WindowToken: WindowState]
├── focus: FocusSessionSnapshot focused token, pending managed focus, …
├── viewports: [WorkspaceID: ViewportState] Niri scroll/selection per workspace
├── monitorSessions: [MonitorID: MonitorSession] visible workspace per monitor
├── scratchpadToken: WindowToken?
├── spaceTopology: SpaceTopology
└── niriEngine / dwindleEngine (private) layout trees, mutation-gated
WorldStore.commit is the only mutation path, entered through WorkspaceManager.recordReconcileEvent(_ event: WMEvent) (which supplies the snapshot/resolve closures and writes the resolved ActionPlan back through the in-commit mutators).
WindowModel (Core/Workspace/WindowModel.swift) is a reference-type per-window registry — but it is now private to WorldStore, not a shared source of truth. It stores one WindowState per WindowToken plus reverse indexes (windowIdToToken, tokensByWorkspace, tokensByWorkspaceMode, tokensByPid), constraint/min-size caches, and missing-detection counters.
WindowState (Core/Workspace/WindowState.swift) is the per-window record — a struct (the old nested WindowModel.Entry is gone):
struct WindowState: Equatable {
let token: WindowToken
let axRef: AXWindowRef
var workspaceId: WorkspaceDescriptor.ID
var mode: TrackedWindowMode // .tiling or .floating
var lifecyclePhase: WindowLifecyclePhase
var observedState: ObservedWindowState
var desiredState: DesiredWindowState
var restoreIntent: RestoreIntent?
var replacementCorrelation: ReplacementCorrelation?
var managedReplacementMetadata: ManagedReplacementMetadata?
var floatingState: FloatingState?
var manualLayoutOverride: ManualWindowOverride?
var ruleEffects: ManagedWindowRuleEffects
var hiddenState: HiddenState?
var layoutReason: LayoutReason
// pid / windowId are derived from token
}The focus session (FocusSessionSnapshot) and per-monitor visible-workspace state (MonitorSession) are value types defined in Core/Reconcile/ReconcileSnapshot.swift and held on WorldStore. There is no single SessionState type.
Directory: Sources/OmniWM/Core/Layout/Niri/ (~31 files)
Niri arranges windows in vertical columns that scroll horizontally, inspired by the Niri Wayland compositor.
NiriRoot (per workspace)
├── NiriContainer (column 1)
│ ├── NiriWindow (window A)
│ └── NiriWindow (window B) ← stacked vertically
├── NiriContainer (column 2)
│ └── NiriWindow (window C)
└── NiriContainer (column 3) ← can be tabbed
├── NiriWindow (window D) ← active tab
└── NiriWindow (window E) ← hidden tab
| Type | Purpose |
|---|---|
NiriLayoutEngine |
Owns per-workspace roots, per-monitor NiriMonitor state, tokenToNode index, axis-solve cache, config. |
NiriRoot |
Per-workspace container; cached columns / all-windows / id set. |
NiriContainer |
A column: displayMode (.normal/.tabbed), width: ProportionalSize, activeTileIdx, width/move springs. |
NiriWindow |
Leaf: token, SizingMode (.normal/.maximized/.fullscreen), height: WeightedSize, constraints, move animations. |
ProportionalSize |
.proportion(CGFloat) or .fixed(CGFloat) — column width. |
WeightedSize |
.auto(weight:) or .fixed(CGFloat) — window height within a column. |
ViewportState |
Per-workspace scroll/selection snapshot. Stored in WorldStore.viewports, passed into calculateLayout. |
Layout computation lives in NiriLayout.swift (calculateLayout(...) -> [WindowToken: CGRect]). Constraint solving is NiriAxisSolver in NiriConstraintSolver.swift — a pure 1-D solver distributing span across weighted windows while honoring min/max/fixed constraints, memoized in the engine's axis-solve cache.
File organization. The core engine is split across NiriLayoutEngine.swift plus twelve NiriLayoutEngine+*.swift extensions (+Animation, +ColumnOps, +Monitors, +Sizing, +TabbedMode, +WindowOps, +Windows, +WorkspaceOps, +InteractiveMove, +InteractiveResize, …), with navigation in NiriNavigation.swift, the node tree in NiriNode.swift, viewport math in ViewportState.swift (+4 extensions), and overlays for interactive move/resize, tabbed columns, drag ghost, and swap targets.
Interactive move/resize. Option+Shift+drag moves windows between columns; DragGhostController captures a ScreenCaptureKit thumbnail shown as a translucent ghost and SwapTargetOverlay highlights the drop target. Edge-dragging resizes column widths / window heights.
Directory: Sources/OmniWM/Core/Layout/Dwindle/ (5 files)
Dwindle recursively divides screen space using binary splits, in the style of Hyprland's dwindle / bspwm.
final class DwindleNode {
let id: DwindleNodeId // UUID
var kind: DwindleNodeKind
var parent: DwindleNode?
var children: [DwindleNode] // 0 (leaf) or 2 (split)
var cachedFrame, cachedMinSize
// CubicRectAnimation for smooth transitions
}
enum DwindleNodeKind {
case split(orientation: DwindleOrientation, ratio: CGFloat)
case leaf(handle: WindowToken?, fullscreen: Bool)
}DwindleLayoutEngine.calculateLayout(for:screen:) -> [WindowToken: CGRect]. Smart split (planSplit) chooses orientation from the available rectangle's slope vs. aspect; preselection lets the user direct where the next window inserts. The engine also supports resize/balance/swap/toggle-orientation/toggle-fullscreen and geometric-neighbor navigation. Like Niri it is a plain final class, AX-free, mutation-gated by WorldStore.
Focus management is split across several objects (there is no single coordinator class — KeyboardFocusLifecycleCoordinator.swift now holds only value types: KeyboardFocusTarget, ManagedFocusOrigin, ManagedFocusRequest).
The managed-focus loop (see the full trace in 5.1):
1. User presses focus-left.
2. CommandHandler resolves the target window in the engine.
3. WMController.focusWindow:
a. intentLedger.beginManagedRequest(token, workspaceId, origin)
→ records a .focusWindow Intent + a 100ms settle deadline,
so the upcoming AX echo classifies as echoOf (not external).
b. workspaceManager.beginManagedFocusRequest
→ commits WMEvent.managedFocusRequested (records the request in the world).
4. WMController.performWindowFronting activates the app + window via private APIs
(activateApp, focusSpecificWindow, raiseWindow), then probes the focused window.
5. macOS emits an AX focused-window-changed echo → posted into EventIntake.
6. FactResolver gathers the focused-window fact off-main, re-enters the intake.
7. AXEventHandler.handleActivationFactsResolved:
intentLedger.classifyFocusObservation(token) → .echoOf
→ treat as confirmation, not a competing external focus.
8. workspaceManager.confirmManagedFocus commits .managedFocusConfirmed;
intentLedger.confirmManagedRequest cancels the deadline.
| Type | Purpose |
|---|---|
KeyboardFocusTarget |
Resolved focus: token, axRef, workspaceId, isManaged. |
ManagedFocusRequest |
In-flight request: requestId, token, workspaceId, origin, retryCount, status (.pending/.confirmed). |
EchoClassification |
.echoOf / .lateEcho / .external — see 3.7. |
FocusPolicyEngine (Core/Reconcile/) is a separate concern: time-bounded FocusPolicyLeases that suppress focus-follows-mouse during menus and app-switch transitions, scheduled on the same DeadlineWheel.
Hotkeys (Sources/OmniWM/Core/Input/)
ActionCatalog is the source of truth for bindable actions. buildSpecs() materializes 144 ActionSpecs (90 standalone actions + 6 loop templates × 9), each with a title, search keywords, category, layout compatibility, and default binding. HotkeyBinding/HotkeyBindingRegistry persist and canonicalize per-action bindings (an action can have several shortcuts).
HotkeyCenter (Hotkeys.swift) installs one Carbon InstallEventHandler and registers each binding via RegisterEventHotKey, plus a virtual-hyper synthesis path. On a press it fires onCommand(command), which WMController wires to eventIntake.enqueue(.hotkeyCommand(command)) — commands enter the same single-writer pipeline as everything else (falling back to a direct CommandHandler call only if the intake is closed).
Command routing (Core/Controller/CommandHandler.swift). performCommand enforces isEnabled, overview suppression, and a layout-compatibility guard: a .niri-only command is ignored under Dwindle and vice versa (.shared commands work everywhere).
Mouse events (Core/Controller/MouseEventHandler.swift). A CGEventTap drives focus-follows-mouse (debounced), trackpad swipe gestures (a phase state machine for workspace switching), and interactive move/resize. Transient mouse events are coalesced in the intake before draining.
SkyLight events (Core/SkyLight/CGSEventObserver.swift). Registers for window-server notifications and posts them into the intake:
enum CGSWindowEvent {
case created(windowId, spaceId)
case destroyed(windowId, spaceId)
case frameChanged(windowId)
case closed(windowId)
case frontAppChanged(pid)
case titleChanged(windowId)
}Window create/move/front-app events originate here; AX destroy/miniaturize/focused-window-changed come from the per-app AX observers.
File: Sources/OmniWM/Core/Rules/WindowRuleEngine.swift
decision(facts) -> WindowDecision compiles user rules + built-in rules into CompiledRules and ranks matches by specificity then declaration order. Evaluation precedence (first decisive match wins):
- System text-input panels → unmanaged
- Explicit user rule (bundle ID, app name, title literal/regex, AX role/subrole)
- Explicit built-in rule (default-floating apps, browser PiP regex, Steam tile)
- CleanShot recording overlay → unmanaged
- Required-title-missing → deferral
- App in native fullscreen → managed
- Attribute-fetch failure → deferral
AXWindowServiceheuristic (size constraints, role/subrole)
struct WindowDecision {
let disposition: WindowDecisionDisposition // .managed/.floating/.unmanaged/.undecided
let source: WindowDecisionSource // .manualOverride/.userRule(UUID)/.builtInRule/.heuristic
let workspaceName: String?
let ruleEffects: ManagedWindowRuleEffects // minWidth/minHeight
}For the protocol spec, wire format, and CLI reference, see IPC-CLI.md. This section covers the internal code architecture. The current wire protocol version is 5 (Sources/OmniWMIPC/IPCModels.swift).
omniwmctl OmniWM process
───────── ──────────────
CLIParser IPCServer (AF_UNIX accept loop on a DispatchQueue)
│ │ getpeereid == geteuid
IPCClient ──── Unix socket ────► IPCConnection (actor, per client; NDJSON, 64 KiB/line)
(NDJSON) │
IPCApplicationBridge (actor)
│ auth token + protocol version
┌───────────┼───────────────┐
│ │ │
commands/window/ queries rule ops
workspace (read projection) (add/replace/…)
│ │ │
EventIntake.post(.ipcCommand) │ @MainActor routers built fresh per request
v v v
single-writer IPCQueryRouter IPCRuleRouter
pipeline (live WM state) (settings + reevaluate)
Mutating commands enter the single-writer pipeline. IPCApplicationBridge posts an IPCCommandIntake into EventIntake (.ipcCommand); the interpreter runs intake.perform(controller) on the main actor and completes the request. IPC commands do not mutate state directly — they flow through the same intake → world path as hotkeys.
Actors and routers. IPCApplicationBridge, IPCConnection, IPCEventBroker, and IPCConnectionRegistry are actors; the routers (IPCCommandRouter/IPCQueryRouter/IPCRuleRouter) and IPCRuleProjection are @MainActor and constructed fresh per request. IPCEventBroker holds per-channel AsyncStream continuations; IPCEventDemandTracker is an NSLock-guarded refcount so hasSubscribers can be checked nonisolated to skip producing events nobody wants. IPCAutomationManifest (in OmniWMIPC) is the shared declarative source of truth for commands/queries/channels.
Security. The trust boundary is the local user account. Each session carries an authorization token written newline-terminated at <socket-path>.secret with 0600 perms; the server enforces socket permissions 0600, creates socket directories 0700, and verifies the peer UID via getpeereid().
Directory: Sources/OmniWM/Core/Ax/
Per-app threading. AXManager keeps an AppAXContext per process. Each context spins a dedicated NSThread/CFRunLoop and performs all of that app's AXUIElement reads and writes there, plus its AX observers (window destroy/miniaturize + focused-window-changed). Per-thread state is pinned with ThreadGuardedValue against a @TaskLocal appThreadToken.
Frame application. AXManager.applyFramesParallel (still the live entry point — "parallel" refers to the per-app thread fan-out, not GCD) coalesces requests per pid and dispatches one setFramesBatch to each app's thread. The verification and retry bookkeeping lives in AXFrameApplicationLedger:
prepareFrameApplicationdedups a target against the last-applied / pending frame within tolerance.- The write happens on the app thread via
AXWindowService.setFrame(writeskAXSize/kAXPositionin order, then reads back to verify). handleFrameApplyResultsverifies observed vs. target; on mismatch it retries within a per-window budget (retryBudgetByWindowId, default 1) — re-enqueued synchronously byAXManager, scheduled via a per-windowTask { @MainActor }generation counter, not theDeadlineWheel.- On repeated mismatch it calls
learnSizeQuantumto record the app's snap quantum (capped at 16pt), so OmniWM stops fighting apps that round their own size to a grid.
Inactive-workspace suppression. Windows on non-visible workspaces are tracked in AXManager.inactiveWorkspaceWindowIds (a Set<Int> rebuilt by LayoutRefreshController) and checked live before each write, avoiding pointless AX calls and visual glitches.
Directory: Sources/OmniWM/Core/Spaces/
OmniWM requires the macOS "Displays have separate Spaces" setting to be ON (SkyLight.displaysHaveSeparateSpaces, backed by SLSGetSpaceManagementMode); when it is OFF the window-management runtime does not start (the app stays alive with a status-bar warning), and an unavailable reading fails open so a missing private symbol never bricks tiling.
SpaceTopology is a pure value model of the macOS Spaces layout: per-display space lists + current space, the global active space (kept only as a frontmost-display hint), the set of fullscreen-type spaces, and a window→space map, with read-only derivations (isCurrentSpace, isFullscreenSpace, isWindowOnKnownInactiveSpace, selectWindowSpace, …). Because each display has its own active space, per-window space decisions use the per-display current space (isCurrentSpace) rather than the single global active space — e.g. reconcileNativeFullscreenWithTopology suspends a window whose fullscreen space is current on its own display. SpaceTracker is a @MainActor stateless transform that runs whenever services are active (it no longer gates the safety-critical refresh on settings.spacesTrackingEnabled): it rebuilds a fresh SpaceTopology from read-only SkyLight queries (CGSCopyManagedDisplaySpaces, CGSCopySpacesForWindows, selecting a window's desktop space via SpaceTopology.selectWindowSpace) and commits it through WorldStore. Refresh is driven by activeSpaceDidChange and activeDisplayDidChange. The durable topology lives on WorldStore (private(set) var spaceTopology), not in the tracker.
Native-inactive safety. Windows on a known inactive native Space are left to macOS: they are frame-write-suppressed (even when their OmniWM workspace is active) and never physically parked off-screen, and a window created on an inactive native Space defers admission until its Space becomes current. The suppression self-heals — it clears on the next topology refresh once the Space is current, and no-ops when a window's Space is unknown.
Native fullscreen is now derived from facts, not inferred from AX element lifecycle:
- The old AX destroy/recreate inference (speculative-preserve heuristics, recreate-before-admission, timeout cleanup) was fully removed.
- The
NativeFullscreenAvailabilityenum and theisAppFullscreenActivestored boolean were removed.NativeFullscreenRecordnow holds onlyoriginalToken,currentToken,workspaceId,exitRequestedByCommand, andtransition. WorkspaceManager.isAppFullscreenActiveis a computed property derived from the records:nativeFullscreenRecordsByOriginalToken.values.contains { $0.transition == .suspended }.
Native fullscreen is co-driven by two observed facts: (1) SkyLight fullscreen-space membership (SpaceTracker.reconcileNativeFullscreenWithTopology) and (2) the AX-observed focusedWindow.isFullscreen at activation (AXEventHandler). When a managed window enters native fullscreen its management is suspended (markNativeFullscreenSuspended) and SurfaceReconciler derives an "In macOS Full Screen" placeholder panel (NativeFullscreenPlaceholderManager); on exit the record is removed and management restored.
Directories: Sources/OmniWM/Core/Surface/, Sources/OmniWM/Core/Border/
WorldView is a read-only @MainActor facade wrapping a single WMController. It exposes exactly the state SurfaceDerivation needs (renderable focus token, fullscreen flags, monitors, space topology, border config, per-window observed/pending frames) plus helpers that build tab-rail infos, bar surfaces, and native-fullscreen placeholders. It holds no mutable state and is constructed fresh per reconcile pass.
SurfaceDerivation.derive(world:) is a pure transform WorldView → DesiredSurfaceScene. The border-eligibility gate in deriveBorder is the load-bearing logic: border config enabled, target not an owned OmniWM surface, no pending native-fullscreen transition, not suppressed/fullscreen, workspace visible, valid frame.
The focus border is no longer an NSWindow managed by a dedicated controller. It is a derived surface applied by BorderSurfaceApplier, which drives a BorderWindow — a private SkyLight/CGS server-side window (created via SkyLight.createBorderWindow, drawn into a CGContext), positioned one level below the target window via transactionMoveAndOrder(.below), and registered with SurfaceCoordinator by CGS window number.
SurfaceCoordinator (a .shared singleton) is the registry of OmniWM-owned surfaces, backed by SurfaceScene. Beyond "exclude from tiling" it answers hit-testing (containsInteractive), ScreenCaptureKit capture-eligibility (isCaptureEligible), and focus-recovery suppression (hasFrontmostSuppressingWindow). The vocabulary lives in SurfaceScene.swift: SurfaceKind (border, workspaceBar, overview, nativeFullscreenPlaceholder, tabbedColumnOverlay, dragGhost, utility, quake), HitTestPolicy, CapturePolicy, and SurfacePolicy (which bundles them plus suppressesManagedFocusRecovery). OwnedWindowRegistry (in App/) is now a thin facade over SurfaceCoordinator.shared.
Directory: Sources/OmniWM/Core/Animation/
SpringAnimation/SpringConfig— a closed-form damped-spring solver sampled by absoluteCACurrentMediaTime.offsetBy(_:)rebases both endpoints so the world can re-anchor a viewport mid-flight. The named presets (niriHorizontalViewMovement,niriWindowMovement,niriWindowResize, and thesnappy/balanced/gentle/reducedMotion/defaultaliases) are all the same critically-damped curve (dampingRatio 1.0,stiffness 800);resolvedForReduceMotionis currently a no-op.CubicAnimation— cubic-bezier easing used by the Dwindle path.AnimationDriver— owns the per-workspace viewport scroll motion only (gesture or spring). It is seeded from inside the commit path (reconcileViewportCommitre-seeds the spring from a committedViewportStatetransition) and sampled per frame byNiriLayoutHandler. Per-window/column animations live in the Niri engine, not here.SwipeTracker— accumulates trackpad deltas over a 150ms window and projects an inertial throw target that a spring snaps to.AnimationClock— a monotonic accumulating clock overCACurrentMediaTime, held by the engines andWMController.MotionPolicy— a@MainActor @Observablesingle boolean (animationsEnabled) seeded from settings; it gates non-gesture scroll animations. It does not read the OS reduce-motion setting (that is consulted separately in UI views).
The per-frame display link is owned by LayoutRefreshController (not by Animation/); see 3.9.
Directory: Sources/OmniWM/Core/Clipboard/
ClipboardHistoryService polls NSPasteboard.changeCount every 0.5s, captures changed contents off-main through a pasteboard reader (filtering out 1Password/transient/concealed types), and feeds them to ClipboardHistoryStore — a Swift actor that deduplicates by SHA-256 digest, maintains MRU ordering, prunes by item/byte limits, and atomically persists to clipboard-history.json (0600). History is surfaced as the clipboard mode of the Command Palette; WMController exposes clipboardPaletteItems() / copyClipboardItem(id:) / deleteClipboardItem(id:) / clearClipboardHistory().
| Feature | Key Files | Description |
|---|---|---|
| Overview | Core/Overview/OverviewController.swift |
Expose-style workspace overview. Rendered with Core Graphics (OverviewView.draw → OverviewRenderer.render(context: CGContext)), not Metal; thumbnails via ScreenCaptureKit (SCScreenshotManager, ≤4 concurrent). Search, drag-to-reorganize. |
| Quake Terminal | QuakeTerminal/QuakeTerminalController.swift |
Drop-down terminal on GhosttyKit. Each tab is a tree of split panes (QuakeTerminalTab → QuakeSplitContainer/SplitNode), each a GhosttySurfaceView (CAMetalLayer-backed). Slide-in/out animation; registers as a .quake surface. |
| Command Palette | UI/CommandPalette/CommandPaletteController.swift |
Fuzzy search over windows, commands, and clipboard history. |
| Menu Anywhere | UI/MenuAnywhere/MenuAnywhereController.swift |
Pops the frontmost app's menu bar as a native NSMenu at the cursor, via MenuExtractor (ObjC runtime AX-tree walk). |
| Workspace Bar | UI/WorkspaceBar/WorkspaceBarManager.swift |
Per-monitor workspace bars — now driven by SurfaceReconciler via apply([DesiredBarSurface]), not self-polling. |
| Hidden Bar | UI/HiddenBar/HiddenBarController.swift |
Menu-bar collapse/expand separator. |
| Status Bar | UI/StatusBar/StatusBarController.swift |
Menu-bar icon, settings access, manual update checks. |
| Scratchpad | Core/Workspace/WorkspaceManager.swift |
Single transient window (scratchpadToken on WorldStore); show/hide coordinated by WMController. |
| Monitors | Core/Monitor/ |
Display detection (Monitor.current()), stable identity (OutputId), and MonitorRestoreAssignments (re-maps saved per-monitor workspaces after a topology change by displayId then geometry/name best-match). Orientation reported over IPC is the effective orientation (settings.effectiveOrientation — override or auto). |
| Sleep / Lock | Core/Sleep/, Core/LockScreen/ |
SleepPreventionManager (IOPM assertion), LockScreenObserver (DistributedNotificationCenter lock/unlock). |
| Release Updater | App/UpdateCoordinator.swift |
Polls the latest GitHub release once per day, supports manual checks, shows a release-notes popup. |
User presses a focus hotkey (e.g. focus-left). Note how the IntentLedger makes the resulting AX echo classifiable as our own action:
HotkeyCenter.dispatch → onCommand [INTAKE transport]
│ Hotkeys.swift:463
v
EventIntake.enqueue(.hotkeyCommand) → drain [STAGE 1]
│ CFRunLoopPerformBlock on main
v
EventInterpreter.handleIntakeEvent → CommandHandler.handleHotkeyCommand
│ EventInterpreter.swift:60
v
CommandHandler → executeCombinedNavigation → WMController.focusWindow
│ resolves the target NiriNode
├──> IntentLedger.beginManagedRequest(token) records .focusWindow Intent
│ + DeadlineWheel 100ms settle deadline (so the echo = echoOf)
└──> WorkspaceManager.beginManagedFocusRequest
v
WorldStore.commit(.managedFocusRequested) [STAGE 2] seq++
│
v
WMController.performWindowFronting [STAGE 3 — effector]
│ activateApp + focusSpecificWindow + raiseWindow (private APIs)
v
macOS emits AX focused-window-changed echo
│ AppAXContext observer → EventIntake.post(.axFocusedWindowChanged)
v
EventInterpreter → AXEventHandler.handleAppActivation [STAGE 1 re-entry]
│ FactResolver.resolveActivationFacts (off-main) → EventIntake.post(.activationFactsResolved)
v
AXEventHandler.handleActivationFactsResolved
│ IntentLedger.classifyFocusObservation → .echoOf (confirmation, not external)
v
WorkspaceManager.confirmManagedFocus → WorldStore.commit(.managedFocusConfirmed) seq++
│ IntentLedger.confirmManagedRequest cancels the deadline
v
WMController.handleSessionStateChanged → SurfaceReconciler.noteWorldChanged [STAGE 4]
│ SurfaceDerivation.deriveBorder reads WorldView.renderableFocusToken
v
BorderSurfaceApplier moves the focus border to the newly focused window
An application opens a new window:
macOS window server creates window
│
v
CGSEventObserver.handleRawCGSEvent → EventIntake.post(.cgs(.created)) [INTAKE]
│ CGSEventObserver.swift:120
v
EventIntake stamps seq + schedules one drain (CFRunLoopPerformBlock) [STAGE 1]
v
EventInterpreter → AXEventHandler.handleCGSEvent → handleCGSWindowCreated
│ → processCreatedWindow → trackPreparedCreate (reads AX attrs, runs rules)
v
WindowRuleEngine.decision(facts) → .managed / .floating / .unmanaged
v
WorkspaceManager.addWindow → recordReconcileEvent(.windowAdmitted) [STAGE 2]
│ WorldStore.commit: seq++, model.upsert, EventNormalizer,
│ StateReducer.reduce → ActionPlan, InvariantChecks, ReconcileTxn
v
AXEventHandler → LayoutRefreshController.requestRelayout(.axWindowCreated) [STAGE 3]
│ buildRelayoutEffectPlan (async, under withEngineBuildScope) → EffectPlan
v
LayoutRefreshController.executeEffectPlan → AXManager.applyFramesParallel
│ per-pid batch → AppAXContext.setFramesBatch on the app's AX thread
│ AXFrameApplicationLedger verifies / retries / learns size quantum
v
SurfaceReconciler.noteWorldChanged → WorldView → border/bar diff-applied [STAGE 4]
User runs omniwmctl command focus left:
CLIParser.parse → IPCRequest { kind: .command, payload: focus(left) }
v
IPCClient connects to the Unix socket, sends NDJSON
v
IPCServer accepts → IPCConnection (actor) reads the line → IPCRequest
v
IPCApplicationBridge (actor): verify token + protocol version 5
│ for mutating commands: EventIntake.post(.ipcCommand(intake))
v
EventInterpreter (.ipcCommand) → intake.perform(controller) [STAGE 1]
│ → CommandHandler.performCommand(.focus(.left)) (same path as 5.1 from here)
v
ExternalCommandResult → IPCResponse { ok: true } → NDJSON → client
v
CLIRenderer displays the result
- Add the enum case in
Core/Input/HotkeyCommand.swift. - Add the action spec in
Core/Input/ActionCatalog.swift(title, keywords, category, layout compatibility, default binding). This is the source of truth for the command palette and default bindings. - Handle it in
Core/Controller/CommandHandler.swift— set the rightLayoutCompatibilityso the guard accepts it under the active layout. Mutations must reach the world throughWorkspaceManager.recordReconcileEvent, never by touchingWindowModel/engines directly. - Expose via IPC (optional) in
IPC/IPCCommandRouter.swiftand the manifest (OmniWMIPC/IPCAutomationManifest.swift); add the CLI name inOmniWMCtl/CLIParser.swift.
- Define the response model in
OmniWMIPC/IPCModels.swift. - Implement the read-only projection in
IPC/IPCQueryRouter.swiftfrom liveWMController/WorkspaceManagerstate. - Add CLI rendering/parsing in
OmniWMCtl/, and the descriptor inIPCAutomationManifest.swift.
- Add the property to
Core/Config/SettingsStore.swift(give it adidSetthat callsscheduleSave()if it should persist). - Wire runtime behavior in
WMController.applyPersistedSettings()or the consuming handler. - Add UI under
Sources/OmniWM/UI/. - Thread it through the TOML model:
SettingsExport.swift,CanonicalTOMLConfig.swift,SettingsTOMLCodec.swift.settings.tomlis the only settings source of truth — verify it survives encode/decode. Operational/runtime state (updater timestamps, restore catalog, palette mode) belongs inRuntimeStateStore(runtime-state.json), not the TOML.
- Pick the engine:
Core/Layout/Niri/orCore/Layout/Dwindle/. - For Niri, find the right
NiriLayoutEngine+*.swiftextension (+ColumnOps,+Sizing,+TabbedMode,+WindowOps,+WorkspaceOps,+Animation, …); navigation is inNiriNavigation.swift, constraint solving inNiriConstraintSolver.swift. - Keep engines pure: no AX calls, no frame writes. Any engine mutation must run inside a commit or a
withEngineBuildScope— the engines assert otherwise. Emit a frame map; letNiriLayoutHandler/DwindleLayoutHandlerbuild theEffectPlan.
@_silgen_namedeclarations live inCore/PrivateAPIs.swift; runtimedlopen/dlsymwrappers inCore/SkyLight/SkyLight.swift.- Wrap every private call in a safe Swift function with a fallback. Private APIs can break across macOS versions — verify behavior across versions and prefer public APIs where possible.
| Term | Definition |
|---|---|
EventIntake |
The single ordered buffer all transports post into; monotonic global seq; one main-run-loop drain per cycle. |
EventInterpreter |
The drain sink — a pure switch that dispatches each IntakeEvent to a WMController sub-handler. Does not classify or commit. |
FactResolver |
Gathers the off-main activation-focus fact and re-enters the intake via .activationFactsResolved. |
IntentLedger |
Ring buffer of focus/activation Intents; classifyFocusObservation returns echoOf/lateEcho/external. |
DeadlineWheel |
Main-actor timing wheel; posts .intentExpired back into the intake. Drives intent settle/expiry, not frame retries. |
WMEvent |
The typed, exhaustive event consumed by WorldStore.commit. |
WorldStore |
The single synchronous writer. Owns WindowModel, focus, viewports, monitor sessions, space topology, and both engines (all private). |
commit |
WorldStore.commit(_:…) — normalize → reduce → resolve → invariants; bumps seq. The only mutation path. |
withEngineBuildScope |
Sanctions engine mutation outside a commit (for async plan-building) without bumping seq. |
ActionPlan |
Pure output of StateReducer.reduce — per-domain state deltas + a ViewportPlan + notes. |
EffectPlan |
Effector-side plan (Core/Layout/LayoutBoundary.swift): per-workspace layout diffs + seq-gated post-layout actions. Built by the layout handlers. |
InvalidationMarks |
Per-domain seq watermarks used to drop layout plans that were built against a now-stale world. |
InvariantChecks |
Post-commit consistency checks. .assert violations crash in debug; three layout checks are .trace (log-only). |
WindowToken |
Value type (pid + windowId). Primary dictionary key; survives AX recreation via rekey. |
WindowHandle |
Reference-identity wrapper around a WindowToken; re-pointed on rekey. |
AXWindowRef |
Accessibility bridge (AXUIElement + windowId); equality by windowId. |
WindowState |
Per-window value record stored in WindowModel (replaces the old WindowModel.Entry). |
WindowModel |
Reference-type per-window registry, now private to WorldStore. |
FocusSessionSnapshot |
Value type holding focused token, pending managed focus, per-workspace last-focused, lease, etc. (on WorldStore.focus). |
MonitorSession |
Per-monitor visible/previous workspace (on WorldStore.monitorSessions). |
ViewportState |
Niri per-workspace scroll/selection state, stored in WorldStore.viewports. |
LayoutRefreshController |
The effector: schedules refreshes, runs the display-link loop, executes EffectPlans. |
RefreshReason / RefreshRequestRoute |
Why a refresh was requested, and which route it maps to (fullRescan/relayout/immediateRelayout/visibilityRefresh/windowRemoval). |
AXManager |
Per-app AX frame writer; owns AXFrameApplicationLedger. applyFramesParallel = per-app thread fan-out. |
AXFrameApplicationLedger |
Dedups, verifies, retries, and learns a per-window size quantum for frame writes. |
SurfaceReconciler |
Stage 4: derives border/bars/tab-rails/native-fullscreen placeholders from WorldView and diff-applies them. |
WorldView |
Read-only facade over world state used by SurfaceDerivation. |
SurfaceCoordinator / SurfaceScene |
Registry + policy store for OmniWM-owned surfaces (hit-testing, capture exclusion, focus-recovery suppression). |
SpaceTopology |
Pure value model of the macOS Spaces layout (per-display spaces, current/fullscreen spaces, window→space map). |
SpaceTracker |
Stateless transform that rebuilds SpaceTopology from read-only SkyLight queries and commits it. |
NativeFullscreenRecord |
Per-window record (originalToken, currentToken, workspaceId, exitRequestedByCommand, transition) from which isAppFullscreenActive is derived. |
AnimationDriver |
Owns per-workspace viewport scroll motion (gesture/spring). |
SpringConfig |
Spring parameters; presets are all the same critically-damped curve. |
MotionPolicy |
Single-boolean animations-enabled gate (does not read OS reduce-motion). |
HotkeyCommand |
Enum of every command that can be triggered by hotkey or IPC; carries LayoutCompatibility. |
WindowDecision |
Rule-evaluation result: disposition, source, workspaceName, ruleEffects. |
The redesign's north star is one authoritative world with one writer. Several otherwise-reasonable refactors were deliberately not pursued because they would distribute truth or mutation across more objects, working against that goal:
- Ledger fold (not pursued). Folding
IntentLedger(focus/activation intents) andAXFrameApplicationLedger(frame-write verification) into one type was considered and rejected. They are two clean, non-overlapping truths on different stages of the pipeline; merging them would add coupling with no single-source-of-truth benefit. - God-file dissolution (not pursued).
WMController,WorkspaceManager,LayoutRefreshController, andAXEventHandlerare large, but their size comes from logic, not from duplicated state — the world is already centralized inWorldStore. Mechanically extracting sub-objects would scatter state and mutation across more coordinating objects, i.e. move away from the single-writer model. Size alone is not a reason to split here. - "Everything through commit" (deferred, tracked separately). Today the async layout plan-build mutates the engines under
withEngineBuildScoperather than insidecommit, and the 60–120Hz animation tier mutates engine/viewport offsets outsidecommitentirely (see 3.9). Routing plan-build throughcommitwould let the three.traceinvariant checks (layout_token_missing,layout_token_wrong_workspace,selection_unresolved) become hard asserts and close a one-cycle staleness window. Butcommitis synchronous while plan-build is async, and the animation tier must stay ungated for responsiveness — so this is a multi-week redesign with real risk to animation/responsiveness for a modest gain. It is deferred and scoped on its own, not bundled here.
Long-standing names that a returning contributor may search for, and what replaced them:
| Removed / renamed | Now |
|---|---|
RuntimeStore / RuntimeStore.transact |
WorldStore.commit (Core/World/), entered via WorkspaceManager.recordReconcileEvent |
SessionState (single type) |
Split into FocusSessionSnapshot, MonitorSession, viewports, scratchpadToken on WorldStore |
WindowModel.Entry (nested struct) |
WindowState (top-level value type) |
BorderManager / FocusBorderController / BorderCoordinator |
Derived surface: SurfaceReconciler → BorderSurfaceApplier → BorderWindow |
FocusBridgeCoordinator |
Managed focus split across WMController, AXEventHandler, WorkspaceManager, IntentLedger |
isAppFullscreenActive (stored flag) |
Derived from NativeFullscreenRecords |
| AX destroy/recreate native-fullscreen inference | Topology (SpaceTracker) + AX-observed fullscreen at activation |
KeyboardFocusLifecycleCoordinator.swift still exists but now holds only value types (KeyboardFocusTarget, ManagedFocusOrigin, ManagedFocusRequest); it is not a coordinator class. WindowModel, AXManager, and ReconcileTraceRecorder were not removed — WindowModel is now private to WorldStore, and AXManager remains the per-app frame writer.