The OS Upgrade Tax

Apple Macintosh SE 30

Photo by Julian Hochgesang on Unsplash

I started the day thinking I’d commit a little ActiveSpace polish and maybe write a post about something gentle. Instead I spent the afternoon untangling three different ways macOS 26.5 (Tahoe) had quietly broken two different apps. 26.5 shipped earlier this month. I’d done my post-upgrade checks. I’d run my standard test suite. All seemed to be in order. But there were OS-level changes that broke a couple of the Jorvik apps, subtle changes, innocuous, devoid of headlines, but carrying a cost.

This is the tax. Every major macOS upgrade comes with a bill, and you sometimes get it months later, payable in WTFs.

Rainy Day stopped hiding the cursor

The setup: when the Rainy Day screensaver activates, the cursor needs to disappear. I had two mechanisms running, both of which used to work.

One was a CSS rule — * { cursor: none !important; } — inside the WKWebView that hosts the raindrop effect. WebKit honours this when it processes mouseMoved, so as long as the window has keyboard focus, the cursor inside the WebView is hidden.

The other was a fallback for the dual-display case, where only one of the two screensaver windows is ever key. An .activeAlways NSTrackingArea on the container view, calling NSCursor.set() on a 1x1 transparent NSCursor on every mouseEntered / mouseMoved. macOS delivers mouseMoved to non-key windows when the tracking area opts in, so the cursor gets hidden on every display regardless of key status.

Both mechanisms had been bulletproof since I shipped the dual-display fix. Today’s report from the user: cursor visible on laptop-only, on single-external, on dual-external. Every configuration tested.

The laptop-only case is the one that didn’t make sense. One window, that window is key, WebKit’s CSS rule should fire. It didn’t.

I don’t know exactly what Tahoe changed. The symptom is consistent with WebKit no longer honouring cursor: none at .screenSaver window level, or honouring it only when an element has explicit input focus, or some other shift in policy. I didn’t try to find out. I added a third path instead.

Three mechanisms layered, none bulletproof on its own:

  1. resetCursorRects() + addCursorRect() on the container view. The window-server-level cursor mechanism. The window server tracks the mouse independently of which window is key; if a view says “use this cursor in this rect,” that’s what the user sees. Works on every display.
  2. The existing tracking area, with one fix: NSImage(size:) alone doesn’t create a bitmap representation. NSCursor materialises a fallback when you hand it an image with zero reps, and that fallback may not be transparent. Drawing into the image with lockFocus() + a clear fill guarantees a transparent bitmap rep exists.
  3. CGDisplayHideCursor(CGMainDisplayID()) in the activate path, preceded by NSApp.activate(ignoringOtherApps: true). Rainy Day is LSUIElement — not a regular foreground app — and CGDisplayHideCursor requires the caller to be frontmost. NSApp.activate is allowed for accessory apps, even briefly, even invisibly. The hide call ref-counts, and the saver is covering the screen at the same instant as the activation, so nothing flickers.

The user tested. All three paths fired in the log. Cursor hidden across every configuration.

I left the diagnostic logging in until the fix was confirmed and stripped it after. The three-path stack stays.

Rainy Day couldn’t lock the screen on dismiss

Same app, different bug, found by reading the log while I was diagnosing the first one.

Rainy Day has a “lock on dismiss” option: when the saver tears down because of user input, the screen locks instead of revealing the desktop. The implementation synthesised the standard Lock Screen shortcut, controlcommandQ, by posting a CGEvent at the session event tap. This is the same path AppleScript uses under the hood when it asks System Events to fire a keystroke. It had worked since I added the feature.

The log told a much louder story. Thirteen LockScreen.lock() calls in ONE millisecond. Then four seconds of nothing. Then the safety-net timeout fired and tore the saver down without locking anything.

Two bugs at once.

The first was the burst. Each screensaver window has an NSEvent.addLocalMonitorForEvents watching for .mouseMoved, .keyDown, etc. When the user moves the mouse to dismiss, mouseMoved fires repeatedly — it’s per-pixel-of-motion. The monitor closure called onDismiss, which called dismissWindows, which called LockScreen.lock() and armed the lock observer. The monitor itself wasn’t removed until full teardown, which the lock path defers to the unlock observer. So every mouseMoved during the dismiss burst re-armed the whole flow. Thirteen attempts in a flick of the cursor.

Classic anti-pattern. Event monitor that triggers a one-shot teardown has to disarm itself before invoking the callback, not after. Fixing it took five lines of code.

The second bug is the Tahoe regression: none of those thirteen synthesised keystrokes reached loginwindow. The CGEvent was consumed by the session tap and went nowhere. Whether Tahoe is rejecting synthesised system shortcuts at a finer-grained policy layer, or whether controlcommandQ has been moved behind a new permission, I don’t know. It doesn’t work.

