sk heavy industries

Where Am I? Identifying & Mapping macOS Virtual Desktops

I've used virtual desktops since my early Linux days. On macOS, I keep about ten Spaces open and bind each one to a hotkey. ctrl+1 is desktop 1, ctrl+2 is desktop 2, and so on. Music & media live in desktop 8, whether on my work or personal laptop, so that one is muscle memory.

The middle desktops are the problem. Desktops 3 through 7 churn as projects come and go, and I lose track of which is which. I'll hit ctrl+5 expecting what I was just looking at and land somewhere else.

macOS gives you the shortcuts to jump between Spaces, but nothing tells you where you landed. I wanted a small permanent "you are here" in the menu bar and I'd wanted it for years. Agentic development finally made it quick enough to build.

Virtual Desktop Badge is a menu-bar app that shows the current desktop number in a small box. No Dock icon, window or preferences screen. Just the number with an optional label beside it.

The badge in the menu bar

It's out! macOS 13+, MIT-licensed, source and a signed build on GitHub.

That's the pitch and the rest of this post is the interesting part. I was surprised by some of the hoops I had to jump through to get this working.

The problem: there is no API for this

You'd think "what Space number am I on" is a one-line system call, but it's not. macOS has no public API that answers that. The window-management APIs were narrowed years ago and Spaces ended up on the wrong side of that line. You can switch Spaces and you can be told a switch happened but you cannot ask which one is showing right now through anything documented.

So the number on the badge comes from two signals, each covering for the other's weakness.

Signal one: the keyboard, for instant feedback

The shortcuts I actually use are ctrl+1 through ctrl+0. When I press one I want the badge to update instantly. There is a global key monitor that handles that.

The detail that matters is matching keys by physical position, not by the character they produce. Match on the character and the badge breaks the moment someone runs a non-US layout or remaps their number row. Key codes don't care about layout. The key in the "1" slot is the same code whether it types 1, &, or anything else.

// ctrl + a number-row key, matched by physical position (layout-independent)
// kVK_ANSI_1 … kVK_ANSI_0  ->  desktops 1 … 10
if event.modifierFlags.contains(.control),
   let desktop = desktopNumber(forKeyCode: event.keyCode) {
    badge.setNumber(desktop)   // immediate, no private API involved
}

This path is fast and needs no private API, but it only knows about the switches it sees. A trackpad swipe never touches those keys. Neither does a Mission Control click or a jump to desktop 11. Left alone the keyboard path drifts out of sync with reality.

Signal two: the system notification, for ground truth

The second signal is always correct but it's just not always instant. NSWorkspace.activeSpaceDidChangeNotification fires on every Space change. Keys, swipe and Mission Control. When it fires the badge throws away its guess and re-derives the real number from scratch.

"From scratch" is where the private API shows up. CGSCopyManagedDisplaySpaces hands back the ordered list of Spaces per display. Walk the list for the primary (menu-bar) display, find the active one, and its position is your 1-based desktop number.

// fires on any Space change; this path is the source of truth
center.addObserver(forName: NSWorkspace.activeSpaceDidChangeNotification, ) { _ in
    let spaces = CGSCopyManagedDisplaySpaces(connection)   // private, undocumented
    badge.setNumber(activeSpaceIndex(on: primaryDisplay, in: spaces))
}

So the work splits cleanly and the keyboard gives you a zero-lag answer on the common case. The notification corrects it a fraction of a second later, after any switch however it happened so you get the speed without having to trust the fast path.

Quarantining the part that will break

CGSCopyManagedDisplaySpaces is private and undocumented, which is a liability. Apple can change it or remove it in any major macOS release. I don't want that risk spread across the whole codebase.

So every line that touches the private API lives in one file: CGSBridge.swift. Nothing else in the app knows it exists. If a future macOS breaks it there's exactly one place to look, and the keyboard path keeps the badge useful while I fix it. Containing the blast radius of the sketchy dependency was the design decision I cared most about.

The rest of the layout follows the same instinct. Everything that decides what number and label to show lives in a pure core, VirtualDesktopBadgeCore and it makes no system calls. Raw Spaces data goes in and a typed model and a 1-based index come out. The AppKit executable is thin wiring on top: the status item, the key monitor, the notification observer. Because the core is pure I can unit-test the Space-number logic with no GUI and no running macOS session.

VirtualDesktopBadgeCore/     pure, unit-tested, zero system calls
  DisplaySpaces.swift          raw CGS data  ->  typed model
  SpaceIndex.swift             active space  ->  1-based desktop number
  BadgeRenderer.swift          number        ->  menu-bar image
  …
virtualdesktopbadge/         the AppKit glue
  CGSBridge.swift              the one file touching the private API
  AppDelegate.swift            status item, key monitor, notification, menu

Labels, because a number isn't always enough

A number tells you where you are but not what's there, so the badge shows a label beside it. If you've set a manual note it shows that, like yolo GME R&D or fable hax vs freebsd. If you haven't, it shows the apps on that desktop instead: frontmost first, up to three, then +N and updates live as you open and close windows.

A manual note versus the automatic app list

What it doesn't do

  • The instant keyboard path only covers ctrl+1ctrl+0, which is desktops 1 through 10. Those are the number-row keys. Higher desktops still show the right number through the notification path but miss the zero-lag shortcut.
  • Auto app-labels only reflect the desktop you're looking at. macOS won't tell you what's on the Spaces you aren't viewing. That's fine. The badge only needs the current one.
  • The number derivation leans on that private API. A major macOS release could change it and force a patch. See "one file" above.

Get it

macOS 13 (Ventura) or later. Grab the latest .zip from Releases, or build from source with ./scripts/package_app.sh. It's ad-hoc signed rather than notarized, so macOS asks you to allow it once on first launch. Feedback and pull requests welcome.

Stack: Swift, AppKit, Swift Package Manager, XCTest; a private CoreGraphics (CGS) Spaces API, quarantined to one file.