Aerospork (GitHub Repo)
AeroSpork brings a settings GUI and robust monitor tracking to the AeroSpace window manager, specifically targeting reliability for DisplayLink dock users.
Deep dive
- Implements CoreGraphics-based monitor fingerprinting to track displays across undock/redock events.
- Replaces complex dependency chains with direct calls to POSIX/Carbon/CoreAudio APIs to reduce attack surface and build fragility.
- Uses a line-based TOML writer for the GUI to preserve user comments and manual configurations.
- Requires Developer ID signing and Sparkle for updates due to lack of App Store sandbox compatibility.
- Optimizes layout events via a 50ms refresh debouncer to improve performance over framebuffer-linked docks.
Decoder
- DisplayLink: A proprietary technology that uses a USB-to-Display adapter, often complicating window management because it reports different identifiers than native video ports.
- EDID: Extended Display Identification Data, which monitors use to communicate capabilities to the OS; these are often missing or unreliable on DisplayLink hardware.
- Notarization: A process where Apple scans software for malicious content and issues a ticket, allowing macOS to run the app without triggering security warnings.
Original article
AeroSpork — an i3-like tiling window manager for macOS
AeroSpork is an i3-style tiling window manager for macOS. Windows are leaves of a layout tree, workspaces are emulated rather than mapped onto native Spaces, and nothing requires disabling System Integrity Protection. It is configured in TOML, driven from a CLI, and ships a settings GUI.
It is a fork of AeroSpace by Nikita Bobko, which is where the tree model, the workspace emulation and most of the command surface come from. Both are MIT licensed.
Why I forked it
I ran AeroSpace daily on four monitors behind a DisplayLink dock, and three things wore me down. It felt sluggish. Long sessions drifted, so state that was correct at login was not correct by the evening. And the DisplayLink panels were a coin flip: workspaces came back on the wrong screens after every undock, because monitors are matched by name, regex or index, and none of those survive a redock. Two identical displays are indistinguishable to a name match.
I sent the monitor work upstream as PR #1526 in July 2025. It was closed the next day without review. That is the maintainer's call on their own project, and upstream is clear that it keeps a deliberately small surface, so I kept the work here instead.
What that turned into, in this codebase:
- The DisplayLink problem is
model/MonitorFingerprint.swift. A display is matched on the per-display UUID first, then EDID vendor/model/serial from CoreGraphics, then name, then size. DisplayLink panels report no EDID at all, so the UUID is the only key that separates two of them. Screen reconfiguration is also debounced, because a dock connects in several stages and fires the change notification more than once. - The sluggishness is two changes rather than a rewrite. Bursts of accessibility events coalesce into one layout pass on a 50ms debounce (
util/RefreshDebouncer.swift), andMacApp.setFrameskips the AX write when a window already sits at its target frame, which matters over a DisplayLink link where every write repaints a framebuffer. I have not published speedup numbers;dev-docs/performance.mdsays which measurements exist and why the benchmark could not settle the rest. - The drift is mostly workspace lifecycle. Workspaces are created on demand and released when they empty, instead of being materialized for every name a keybinding mentions.
Tech stack
| Concern | Implementation |
|---|---|
| Language | Swift, 6.0 language mode (Package.swift); .swift-version pins toolchain 6.4 |
| Minimum OS | macOS 13.0 (Ventura) |
| UI | SwiftUI/AppKit: a MenuBarExtra and a native pane-toolbar Settings scene |
| Third-party dependencies | TOMLKit (config parsing) and Sparkle (in-app updates) |
| CLI/app IPC | POSIX AF_UNIX stream socket, length-prefixed framing (Sources/Common/util/UnixSocket.swift) |
| Global hotkeys | Carbon RegisterEventHotKey (config/HotkeyBinding.swift) |
| Volume control | CoreAudio (util/SystemVolume.swift) |
| Display identity | CoreGraphics CGDisplayCreateUUIDFromDisplayID, CGDisplayVendorNumber, CGDisplayModelNumber, CGDisplaySerialNumber |
| Window IDs | C shim over the private _AXUIElementGetWindow (Sources/PrivateApi/) |
| Build | SwiftPM for the CLI and debug builds; XcodeGen plus xcodebuild for the .app, since SwiftPM cannot produce a bundle |
Why these choices
If you are weighing this against upstream, the reasoning matters more than the table.
Two dependencies instead of four, and each removal was a wrapper going away. BlueSocket was wrapping a local Unix socket, so it became AF_UNIX directly. HotKey was wrapping Carbon's RegisterEventHotKey, which is one call plus the bookkeeping to unregister it. ISSoundAdditions was wrapping CoreAudio. swift-collections supplied one ordered dictionary. The ANTLR-generated shell grammar parsed command strings that /bin/bash -c already parses. Every one of those is a thing that can break on an OS update, or need a version bump before the app can be rebuilt, in exchange for code the platform already provides. Sparkle is the one addition, and only because there is no App Store update path to inherit.
Display identity comes from CoreGraphics, not IOKit. Upstream reads EDID through IOServiceMatching("IODisplayConnect"). That IOKit class does not exist on Apple Silicon, so the iterator yields nothing and vendor/model/serial come back nil for every display. CoreGraphics returns the same values and adds the per-display UUID, which is the only field that survives a DisplayLink dock.
The private _AXUIElementGetWindow stays, and it costs something. It is the only way to get a window id that is stable across refreshes, and window identity is what the whole tree is keyed on. The price is that the Mac App Store is permanently out: private symbols fail review, and the Accessibility APIs this app is built on do not work in a sandbox anyway. Hence Developer ID signing, notarization, and Sparkle rather than TestFlight.
Workspace placement survives a restart of AeroSpork. Workspaces are emulated, so nothing outside the process knows a window belongs to one; at a cold start a window is bound by where it physically sits, and the workspace chosen for each monitor is the first key-bound name in sort order — which a named workspace like A can never be. Placement is now remembered, keyed on the window id the macOS window server issues. That id is stable for exactly as long as that server runs, so an update, a crash or a Quit keeps it and a logout, a reboot or an application relaunching does not. Where the id is gone there is no honest way to recognise a window — every terminal window reports the same accessibility identifier — so it falls back to placing by location rather than guessing, and on-window-detected remains the way to state intent.
The Xcode project is generated, not committed. project.yml plus XcodeGen produces it, because SwiftPM cannot build an app bundle but a checked-in .pbxproj is a merge conflict waiting to happen. Debug builds skip Xcode entirely.
The config writer is line-based on purpose. Re-serializing the whole file would be far simpler, and would destroy every comment plus anything the GUI cannot model, such as per-monitor gap arrays. Instead it rewrites only the keys you changed, preserves what the panes cannot model (a rich monitor fingerprint, a fallback list) field for field on every save, and refuses the few shapes it cannot rewrite safely, pointing you at the Raw TOML pane. That is what makes a GUI safe to put on top of a dotfile.
Sources/
├── aerosporkApp/ # app entry point (@main)
├── AppBundle/ # the window manager: tree/, layout/, command/, config/, model/, mouse/, ui/
├── Cli/ # command-line client
├── Common/ # shared with the CLI, incl. the socket implementation
└── PrivateApi/ # C shim for _AXUIElementGetWindow
Differences from AeroSpace
| AeroSpace | AeroSpork | |
|---|---|---|
| Monitor matching by hardware UUID / EDID | ❌ name, regex or number only | ✅ also pins DisplayLink panels |
| Settings GUI | ❌ "will never provide a GUI for configuration" | ✅ 7 native panes |
| Notarized builds | ❌ | ✅ signed, notarized, stapled |
| Third-party dependencies | 4 | 2 |
| Config schema | one syntax | v2 shorthand, older syntax still parses |
| Windows keep their workspace across a restart | ❌ | ✅ also their monitor |
| Command surface | larger | smaller |
| Maturity | public beta, larger community | younger fork |
Config schema. mod plus workspaces generates the usual i3 keymap, and [keys], [monitors] and [on-window] replace the longer upstream spellings. An existing config is migrated once on first launch, and only when the result is proven to parse to the same effective configuration; otherwise the file is left alone. The original is kept beside it as *.pre-v2.
Settings GUI. Seven native preference panes in a compact macOS toolbar, over a comment-preserving writer that only rewrites the keys you changed. Opening Settings and changing nothing leaves the file byte-identical, and editing one section never rewrites another. The selected pane is remembered. Raw TOML validates against the same parser the app uses at startup and adds native Find, line numbers, restrained syntax highlighting, section navigation, cursor position, and clickable source diagnostics, so no config key is unreachable from the GUI.
Coming from AeroSpace
A fork, not a drop-in replacement. Configs and scripts need small edits.
AEROSPACE_*environment variables are gone and not aliased. A script reading$AEROSPACE_FOCUSED_WORKSPACEgets an empty string with no error. The names areAEROSPORK_FOCUSED_WORKSPACE,AEROSPORK_PREV_WORKSPACE,AEROSPORK_WINDOW_IDandAEROSPORK_WORKSPACE.if.during-aerospace-startupis spelledif.during-aerospork-startup. Unknown keys are fatal, so the old spelling fails at startup and names the line.- Feature parity is a non-goal. The fork carries less surface area than upstream.
Installation
Download the notarized universal (arm64 + x86_64) build from the releases page, move AeroSpork.app to /Applications, and grant Accessibility permission when prompted. A Homebrew cask is published at wbsmolen/tap:
brew install --cask wbsmolen/tap/aerospork
Installed copies check for updates themselves through Sparkle, against a signed appcast served from aerospork.app/appcast.xml. Updates are verified against an EdDSA public key in the app's Info.plist, so a build refuses anything it cannot verify. Automatic checking is off until you allow it; Check for Updates… in the menu bar checks on demand. There is no App Store update path to inherit, because the Accessibility APIs this app is built on do not work in a sandbox.
Because update checks are the only network request AeroSpork can make, and the Accessibility permission it needs is a broad one, aerospork.app/privacy.html sets out exactly what is stored, what is sent, and what is not: no analytics, no telemetry, and no system profile.
Configuration
AeroSpork reads whichever of these exists, and reports an error at startup if both do: ~/.aerospork.toml or ${XDG_CONFIG_HOME}/aerospork/aerospork.toml (XDG_CONFIG_HOME defaults to ~/.config). With neither, it falls back to a complete default bundled in the app. Saved changes hot-reload, so you never need to run reload-config by hand.
mod = "alt" # generates the i3 keymap: alt-h/j/k/l, alt-shift-h/j/k/l, ...
workspaces = "1-9" # alt-1..9 to switch, alt-shift-1..9 to move a window
[gaps]
inner = 8
outer = 8
[keys] # anything here overrides a generated binding
alt-enter = "exec-and-forget open -na Ghostty"
[monitors] # pin a workspace to a screen
1 = "main"
2 = { uuid = "AAAAAAAA-0000-4000-8000-000000000001" }
[on-window] # where a window goes when it appears
"com.apple.mail" = "move-node-to-workspace 3"
CLI
36 subcommands, with man pages and bash/fish/zsh completion.
aerospork focus left # focus the window to the left aerospork workspace 1 # switch workspace aerospork move-node-to-workspace 2 # move the focused window aerospork layout tiles horizontal vertical # cycle layout aerospork list-monitors # connected displays and how they are identified aerospork --help
Development
./build-debug.sh # SwiftPM debug build into .debug/ (uses ~/.aerospork-debug.toml) ./build-release.sh # signed release; needs a Developer ID Application certificate ./run-tests.sh # tests, format and lint ./build-docs.sh # man pages and docs site
License
MIT. The original AeroSpace copyright is retained alongside the fork's in LICENSE.txt. Active development; features and configuration may still change.
Fresh dev news, AI-digested daily. Never miss a trend at devoured.fyi