What does work is SACLockScreenImmediate, a private symbol in /System/Library/PrivateFrameworks/login.framework. It’s the IPC path Apple uses internally and what every other menu-bar lock app (Hammerspoon, Bear, etc.) actually calls. It doesn’t synthesise a keystroke — it just tells loginwindow to lock. No Accessibility permission required, because there’s no keystroke to require permission for.

Private API. Could be removed in a future macOS. I verified it’s alive in 26.5 via dlsym before committing to it, kept the existing 4-second safety-net timeout so a future removal degrades to “saver tears down without locking” rather than “user stuck in saver forever,” and moved on.

BrowserNotes put the note on the wrong screen

Different app, different shape of bug, same OS.

BrowserNotes shows a small panel listing the notes you have for the current page. The panel is positioned at the top-right of the browser viewport — not the top-right of the screen, the top-right of the browser. To do that it queries the Accessibility API for the focused browser window’s frame, converts those coordinates to AppKit’s coordinate space, and sets the panel’s origin.

The user reported: with the browser on the second display, the panel appears on the first.

The Accessibility API reports coordinates in a global space whose origin is at the top-left of the primary display (the one with the menu bar), y-down. AppKit reports coordinates in a global space whose origin is at the bottom-left of the primary display, y-up. The conversion is one flip about the primary display’s height. The math is simple if you use the right pivot.

The code used NSScreen.main.frame.height as the pivot. Sonoma documented NSScreen.main as “the screen containing the window with keyboard focus.” If the browser is the focused app on display 2, NSScreen.main is display 2, and — assuming both displays are the same height, which they were in the report — the math accidentally works out.

Tahoe changed the semantics. NSScreen.main now returns the primary display (display 1) regardless of where the focused window is.

Even that wouldn’t have been fatal on equal-height displays, because the y math still gave the right answer. The y-flip bug was hiding. The actual visible bug was a different line: a min() clamp to keep the panel from spilling past the right edge of the display, using NSScreen.main.visibleFrame.maxX. On Tahoe that maxX is display 1’s, so the clamp dragged any x past display 1’s right edge back onto display 1. Browser on display 2 at x=2900, panel target x=3540, clamp pulls it back to 2200. The panel lands on display 1.

Two bugs, one symptom, one Tahoe regression masking the other.

The fix is a helper — axRectToAppKit(_:) that pivots on NSScreen.screens.first.frame.height (the actual anchor of the AX coord space, always, no matter what NSScreen.main reports), and screenContaining(_:) to find the NSScreen whose frame actually contains a given point. The page-highlights HUD now finds the browser’s real screen and clamps against its visible frame. The same y-flip bug existed in two other panels in BrowserNotes; I fixed both defensively.

The point

Two apps. One OS upgrade. Three completely different shapes of breakage:

None of these would have shown up in a feature test. Each one needs the specific combination of: this app, this OS version, this user setup. The cursor bug was easy because the user has dual displays so I had a lot of surface. The BrowserNotes bug was invisible until the user happened to drag the browser onto the second screen. The lock-screen bug was hidden behind a 4-second safety-net timeout that tore the saver down without anyone noticing it had never actually locked.

You don’t find these by running tests. You find them by being the user, or by having a user who tells you. Today I had both. Thank you, dual-screen setup and the user who pays attention.

This is what shipping a suite means. Every major macOS release is N small regressions in N different corners of N different apps, and you pay the bill in afternoons like this one.

Tahoe 26.5 was supposed to be a quiet release. It mostly is. The work is in finding the “mostly.”

I could be wrong

It’s entirely possible that I have misinterpreted things. I am not, by any reasonable measure, a macOS/Swift/SwiftC expert. I’m learning as I go. I may have misread or not understood the documentation. I use Github sources as inspiration/guidance for much of my code — I may well have copy-pasted code without fully understanding it. I own the bugs in my code. OS upgrades just surface issues that are already there.

In fairness

If you’ve read more than one of my posts you might come away thinking I have it in for macOS. I don’t.

Software evolves. APIs deprecate. Documentation drifts. That’s true of every platform with this much surface area, and complaining about it would be like complaining that the tide comes in. I’m not. I’m just writing down what I find when I trip over it, because the tripping-over is where the interesting detail lives.

The blog is a logbook. The wins go in too — they just don’t write themselves up as easily as the breakages, because “thing worked exactly as documented” isn’t much of a story.

I like macOS. I enjoy building for it. Posts like this one are notes from the field, not complaints from the cheap seats. If they ever read otherwise, that’s on me, not on Apple.