A mac-native tiling window manager. Two processes:
-
mcmonad-core (Swift 6): A thin, bulletproof daemon that owns all macOS API interaction. Enumerates windows, moves/resizes them, observes events, registers hotkeys, focuses windows. Contains zero window management logic. It is a servant — it does what it's told and reports what it sees.
-
mcmonad (Haskell): The brain. Owns the StackSet, layouts, manage hooks, configuration-as-code, and the event loop. This IS xmonad, but instead of talking to X11, it talks to mcmonad-core over a Unix socket. Users write Haskell to configure it, just like xmonad. QuickCheck tests verify the model.
catchXisolates exceptions.Mod-qrecompiles and restarts.
The IPC boundary between them is the clean split between pure core and IO shell — the same architectural principle that makes xmonad reliable, but enforced by process isolation.
xmonad's reliability comes from separating pure logic from IO. In xmonad, that boundary is the X monad. Here, we make it a process boundary:
- If Haskell crashes, Swift keeps running. launchd restarts the Haskell process. Swift reconnects. Windows stay where they are.
- If Swift crashes, launchd restarts it. Haskell reconnects and sends the current layout. Sub-second recovery.
- No FFI. Haskell never calls macOS APIs. Swift never runs layout algorithms. Each process does exactly one thing.
- Real QuickCheck. The StackSet is Haskell, tested with actual QuickCheck, not a port or approximation.
- Real xmonad config. Users write Haskell. It compiles. It runs. Just like xmonad.
Distilled from OmniWM's implementation — the APIs and techniques that work. This knowledge lives in mcmonad-core (Swift).
Layer 1: SkyLight (private framework)
/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight via dlopen/dlsym.
The fast path — low-latency window enumeration, bulk moves, event observation.
SLSMainConnectionID()— window server connectionSLSWindowQueryWindows+ iterator API — bulk window enumeration withGetWindowID,GetPID,GetBounds,GetLevel,GetTags,GetAttributes,GetParentID- Transactional moves:
SLSTransactionCreate→SLSTransactionMoveWindowWithGroup→SLSTransactionCommit— atomic batch repositioning SLSDisableUpdate/SLSReenableUpdate— suppress redraws during batch ops- Border windows:
SLSNewWindow(type=2) +SLWindowContextCreate+SLSSetWindowShape
Layer 2: Accessibility (AXUIElement) Required for metadata, size constraints, and resize operations.
AXUIElementCreateApplication(pid)→ per-app element- Read/write
kAXPositionAttribute,kAXSizeAttribute - Read
kAXRoleAttribute,kAXSubroleAttribute,kAXTitleAttribute, button attributes,AXMinSize,AXMaxSize,AXFullScreen _AXUIElementGetWindow(element, &windowId)— critical private bridge from AX → CGWindowIDAXObserverCreate/AXObserverAddNotification— per-app observers
Layer 3: Private process APIs (focus)
getProcessForPID(pid, &psn)+_SLPSSetFrontProcessWithOptions(&psn, windowId, kCPSUserGenerated)SLPSPostEventRecordTo(&psn, eventBytes)— 248-byte synthetic key event
SkyLight callbacks via SLSRegisterConnectionNotifyProc:
| Event | ID | Payload |
|---|---|---|
| Window created | 1325 | spaceId (UInt64 @ 0), windowId (UInt32 @ 8) |
| Window destroyed | 1326 | spaceId (UInt64 @ 0), windowId (UInt32 @ 8) |
| Window moved | 806 | windowId (UInt32 @ 0) |
| Window resized | 807 | windowId (UInt32 @ 0) |
| Title changed | 1322 | windowId (UInt32 @ 0) |
| Window closed | 804 | windowId (UInt32 @ 0) — per-window subscription |
| Front app changed | 1508 | pid (Int32 @ 0) |
Callbacks arrive on arbitrary threads → coalesce → drain on main runloop via
CFRunLoopPerformBlock. Frame change events must be coalesced per-window.
OmniWM's proven filter for deciding which windows to report:
parentId == 0(top-level only)levelin{0, 3, 8}(Normal, Floating, ModalPanel)- Visible attribute bit
0x2OR tag bit 54 - Document tag
0x1OR (floating AND modal) - AX classification: close/fullscreen/zoom buttons, subrole, size constraints
- Cannot remove titlebars. macOS doesn't expose this.
- Cannot move fullscreen windows. Must exit native fullscreen first.
- Some apps resist resizing. Detect via
AXMinSize == AXMaxSize. - Write order matters. Shrinking: size then position. Growing: position then size.
- Per-app AX threads. One thread per app prevents a hung app from blocking the layout cycle.
- Requires "Displays have separate Spaces" OFF for multi-monitor.
- Accessibility permission required. Prompt via
AXIsProcessTrustedWithOptions.
Carbon RegisterEventHotKey with GetApplicationEventTarget(). Dispatch via
EventHotKeyID.id. Hotkey presses are forwarded to Haskell over IPC.
Why xmonad never crashes. This knowledge lives in mcmonad (Haskell).
The StackSet is a purely functional value. Every operation on it is a pure
function StackSet → StackSet. IO happens at the boundary — in xmonad, that's
X11 calls. In mcmonad, it's IPC messages to the Swift daemon.
StackSet
├── current: Screen ← focused workspace (always exactly one)
│ ├── workspace: Workspace { tag, layout, stack :: Maybe (Stack Window) }
│ ├── screen: ScreenId
│ └── screenDetail: ScreenDetail (rectangle)
├── visible: [Screen] ← other monitors (Xinerama)
├── hidden: [Workspace] ← not displayed
└── floating: Map Window RationalRect
The Stack is a zipper: { focus :: !a, up :: [a], down :: [a] }. Focus is
tracked by construction. up is reversed for O(1) focus operations.
Every window appears exactly once. Enforced by API, verified by QuickCheck.
163+ QuickCheck properties: structural invariants, reversibility, idempotence, locality, completeness. Run on random StackSets with arbitrary window/workspace/ screen counts.
catchX :: X a -> X a -> X a
userCodeDef :: a -> X a -> X aAll user code (layouts, hooks) wrapped in exception handlers. A buggy layout cannot crash the WM.
Mod-q: serialize state → recompile xmonad.hs → exec new binary → read state
→ resume. Zero interruption.
All state changes flow through one function:
windows :: (WindowSet -> WindowSet) -> X ()(1) Apply pure transformation, (2) run layouts, (3) send frame commands to mcmonad-core, (4) send focus command, (5) run log hook. No other mutation path.
Users write Haskell. It compiles. The type system catches errors. The full Haskell ecosystem is available. This is non-negotiable — it's what makes xmonad xmonad.
-
mcmonad-core contains zero window management logic. It enumerates, moves, resizes, focuses, and reports. It does not decide which window goes where. It is a dumb pipe to macOS APIs.
-
mcmonad contains zero macOS API calls. All platform interaction goes through IPC. The Haskell process is portable in principle.
-
xmonad is a library dependency.
import qualified XMonad.StackSet as W. We use the real, battle-tested StackSet directly — not a port, not a vendor, abuild-depends: xmonad. StackSet has zero X11 imports; it is fully generic over window type, layout type, workspace ID, screen ID, and screen detail. We instantiate it with our macOS types. -
Exception isolation at every boundary.
catchMin Haskell.do/catchin Swift. A failing AX call, a buggy layout, a broken manage hook — none crash either process. -
IPC is the single point of coupling. JSON-newline over Unix socket. Either process can crash and restart independently.
-
Configuration is compiled Haskell. Users write
~/.config/mcmonad/mcmonad.hs.Mod-qrecompiles and restarts the Haskell process. Swift stays running. -
Nix for tooling. Flake provides GHC + Swift toolchains, builds both binaries, home-manager module runs Swift daemon via launchd.
-
Screens are roles, not sides. mcmonad-core decides which attached display is primary / secondary / tertiary / aux (from the arrangement, overridden by the user's picks in the launcher, keyed by display UUID in the daemon's defaults) and reports the role with each screen's geometry. The brain places workspaces by role (
MCMonad.Affinity) and never knows left from right. Viewing a workspace shows it on its role's screen and moves focus there; xmonad's greedy view survives only behindxmonadClassic. -
Layouts can be ported from xmonad-contrib. Since our
LayoutClassmirrors xmonad's (same methods, different monad), layouts that use onlypureLayoutandpureMessage(most of them) can be copied with trivial changes.
- Accessibility: required by mcmonad-core. Prompt via
AXIsProcessTrustedWithOptions. - Screen Recording: needed by the launcher's OCR screen index (on by default,
ocrIndex = Falseopts out). On macOS 26 nothing prompts; a ScreenCaptureKit query lists MCMonadCore.app in the pane, unticked, and the daemon opens the pane once. Whatever the index reads off the framebuffer stays in mcmonad-core's memory: never on disk, never logged, never on the socket. The same rule binds anything else that touches window pixels. - No other explicit permissions needed.