The App I Needed At 1am

computer mouse

Photo by Saad Jameel on Unsplash

I lost my mouse pointer at one in the morning.

I was dogfooding the latest ActiveSpace build — running it on my own machine for a few days before declaring it shipped — and I’d just woken the screen from lock. I went to do something with my mouse, and there was no pointer anywhere in sight. Just a desktop, sitting there, with all the unhurried indifference of any inanimate object.

Pointer invisibility is one of those things you can almost feel as much as see. The pointer isn’t at the centre of your attention — it’s the thing that directs your attention — so its absence registers as a kind of low-grade vertigo before you consciously notice it’s gone. You move the mouse a bit. Nothing. You move it more emphatically. Still nothing. You start the mental triage: did the screen freeze? Is the mouse unresponsive? Battery exhausted? You tap a key and the menu bar clock ticks; the system isn’t hung. So it’s the pointer specifically. It’s gone somewhere.

I had a strong suspicion about where.

The 640×480 black hole

If you’ve read the third instalment of the stars-align trilogy, you’ll know that ActiveSpace, on single-monitor setups, creates an invisible 640×480 virtual display 6,000 points off-screen to the right. It exists for a structural reason — macOS’s single-display identifier of “Main” breaks the Dock’s gesture handling, and a second display forces the OS into proper UUID-based identifiers, which fixes everything — but it has a side effect: the OS treats the virtual as a perfectly valid place for the pointer to live.

The fence I built into ActiveSpace catches the pointer when it tries to enter the virtual’s space and warps it back to the inside edge of main. It’s a session-level CGEventTap on mouseMoved and the *MouseDragged events, fast, arrangement-aware, and reliable… ninety-nine and a bit per cent of the time. There’s a small window during park-and-unpark of the virtual display — specifically when the screen wakes from lock, while the fence is briefly disarmed because the virtual display it’s policing is mid-move — where a fast pointer throw can slip past. About 750 milliseconds, in practice. Hard to hit deliberately. Easy to hit by accident if you’ve moved your mouse at the wrong moment of waking.

So at 1am, my pointer was at (7000, 240) or thereabouts, sitting peaceably on the virtual where I couldn’t see it. The good news: I knew the bug. The bad news: I’d run out of mouse to use to fix the bug.

What I did about it (the wrong way)

I knew exactly how to recover. CGWarpMouseCursorPosition. Three lines of Swift. I just needed to run it.

I opened Terminal. I typed… commandspace for Spotlight, “terminal”, return. Terminal opens to the focus of the front-most application, which depending on what was up could be helpful or not. I typed in:

echo 'import CoreGraphics; CGWarpMouseCursorPosition(CGPoint(x: 200, y: 200))' | swift -

The pointer reappeared, near the top of the menu bar, exactly where I’d aimed it. Crisis averted. I went to bed at 1.20am, having added the recipe to ActiveSpace’s README under “If your mouse pointer disappears.” A documentation fix, I told myself. The cursor fence catches almost everything; in the rare case it doesn’t, here’s a one-liner.

The next morning I read the README again with a fresher pair of eyes. Two things made me immediately uncomfortable.

First: the recipe asks the user to open a Terminal. To open a Terminal, our user needs to either know the keyboard incantation for Spotlight (which most users do) and the keyboard incantation for opening apps via Spotlight (which most users do) and not have a configuration where Spotlight opens to a focus that interferes with typing (which is mostly fine), and then once Terminal is open, our user needs to paste a Swift one-liner into it. None of which is impossible without a pointer. All of which is meaningfully harder than it would be with one.

Second: the recipe lives on a web page. The web page lives on jorviksoftware.cc. Our user, in order to read the recipe, needs to… navigate… to it… in a browser…

The README was inviting users to play a deeply unfair version of the game where every standard recovery action assumed the precise capability they’d just lost. It read fine; it was useless.

Introducing MouseCatcher

The fix is so obvious that I felt slightly stupid for not having reached for it the night before. What I needed was a tiny app — not a panel, not a setting, not an option somewhere in ActiveSpace itself — an app that repositions the pointer and exits. Spotlight is keyboard-only. commandspace, type mousecatcher, return. If the app is in /Applications, Spotlight finds it. The whole recovery becomes three keystrokes and a twelve-letter word.

Building it

The interesting thing about an app that does one fixed thing and exits is that it has almost no architecture. Here is the entire implementation, give or take:

import Cocoa
import CoreGraphics

_ = CGAssociateMouseAndMouseCursorPosition(1)
let bounds = CGDisplayBounds(CGMainDisplayID())
CGWarpMouseCursorPosition(CGPoint(x: bounds.midX, y: bounds.midY))
exit(0)

CGAssociateMouseAndMouseCursorPosition(1) is defensive: if for some reason the cursor has been detached from mouse motion (rare, but cheap insurance — you don’t want to have warped it to a visible position only to find that wiggling the mouse doesn’t move it), this re-associates the two. CGMainDisplayID() returns the display owning the menu bar, which under normal circumstances is always a real display; the virtual sits off-screen and is never main. CGWarpMouseCursorPosition does the actual warping, in absolute global coordinates that bypass display-edge confinement. The exit is honest about what kind of program this is: a single function call dressed in an .app bundle.

The bundle has LSUIElement = true and the activation policy never escalates, so there’s no Dock flash on launch. The whole thing runs in a few tens of milliseconds and is gone before macOS would have time to draw an icon for it. Spotlight launches it; you don’t see anything happen except the cursor reappearing, near the centre of your main display. Then nothing else, because there is nothing else.

It’s a few lines of Swift, an Info.plist, a build script, and a generated icon. About forty-five minutes of work, including the icon. I have written longer commit messages.

How to ship it

The interesting question wasn’t how to write MouseCatcher; it was how to distribute it. The Jorvik convention is one repo per app, one Release Manager entry per app, one GitHub release per app. MouseCatcher could’ve been all of those things. Three new files in a new repo, an entry in the catalogue, a release page. Done.

It would have been over-engineered. The whole point of MouseCatcher is that it’s a partner of ActiveSpace — you wouldn’t need it without ActiveSpace’s virtual display, you wouldn’t install it without ActiveSpace, and asking users to chase a separate download for the recovery utility for their other utility seemed ridiculous. So MouseCatcher rides in ActiveSpace’s release. One .pkg, one .zip, two .apps.

The implementation is what Apple sometimes calls a helper-app architecture. MouseCatcher.app is built by a Run Script build phase inside ActiveSpace’s Xcode project, then copied into ActiveSpace.app’s Contents/Helpers/ directory. Both bundles get signed, both get notarised, both get stapled. The outer ActiveSpace bundle is what users download; MouseCatcher rides inside, invisible, unseen.

Then, on every launch, ActiveSpace runs a tiny self-deploy step. It reads the version of MouseCatcher embedded inside its own bundle. It reads the version of MouseCatcher (if any) at the deployed location. If they match, it does nothing. If the deployed copy is missing or older, it copies the embedded one across. The result: when ActiveSpace updates and the new release contains a newer MouseCatcher, the deployed copy refreshes silently the next time you launch ActiveSpace. No installer, no permissions prompt, no user action.

Mostly.

The /Applications/Utilities trap

I had originally planned to deploy MouseCatcher to /Applications/Utilities/. Conceptually it’s a utility; conceptually that’s where utilities go; macOS has had that subfolder since approximately the dawn of time. It felt right.

I shipped a build with that target path. ActiveSpace launched. Nothing appeared at the deployed location.

The aslog file showed nothing. The deploy code is wrapped in a do-catch that logs the error description on failure. The error description was… not in the log. Which is interesting because that meant either the deploy hadn’t run at all (likely, given a race condition I’d been worried about), or the deploy had thrown silently somewhere I wasn’t catching (unlikely, given how the code was structured), or the deploy had run, the catch had fired, and the log line had ended up somewhere I wasn’t looking.

It was the third one. The launchd handoff path that ActiveSpace uses on first launch was draining the run loop and exiting before any log writes finished flushing. So the catch fired, but the message was lost. I’d been blind-firing into the void.

The actual cause turned out to be embarrassingly simple. Type this:

ls -lde /Applications/Utilities

And you get something like this:

drwxr-xr-x  5 root  wheel  160 10 Apr 06:23 /Applications/Utilities

Mode 755, owner root, group wheel. The wheel group, not admin. Even an admin user can’t write into /Applications/Utilities without elevation — without a sudo, or an Authorisation Services prompt, or a privileged helper. macOS reserves the folder for system utilities. I’d been quietly blocked every time the deploy ran, and the launchd-related log loss had hidden the evidence.

This is the kind of thing where you find out the assumption you’ve been making for a decade is wrong. /Applications, the parent, is drwxrwxr-x root admin: any admin user can write there. So can the user-driven Mac App Store, drag-from-DMG, .pkg installers from any developer the user trusts. /Applications/Utilities is a different beast. Apple owns it, even on your own machine.

The fix is the path nobody had any reason to feel strongly about: /Applications/MouseCatcher.app, sibling of /Applications/ActiveSpace.app, exactly where every other Jorvik utility installs. The deploy works. The cursor is recoverable. Spotlight finds it.

I’ll concede that aesthetically I’d still prefer it under /Applications/Utilities/. But there is no point in fighting the OS’s permission model for a tiny one-shot application, and inviting users to type their password to install this utility is the kind of UX that makes the cure worse than the disease.

The Spotlight cache plot twist

One last thing. Even after the deploy started working, Spotlight kept surfacing the wrong copy of MouseCatcher.

I’d done a Spotlight cleanup the day before — deleted an iCloud Drive clone of the entire Jorvik source tree (the long-overdue purge of a path that had been causing me headaches for weeks), deleted a backup folder full of stale apps, deleted a few stray Xcode build directories. Reclaimed about ten gigabytes. Dropped a .metadata_never_index marker at the root of ~/Dev/Jorvik Software/ so Spotlight would stop indexing build artefacts in there going forward. Going forward. The cached entries from before the marker was added were still in the index.

What that meant practically: commandspace → “MouseCatcher” and Spotlight returned /Users/<username>/Dev/Jorvik Software/ActiveSpace/MouseCatcher/MouseCatcher.app — the build artefact in the source tree — because that path had been indexed before the marker existed. The marker stops new indexing; it doesn’t flush old entries.

The way to flush is to add the path to System Settings → Spotlight → Privacy. macOS treats that as “immediately drop everything you have for paths under here.” You can then optionally remove the path from Privacy afterwards (the marker file keeps it from being re-indexed even when removed from Privacy, so the source tree stays Spotlight-invisible either way).

I added it. Cached entries vanished. Spotlight returned only /Applications/MouseCatcher.app. The recovery flow worked, end to end, in three keystrokes.

The bigger question, briefly

I want to be honest about what kind of utility MouseCatcher is. It’s the workaround to a known limitation of a workaround. ActiveSpace’s virtual display is itself a workaround for an OS bug; the cursor fence is a workaround for the virtual display’s side effects; MouseCatcher is the recovery flow for the rare case where the fence misses.

That’s a tower of mitigations, and I notice I’ve been building a lot of those lately. I can defend this because the OS bug is real, the virtual is the smallest workaround that solves it, the fence covers the secondary issue cleanly, and MouseCatcher is a few hundred bytes of recovery for the residual edge case.

Each layer has a clear reason to exist. The user, on a single-display Mac running a recent macOS, gets instant space switching with no visible cost — and on the rare day the rent does come due, the recovery is a quick Spotlight search away.

That feels like an acceptable bargain.

It also feels like the kind of bargain you only notice you’ve struck when you’re sitting in front of a Mac at one in the morning, and your mouse pointer is gone, and you realise the recovery instructions on your own website assume the one capability you don’t currently have.

Now they don’t.